feat: dev-5.1 分支基线(v1.3.35 发版后代码 + 5.1 声道改造文档)

- 以 main(ccc65af) 为基线提交当前工作区代码
- 新增 5.1 声道改造文档两份
- .gitignore 补充 .vscode/、.screenshots/
This commit is contained in:
freeboss
2026-11-02 19:25:12 +08:00
parent ccc65afcbc
commit b7b5518834
1394 changed files with 13694 additions and 3710 deletions

167
test/voice_helper/README.md Normal file
View File

@@ -0,0 +1,167 @@
# 语音协议广播测试脚本
`adb shell am broadcast` 形式的联调脚本集合,覆盖 [可行性评估文档](../../../docs/2026-08-22-语音影视接入可行性评估.md) §6.1 中定义的统一协议(`action=com.pukka.jiecheng.action.VOICE_COMMAND`),用于在没有第三方语音 App 时手工驱动 voice 模块。
> 适用范围:`bash`(Git Bash 即可)。脚本里所有路径用 Unix 风格。
## 1. 目录结构
```
test/voice_helper/
├── lib/
│ └── common.sh # 公共函数:payload 构造 / 广播发送 / 应用级操作
├── jump_film.sh # 单片跳转 JUMP/FILM
├── jump_category.sh # 分类跳转 JUMP/CATEGORY
├── search.sh # 语音搜索 SEARCH
├── control.sh # 播放控制 CONTROL/<action>
├── cold_start.sh # 冷启动单片跳转(force-stop + 投递 + 拉起)
├── throttle_test.sh # 2 秒内连续投递同类命令(验证节流)
├── security_test.sh # 非白名单 sourceApp 投递(验证签名校验)
├── listen_callback.sh # 反向广播回调监听(adb logcat)
├── run_all.sh # 顺序执行全部用例
└── README.md
```
## 2. 协议约定(与文档 §6.1 一致)
```
action: com.pukka.jiecheng.action.VOICE_COMMAND
permission: com.pukka.jiecheng.permission.VOICE_COMMAND
component: com.pukka.jiecheng/.voice.receiver.VoiceCommandReceiver
extras:
requestId string 第三方生成的请求 ID,回传时带回
sourceApp string 第三方包名
sourcePlatform string 第三方平台标识(用于选择 VendorAParser/BParser)
commandType string JUMP / SEARCH / CONTROL
payload string JSON,业务字段
callbackAction string 反向广播 action,由第三方传入
timestamp long 毫秒
```
`payload` 按 `commandType` 分支:
| commandType | payload 字段 |
|---|---|
| `JUMP` | `jumpType` (`FILM`/`CATEGORY`/`ACTOR`/`DIRECTOR`) + 对应 `*Id`/`*Name`,可带 `autoPlay` |
| `SEARCH` | `filmName` / `actorName` / `directorName` / `category` / `unionStyle` / `region` / `releaseDate` |
| `CONTROL`| `action` (`PLAY`/`PAUSE`/`FULLSCREEN_ENTER`/`FULLSCREEN_EXIT`/`SEEK_FORWARD`/`SEEK_BACKWARD`/`SEEK_TO`/`PREV`/`NEXT`/`SELECT_INDEX`/`PAGE_NEXT`/`PAGE_PREV`/`GOTO_PAGE`) + `seconds` / `index` / `page` |
## 3. 快速开始
```bash
# 设备已连接 adb 后:
cd test/voice_helper
# 单片跳转(默认 filmId=film-demo-001 / autoPlay=true)
./jump_film.sh
# 自定义参数
./jump_film.sh film-12345 "我的影片" true
# 分类跳转
./jump_category.sh cat-action "动作片"
# 语音搜索
./search.sh "流浪地球"
./search.sh "流浪地球" "吴京"
# 播放控制
./control.sh PAUSE
./control.sh SEEK_FORWARD seconds=10
./control.sh SEEK_TO seconds=300
./control.sh SELECT_INDEX index=3
./control.sh GOTO_PAGE page=2
# 冷启动(force-stop → 投递 → 拉起 MainActivity)
./cold_start.sh
# 2 秒内连续 5 次 PAUSE,验证节流
./throttle_test.sh PAUSE 5
# 非白名单 sourceApp 投递(验证拦截)
./security_test.sh com.attacker.malicious
# 反向广播监听(需 voice 模块按 tag 打印回调日志,tag 默认 VoiceCommand)
./listen_callback.sh # 监听全部
./listen_callback.sh vt-1756000000-12345 # 只过滤指定 requestId
# 一键全量
./run_all.sh
```
## 4. 环境变量
| 变量 | 默认值 | 说明 |
|---|---|---|
| `ACTION_VOICE_COMMAND` | `com.pukka.jiecheng.action.VOICE_COMMAND` | 广播 action |
| `PERMISSION_VOICE` | `com.pukka.jiecheng.permission.VOICE_COMMAND` | 协议权限 |
| `RECEIVER_COMPONENT` | `com.pukka.jiecheng/.voice.receiver.VoiceCommandReceiver` | 显式组件 |
| `MAIN_ACTIVITY_COMPONENT` | `com.pukka.jiecheng/.MainActivity` | 冷启动入口 |
| `TEST_APP_PKG` | `com.pukka.jiecheng` | 应用包名 |
| `DEFAULT_SOURCE_APP` | `com.test.voicehelper` | 模拟第三方包名 |
| `DEFAULT_PLATFORM` | `vendorA` | 模拟平台 |
| `DEFAULT_CALLBACK_ACTION`| `com.test.voicehelper.action.VOICE_CALLBACK` | 反向广播 action |
| `TEST_REQUEST_PREFIX` | `vt-` | requestId 前缀 |
| `SKIP_PERMISSION_CHECK` | `0` | 开发期临时绕开 signature-level permission 校验时置 1 |
| `LOG_TAG` | `VoiceCommand` | `listen_callback.sh` 监听的 logcat tag |
示例:自定义第三方包名
```bash
TEST_REQUEST_PREFIX=demo- DEFAULT_SOURCE_APP=com.xiaomi.voice ./jump_film.sh
```
## 5. 关于 `signature-level permission`
文档 §6.2 规定 Receiver 使用自定义 `signature-level permission`(`com.pukka.jiecheng.permission.VOICE_COMMAND`)。`adb shell` 默认以 `shell` 用户身份投递,**不持有第三方签名权限**,广播会被系统直接丢弃。
联调期三种处理方式:
1. **临时降级(推荐)**:在 `app/build.gradle` 给 `debug` flavor 注入 `manifestPlaceholder`:
```groovy
debug {
manifestPlaceholders = [voiceProtectionLevel: "normal"]
}
release {
manifestPlaceholders = [voiceProtectionLevel: "signature"]
}
```
然后在 `AndroidManifest.xml`:
```xml
<permission
android:name="com.pukka.jiecheng.permission.VOICE_COMMAND"
android:protectionLevel="${voiceProtectionLevel}" />
```
2. **拷贝脚本到端内**:在应用内临时放一个 `BroadcastSender` Activity 直接发广播,绕过 shell 权限。
3. **改 adb 用户**:通过 `adb root` 切到 root 身份,仅在 userdebug 镜像生效。
脚本本身不强制任何一种处理,遇到权限拦截请按上面方式放开。
## 6. 验证 checklist(对应文档 §12)
联调完成后逐项打勾:
- [ ] 应用未启动时第三方冷启动并执行命令(`cold_start.sh`)
- [ ] 应用未激活时被守卫拦截,回传 `DEVICE_NOT_ACTIVATED`
- [ ] 正在播放时收到搜索 / 分类命令先退出再跳转
- [ ] 2 秒内连续多次控制命令只保留最后一条(`throttle_test.sh`)
- [ ] 第三方收到成功 / 失败回传(`listen_callback.sh` 配合 logcat)
- [ ] `SELECT_INDEX` / `GOTO_PAGE` 在播放页列表中正确选中
- [ ] 白名单外的 `sourceApp` 被拦截、不回传(`security_test.sh`)
## 7. 调试技巧
```bash
# 查看应用包内注册的 Receiver
adb shell dumpsys package com.pukka.jiecheng | grep -i receiver
# 查看设备上所有待发广播
adb shell dumpsys activity broadcasts
# 查看最近一次 am broadcast 的返回结果(带 requestId 过滤)
adb shell am broadcast -a com.pukka.jiecheng.action.VOICE_COMMAND -n ... --es requestId probe
# 实时过滤 voice 模块日志
adb logcat -v threadtime VoiceCommand:V VoiceCommandLogger:V "*:S"
```

View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# cold_start.sh - 冷启动单片跳转测试
# 流程: force-stop -> 投递协议 -> 第三方拉起 MainActivity -> 等待启动链路完成 -> 排队命令回放
# 用法: cold_start.sh [filmId] [filmName]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/lib/common.sh"
require_adb
check_receiver_exists
FILM_ID="${1:-film-demo-001}"
FILM_NAME="${2:-冷启动测试片}"
print_header "冷启动单片跳转测试"
# 1. 强制停止应用,模拟完全未启动状态
force_stop_app
sleep 1
# 2. 第三方先投递协议(应用未启动,命令应进入 CommandQueue 排队)
REQUEST_ID=$(gen_request_id)
PAYLOAD=$(build_jump_payload FILM \
"filmId=${FILM_ID}" \
"filmName=${FILM_NAME}" \
"autoPlay=true")
log_info "[1/4] 应用未启动时投递协议"
send_voice_broadcast "JUMP" "${PAYLOAD}" "${REQUEST_ID}"
# 3. 第三方再显式拉起 MainActivity
sleep 1
log_info "[2/4] 第三方显式拉起 MainActivity"
launch_main_activity
# 4. 等待启动链路完成(激活 / 升级 / 会员校验)
log_info "[3/4] 等待启动链路完成"
wait_for_app_ready 15000 || true
# 5. 给排队命令回放留出时间
log_info "[4/4] 等待排队命令消费(约 5 秒)"
sleep 5
log_ok "冷启动测试结束"
log_info "requestId=${REQUEST_ID},请人工验证 VideoDetailActivity 是否进入"

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# control.sh - 播放控制测试
# 用法: control.sh <action> [k=v ...]
# action 取值(与文档 §6.1 CONTROL action 字段保持一致):
# PLAY | PAUSE | FULLSCREEN_ENTER | FULLSCREEN_EXIT |
# SEEK_FORWARD | SEEK_BACKWARD | SEEK_TO |
# PREV | NEXT | SELECT_INDEX | PAGE_NEXT | PAGE_PREV | GOTO_PAGE
#
# 示例:
# control.sh PLAY
# control.sh SEEK_FORWARD seconds=10
# control.sh SEEK_TO seconds=300
# control.sh SELECT_INDEX index=3
# control.sh GOTO_PAGE page=2
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/lib/common.sh"
require_adb
check_receiver_exists
if [ $# -lt 1 ]; then
log_err "缺少 action"
echo "用法: $0 <action> [k=v ...]" >&2
exit 1
fi
ACTION="$1"; shift
print_header "播放控制 CONTROL/${ACTION}"
PAYLOAD=$(build_control_payload "${ACTION}" "$@")
send_voice_broadcast "CONTROL" "${PAYLOAD}"
log_ok "预期在播放页执行 ${ACTION}"

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# device.sh - 测试目标设备配置
# 所有 voice_helper 下的脚本通过 lib/common.sh 自动 source 本文件
#
# 用法:
# 多设备场景:把 ADB_SERIAL 改成目标设备的 serial
# 单设备场景:留空(注释掉 export 行),common.sh 会自动选用唯一在线设备
#
# 优先级:命令行 export > 本文件 > common.sh 默认空
# ADB_SERIAL=xxx ./jump_film.sh # 命令行覆盖本文件
# 当前测试机:1f66b893154cf507
ADB_SERIAL="${ADB_SERIAL:-1f66b893154cf507}"
export ADB_SERIAL

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# jump_category.sh - 分类跳转测试
# 用法: jump_category.sh [categoryId] [categoryName]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/lib/common.sh"
require_adb
check_receiver_exists
CATEGORY_ID="${1:-cat-demo-001}"
CATEGORY_NAME="${2:-动作片}"
print_header "分类跳转 JUMP/CATEGORY"
PAYLOAD=$(build_jump_payload CATEGORY \
"categoryId=${CATEGORY_ID}" \
"categoryName=${CATEGORY_NAME}")
send_voice_broadcast "JUMP" "${PAYLOAD}"
log_ok "预期进入 ListActivity 并加载该分类"

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# jump_film.sh - 单片跳转测试
# 用法: jump_film.sh [filmId] [filmName] [autoPlay]
# filmId 默认 film-demo-001
# filmName 默认等于 filmId(仅一个参数时避免重复输入)
# autoPlay true / false,默认 true
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/lib/common.sh"
require_adb
check_receiver_exists
FILM_ID="${1:-film-demo-001}"
# 缺省 filmName 时与 filmId 同名,避免用户重复输入
FILM_NAME="${2:-${FILM_ID}}"
AUTO_PLAY="${3:-true}"
print_header "单片跳转 JUMP/FILM"
PAYLOAD=$(build_jump_payload FILM \
"filmId=${FILM_ID}" \
"filmName=${FILM_NAME}" \
"autoPlay=${AUTO_PLAY}")
send_voice_broadcast "JUMP" "${PAYLOAD}"
log_ok "预期进入 VideoDetailActivity(autoPlay=${AUTO_PLAY})"

View File

@@ -0,0 +1,342 @@
#!/usr/bin/env bash
# common.sh - 语音协议广播测试公共函数
# 其他脚本通过 source "$(dirname "$0")/lib/common.sh" 加载
#
# 协议参考 docs/2026-08-22-语音影视接入可行性评估.md §6.1
set -o pipefail
# 加载 device.sh 设备配置(位于 common.sh 同级父目录)
# 必须在协议常量前 source,确保 ADB_SERIAL 已被设置
_DVH_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -f "${_DVH_LIB_DIR}/../device.sh" ]; then
# shellcheck disable=SC1091
source "${_DVH_LIB_DIR}/../device.sh"
fi
unset _DVH_LIB_DIR
# ===== 协议常量(与文档 §6.1 保持一致)=====
: "${ACTION_VOICE_COMMAND:=com.pukka.jiecheng.action.VOICE_COMMAND}"
: "${PERMISSION_VOICE:=com.pukka.jiecheng.permission.VOICE_COMMAND}"
# 应用 applicationId:默认商用版(tv.huashi.jetsenx.jetsenfilx.jsttv);
# 家用版 APK 安装时通过 APP_PKG=tv.huashi.jetsenx.jetsenfilx.jsttv.home 覆盖。
# 下方 RECEIVER_COMPONENT / SKYWORTH_RECEIVER_COMPONENT / TEST_APP_PKG 都基于 APP_PKG 派生。
: "${APP_PKG:=tv.huashi.jetsenx.jetsenfilx.jsttv}"
: "${RECEIVER_COMPONENT:=${APP_PKG}/com.pukka.jiecheng.voice.receiver.VoiceCommandReceiver}"
: "${TEST_APP_PKG:=${APP_PKG}}"
: "${MAIN_ACTIVITY_COMPONENT:=com.pukka.jiecheng/.MainActivity}"
: "${DEFAULT_SOURCE_APP:=com.test.voicehelper}"
: "${DEFAULT_PLATFORM:=vendorA}"
: "${DEFAULT_CALLBACK_ACTION:=com.test.voicehelper.action.VOICE_CALLBACK}"
: "${TEST_REQUEST_PREFIX:=vt-}"
# ===== 创维(skyworth)广播常量(SkyworthVoiceAdapter.java)=====
# action 与 extras key 与 SkyworthVoiceAdapter.ACTION_VOD / EXTRA_VIDEO_LIST 保持一致
: "${ACTION_SKYWORTH_VOD:=com.skyworth.smarthotel.action.voice.RecognizeResult.vod}"
: "${SKYWORTH_RECEIVER_COMPONENT:=${APP_PKG}/com.pukka.jiecheng.voice.adapter.SkyworthVoiceAdapter}"
# 白名单 sender(SkyworthVoiceAdapter.SENDER_WHITELIST),adb 投递时通过 --p 指定
# 发送方包名模拟创维 SDK,绕开白名单校验的测试见 skyworth_security_test.sh
: "${DEFAULT_SKYWORTH_SENDER:=com.skyworth.smarthotel}"
# 当开发期把 permission 临时降到 normal / 关闭时可置 1
: "${SKIP_PERMISSION_CHECK:=0}"
# 当本机 adb 上挂了多台设备时,通过 ADB_SERIAL 指定唯一目标,避免脚本误连
: "${ADB_SERIAL:=}"
if [ -n "${ADB_SERIAL}" ]; then
ADB="adb -s ${ADB_SERIAL}"
else
ADB="adb"
fi
# ===== 颜色输出(仅在交互终端生效)=====
if [ -t 1 ] && command -v tput >/dev/null 2>&1 && [ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]; then
C_RED=$(tput setaf 1); C_GRN=$(tput setaf 2); C_YEL=$(tput setaf 3)
C_CYN=$(tput setaf 6); C_RST=$(tput sgr0)
else
C_RED=""; C_GRN=""; C_YEL=""; C_CYN=""; C_RST=""
fi
log_info() { printf "%s[INFO]%s %s\n" "${C_CYN}" "${C_RST}" "$*"; }
log_ok() { printf "%s[OK]%s %s\n" "${C_GRN}" "${C_RST}" "$*"; }
log_warn() { printf "%s[WARN]%s %s\n" "${C_YEL}" "${C_RST}" "$*"; }
log_err() { printf "%s[ERR]%s %s\n" "${C_RED}" "${C_RST}" "$*" >&2; }
# ===== 校验 =====
require_adb() {
if ! command -v adb >/dev/null 2>&1; then
log_err "未找到 adb,请安装 Android Platform Tools 后重试"
exit 1
fi
if ! ${ADB} get-state >/dev/null 2>&1; then
log_err "adb 未连接设备,请先执行 adb connect / adb devices"
exit 1
fi
# 解析 adb devices 输出,仅保留 device 状态(排除 unauthorized / offline)
local online count
online=$(${ADB} devices | awk 'NR>1 && $2=="device" {print $1}')
count=$(printf '%s\n' "${online}" | grep -c .)
if [ -n "${ADB_SERIAL}" ]; then
if ! printf '%s\n' "${online}" | grep -qx "${ADB_SERIAL}"; then
log_err "指定的 ADB_SERIAL=${ADB_SERIAL} 未处于 device 状态,请检查 adb devices"
exit 1
fi
elif [ "${count}" -gt 1 ]; then
log_err "检测到 ${count} 个在线设备,请先 export ADB_SERIAL=<serial> 指定目标设备,例如:"
log_err " ADB_SERIAL=$(printf '%s' "${online}" | head -n1) ./jump_film.sh \"镖人\" true"
exit 1
else
# 只有一个设备时默认使用它,避免每次都要 export
export ADB_SERIAL="${online}"
log_info "自动选择唯一在线设备:${ADB_SERIAL}"
fi
}
check_receiver_exists() {
local found
found=$(${ADB} shell dumpsys package "${TEST_APP_PKG}" 2>/dev/null \
| grep -i "VoiceCommandReceiver" || true)
if [ -z "${found}" ]; then
log_warn "VoiceCommandReceiver 未在 ${TEST_APP_PKG} 中注册"
log_warn "请确认 voice 模块已实现并打包进 APK,预期 Broadcast 会被丢弃"
fi
}
# 校验创维 Adapter 已注册(Manifest 中 SkyworthVoiceAdapter + action)
check_skyworth_adapter_exists() {
local found
found=$(${ADB} shell dumpsys package "${TEST_APP_PKG}" 2>/dev/null \
| grep -i "SkyworthVoiceAdapter" || true)
if [ -z "${found}" ]; then
log_warn "SkyworthVoiceAdapter 未在 ${TEST_APP_PKG} 中注册"
log_warn "请确认创维语音 Adapter 已打包进 APK,预期 Broadcast 会被丢弃"
fi
}
# ===== 工具函数 =====
gen_request_id() {
local rand
rand=$(printf '%05d' "$((RANDOM % 100000))")
printf '%s%d-%s' "${TEST_REQUEST_PREFIX}" "$(date +%s)" "${rand}"
}
json_escape() {
local v="$1"
v="${v//\\/\\\\}"
v="${v//\"/\\\"}"
printf '%s' "${v}"
}
# JUMP / SEARCH payload(全部字段均为字符串)
# 用法: build_jump_payload "FILM" "filmId=xxx" "filmName=yyy" "autoPlay=true"
build_jump_payload() {
local jump_type="$1"; shift
local first=1 json="{"
json="${json}\"jumpType\":\"${jump_type}\""
while [ $# -gt 0 ]; do
local kv="$1" key val
key="${kv%%=*}"
val="${kv#*=}"
val=$(json_escape "${val}")
json="${json},\"${key}\":\"${val}\""
shift
done
json="${json}}"
printf '%s' "${json}"
}
build_search_payload() {
local first=1 json="{"
while [ $# -gt 0 ]; do
local kv="$1" key val
key="${kv%%=*}"
val="${kv#*=}"
val=$(json_escape "${val}")
if [ $first -eq 1 ]; then first=0; else json="${json},"; fi
json="${json}\"${key}\":\"${val}\""
shift
done
json="${json}}"
printf '%s' "${json}"
}
# CONTROL payload:第一个参数是 action,其余 key=value 中 value 视为数字字面量
# 用法: build_control_payload "PAUSE"
# build_control_payload "SEEK_FORWARD" "seconds=10"
build_control_payload() {
local action="$1"; shift
local json="{\"action\":\"${action}\""
while [ $# -gt 0 ]; do
local kv="$1" key val
key="${kv%%=*}"
val="${kv#*=}"
json="${json},\"${key}\":${val}"
shift
done
json="${json}}"
printf '%s' "${json}"
}
# ===== 核心发送函数 =====
# 用法: send_voice_broadcast <commandType> <payloadJson> [requestId] [sourceApp] [sourcePlatform] [callbackAction]
send_voice_broadcast() {
local command_type="$1"
local payload_json="$2"
local request_id="${3:-$(gen_request_id)}"
local source_app="${4:-${DEFAULT_SOURCE_APP}}"
local source_platform="${5:-${DEFAULT_PLATFORM}}"
local callback_action="${6:-${DEFAULT_CALLBACK_ACTION}}"
require_adb
local timestamp
timestamp=$(date +%s%3N 2>/dev/null || echo $(( $(date +%s) * 1000 )))
log_info "commandType=${command_type} requestId=${request_id}"
log_info "sourceApp=${source_app} platform=${source_platform}"
log_info "payload=${payload_json}"
# adb shell 接收一个字符串后整体交给设备 shell 执行,避免 adb 自身再做 token 化
${ADB} shell "am broadcast \
-a '${ACTION_VOICE_COMMAND}' \
-n '${RECEIVER_COMPONENT}' \
--es requestId '${request_id}' \
--es sourceApp '${source_app}' \
--es sourcePlatform '${source_platform}' \
--es commandType '${command_type}' \
--es payload '${payload_json}' \
--es callbackAction '${callback_action}' \
--el timestamp '${timestamp}' \
--include-stopped-packages" 2>&1 | sed 's/^/ [adb] /'
log_ok "已投递(requestId=${request_id})"
}
# ===== 创维(skyworth)广播 =====
# 模拟创维 SDK 投递 RecognizeResult.vod 广播给 SkyworthVoiceAdapter。
#
# 关于 videoList 的说明:
# 创维 SDK 用 --es 投递 JSON 数组字符串(与 SkyworthVoiceAdapter.resolveVideoList 对应):
# am broadcast -a com.skyworth.smarthotel.action.voice.RecognizeResult.vod \
# --es videoList '["哪吒之魔童降世","我是哪吒"]'
# Adapter 取第一个非空元素,多值场景可模拟创维候选列表。
#
# 可选参数(通过环境变量控制,均为可选):
# SKYWORTH_SKIP_COMPONENT=1 省略 -n 参数(不显式指定 Receiver 组件)
# SKYWORTH_SKIP_SENDER=1 省略 -p 参数(不指定 sender 包名,Adapter 内部用白名单默认值兜底)
#
# 用法: send_skyworth_broadcast <filmName> [senderPkg] [extraKv...]
# filmName 候选影片名(videoList[0])
# senderPkg 发送方包名,默认 DEFAULT_SKYWORTH_SENDER(白名单内);传空串或 SKYWORTH_SKIP_SENDER=1 时省略 -p
# extraKv 透传给 am broadcast 的附加 --es 参数,形如 "key=value"
send_skyworth_broadcast() {
local film_name="$1"
local sender_pkg="${2:-${DEFAULT_SKYWORTH_SENDER}}"
shift 2 2>/dev/null || shift $# # 兼容只传 filmName 的情况
require_adb
# videoList 为 JSON 数组字符串(--es),与创维 SDK 真实投递格式一致
local video_list_json
video_list_json="[\"$(json_escape "${film_name}")\"]"
log_info "action=${ACTION_SKYWORTH_VOD}"
if [ -n "${SKYWORTH_SKIP_SENDER}" ] || [ -z "${sender_pkg}" ]; then
log_info "sender=SKIP(-p omitted) videoList=${video_list_json}"
else
log_info "sender=${sender_pkg} videoList=${video_list_json}"
fi
if [ -n "${SKYWORTH_SKIP_COMPONENT}" ]; then
log_info "component=SKIP(-n omitted)"
fi
# 附加 extras(可选):默认注入 autoPlay=true(创维链路默认自动播片)
# 用户传的 extraKv 会追加在后面,按 am broadcast 规则后者覆盖前者,
# 因此调用方通过 "autoPlay=false" 即可临时关闭自动播放。
local extra_args="--es autoPlay 'true' "
local kv
for kv in "$@"; do
local key val
key="${kv%%=*}"
val="${kv#*=}"
extra_args="${extra_args}--es ${key} '${val}' "
done
# 组装 -n 和 -p(按开关可选)
local n_arg=""
local p_arg=""
if [ -z "${SKYWORTH_SKIP_COMPONENT}" ]; then
n_arg="-n '${SKYWORTH_RECEIVER_COMPONENT}' "
fi
if [ -z "${SKYWORTH_SKIP_SENDER}" ] && [ -n "${sender_pkg}" ]; then
p_arg="-p '${sender_pkg}' "
fi
${ADB} shell "am broadcast \
-a '${ACTION_SKYWORTH_VOD}' \
${n_arg}${p_arg}--es videoList '${video_list_json}' \
${extra_args}--include-stopped-packages" 2>&1 | sed 's/^/ [adb] /'
log_ok "已投递创维 VOD 广播(filmName=${film_name})"
}
# ===== 应用级操作 =====
launch_main_activity() {
log_info "拉起 MainActivity:${MAIN_ACTIVITY_COMPONENT}"
${ADB} shell am start -n "${MAIN_ACTIVITY_COMPONENT}" >/dev/null 2>&1
}
force_stop_app() {
log_info "强制停止应用:${TEST_APP_PKG}"
${ADB} shell am force-stop "${TEST_APP_PKG}" >/dev/null 2>&1
}
# 验证当前前台 Activity 是否为预期页面(按类名片段匹配)
# 用法: wait_for_activity <ActivitySimpleName> [timeoutMs]
# 返回 0 表示命中,1 表示超时
wait_for_activity() {
local activity_name="${1}"
local timeout_ms="${2:-10000}"
local interval_ms=500 elapsed=0
log_info "等待前台 Activity 命中 ${activity_name}(最长 ${timeout_ms}ms)..."
while [ ${elapsed} -lt "${timeout_ms}" ]; do
local top
top=$(${ADB} shell "dumpsys window 2>/dev/null | grep -oE 'mCurrentFocus=[^}]+' | head -n 1" || true)
case "${top}" in
*"${activity_name}"*)
log_ok "前台命中:${top}"
return 0
;;
esac
sleep "$(awk "BEGIN { print ${interval_ms}/1000 }")"
elapsed=$((elapsed + interval_ms))
done
log_warn "等待超时(${timeout_ms}ms),当前前台:$(${ADB} shell "dumpsys window 2>/dev/null | grep -oE 'mCurrentFocus=[^}]+' | head -n 1" || true)"
return 1
}
wait_for_app_ready() {
local timeout_ms="${1:-15000}"
local interval_ms=300 elapsed=0
log_info "等待应用启动链路完成(最长 ${timeout_ms}ms)..."
while [ ${elapsed} -lt "${timeout_ms}" ]; do
local top
top=$(${ADB} shell "dumpsys window 2>/dev/null | grep -oE 'mCurrentFocus=[^}]+' | head -n 1" || true)
case "${top}" in
*"${TEST_APP_PKG}"* | *"com.pukka.jiecheng"* | *"${MAIN_ACTIVITY_COMPONENT%%/*}"*)
log_ok "应用已就绪:${top}"
return 0
;;
esac
sleep "$(awk "BEGIN { print ${interval_ms}/1000 }")"
elapsed=$((elapsed + interval_ms))
done
log_warn "等待超时(${timeout_ms}ms),继续执行"
return 1
}
print_header() {
printf "\n%s================ %s ================%s\n" "${C_CYN}" "$*" "${C_RST}"
}

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# listen_callback.sh - 监听反向广播回调
# 用法: listen_callback.sh [requestId]
# 不传 requestId: 监听所有 VoiceCommand 相关日志
# 传入 requestId: 仅过滤该请求的回调日志
#
# 实现:adb logcat 过滤,由 voice 模块按 requestId 写入 logcat(推荐 tag: VoiceCommand)
# 也可通过 dumpsys activity broadcasts 查看待发/已发广播
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/lib/common.sh"
require_adb
REQUEST_ID="${1:-}"
FILTER_TAG="${LOG_TAG:-VoiceCommand}"
print_header "监听反向广播回调"
log_info "tag=${FILTER_TAG} requestId=${REQUEST_ID:-<全部>}"
log_info "退出: Ctrl+C"
log_warn "请确认 voice 模块在回传时调用 Log.i(\"${FILTER_TAG}\", ...) 打印 requestId/resultCode"
adb logcat -c
${ADB:-adb} logcat -v threadtime "${FILTER_TAG}:V" "*:S" \
| while IFS= read -r line; do
if [ -z "${REQUEST_ID}" ] || [[ "${line}" == *"${REQUEST_ID}"* ]]; then
printf "%s[CALLBACK]%s %s\n" "${C_GRN}" "${C_RST}" "${line}"
fi
done

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# run_all.sh - 一键顺序执行全部用例
# 适用于联调验证:冷启动 -> 单片跳转(应进入播放页)-> 节流 -> 全屏 -> 分类跳转 -> 搜索 -> 安全 -> 创维链路
# 用法: run_all.sh [all|unified|skyworth]
# all 全部执行(默认,统一协议 + 创维)
# unified 只跑统一协议链路(VoiceCommandReceiver)
# skyworth 只跑创维链路(SkyworthVoiceAdapter)
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/lib/common.sh"
require_adb
check_receiver_exists
SCOPE="${1:-all}"
run_unified() {
print_header "Run All: 冷启动 -> 单片跳转 -> 节流 -> 全屏 -> 分类 -> 搜索 -> 安全"
log_warn "各步骤之间会 sleep 等待页面稳定,请勿中途打断"
# 1. 冷启动单片跳转(force-stop + 投递 + 拉起)
"${SCRIPT_DIR}/cold_start.sh"
sleep 5
# 2. 节流测试(需在播放页)
"${SCRIPT_DIR}/throttle_test.sh" PAUSE 5
sleep 2
# 3. 全屏切换
"${SCRIPT_DIR}/control.sh" FULLSCREEN_ENTER
sleep 2
"${SCRIPT_DIR}/control.sh" FULLSCREEN_EXIT
sleep 2
# 4. 分类跳转(会退出播放态)
"${SCRIPT_DIR}/jump_category.sh"
sleep 3
# 5. 语音搜索
"${SCRIPT_DIR}/search.sh" "测试片"
sleep 3
# 6. 安全校验(非白名单 sourceApp)
"${SCRIPT_DIR}/security_test.sh"
sleep 1
}
run_skyworth() {
print_header "Run Skyworth: 创维 VOD 链路(正常 -> 空候选 -> 多候选 -> 安全 -> 冷启动)"
# 1. 正常点播(白名单 sender + 有效片名)
"${SCRIPT_DIR}/skyworth_vod.sh"
sleep 3
# 2. 空候选(应 dropped)
"${SCRIPT_DIR}/skyworth_empty_test.sh" empty
sleep 2
# 3. 多候选跳空(应取首个非空)
"${SCRIPT_DIR}/skyworth_empty_test.sh" multi
sleep 3
# 4. 安全校验(非白名单 sender,应 dropped)
"${SCRIPT_DIR}/skyworth_security_test.sh"
sleep 2
# 5. 冷启动(force-stop + 排队 + 回放)
"${SCRIPT_DIR}/skyworth_cold_start.sh"
}
case "${SCOPE}" in
unified) run_unified ;;
skyworth) run_skyworth ;;
all)
run_unified
sleep 3
run_skyworth
;;
*)
echo "用法: $0 [all|unified|skyworth]" >&2
exit 1
;;
esac
print_header "全部用例执行完毕"
log_ok "请结合 logcat / dumpsys 验证执行链路与回传"

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# search.sh - 语音搜索测试
# 用法: search.sh <filmName> [actorName] [directorName] [category] [unionStyle] [region] [releaseDate]
# 示例: search.sh "流浪地球"
# search.sh "流浪地球" "吴京"
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/lib/common.sh"
require_adb
check_receiver_exists
if [ $# -lt 1 ]; then
log_err "至少需要一个搜索关键词"
echo "用法: $0 <filmName> [actorName] [directorName] [category] [unionStyle] [region] [releaseDate]" >&2
exit 1
fi
# 按协议 §6.1 SEARCH payload 字段顺序做位置映射
KEYS=(filmName actorName directorName category unionStyle region releaseDate)
print_header "语音搜索 SEARCH"
ARGS=()
for i in $(seq 1 $#); do
idx=$((i - 1))
if [ $idx -lt ${#KEYS[@]} ]; then
val="${!i}"
if [ -n "${val}" ]; then
ARGS+=("${KEYS[$idx]}=${val}")
fi
fi
done
PAYLOAD=$(build_search_payload "${ARGS[@]}")
send_voice_broadcast "SEARCH" "${PAYLOAD}"
log_ok "预期进入 SearchActivity 并自动搜索,焦点落首张结果卡片"

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# security_test.sh - 安全校验测试
# 用一个非白名单 sourceApp 投递协议,验证被守卫拦截(文档 §6.2)
# 用法: security_test.sh [fakeSourceApp]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/lib/common.sh"
require_adb
FAKE_APP="${1:-com.attacker.malicious}"
print_header "安全校验测试(sourceApp=${FAKE_APP})"
PAYLOAD=$(build_jump_payload FILM \
"filmId=hack-001" \
"filmName=白盒测试片")
send_voice_broadcast "JUMP" "${PAYLOAD}" "$(gen_request_id)" "${FAKE_APP}"
log_ok "投递完成,预期 sourceApp 未在白名单时被丢弃,不回传结果"
log_warn "(如果脚本同时验证 permission 拦截,可执行:adb shell pm list permissions | grep VOICE)"

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# skyworth_cold_start.sh - 创维 VOD 冷启动测试(两阶段跳转:Adapter 阶段1先唤醒→阶段2导航就绪回放)
# 流程: force-stop -> 投递创维广播(Adapter 内部 enqueueForColdStart + 启动 IndexActivity)
# -> IndexActivity 首帧就绪后自动 replayColdStartQueue -> 进入详情/搜索页
# 用法: skyworth_cold_start.sh [filmName]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/lib/common.sh"
require_adb
check_skyworth_adapter_exists
FILM_NAME="${1:-冷启动测试片}"
print_header "创维 VOD 冷启动测试(filmName=${FILM_NAME})"
# 1. 强制停止应用,模拟完全未启动状态
force_stop_app
sleep 1
# 2. 投广播(Adapter 未激活时:enqueue 命令 + 自己 startActivity(IndexActivity) 唤醒应用)
# 不再手动 launch_main_activity,更贴近 OEM 真实流程。
log_info "[1/3] 应用未启动时投递创维广播(Adapter 内部唤醒+排队)"
send_skyworth_broadcast "${FILM_NAME}"
# 3. 等应用进程启动 + 导航就绪 + 首帧命令回放(coldStartQueue drainAll 一次性消费)
log_info "[2/3] 等待进程启动 + 导航就绪 + 命令回放(约 10 秒)"
sleep 10
# 4. 给搜索请求+详情页跳转留出时间(网络请求+页面渲染)
log_info "[3/3] 等待详情页跳转(约 5 秒)"
sleep 5
log_ok "冷启动测试结束"
log_info "请人工验证是否进入 VideoDetailActivity / SearchActivity"
log_info "若停留在 IndexActivity 未跳转,请 logcat 过滤 VoiceCommandLogger 查看 enqueue/replay/guard 链路日志"

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# skyworth_empty_test.sh - 创维 videoList 空候选测试
# 投递 videoList 为空串的广播,验证 Adapter firstNonEmpty() 后走 dropped 分支
#
# 可选参数(通过环境变量控制,均为可选):
# SKYWORTH_SKIP_COMPONENT=1 省略 -n 参数(不显式指定 Receiver 组件)
# SKYWORTH_SKIP_SENDER=1 省略 -p 参数(不指定 sender 包名,Adapter 内部用白名单默认值兜底)
#
# 用法: skyworth_empty_test.sh [mode]
# mode: empty 只投一个空串候选(默认)
# multi 投 [空串, 真实片名],验证跳过空候选取首个非空
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/lib/common.sh"
require_adb
check_skyworth_adapter_exists
MODE="${1:-empty}"
# 组装 -n 和 -p(按开关可选,与 send_skyworth_broadcast 保持一致)
n_arg=""
p_arg=""
if [ -z "${SKYWORTH_SKIP_COMPONENT}" ]; then
n_arg="-n '${SKYWORTH_RECEIVER_COMPONENT}' "
fi
if [ -z "${SKYWORTH_SKIP_SENDER}" ]; then
p_arg="-p '${DEFAULT_SKYWORTH_SENDER}' "
fi
print_header "创维 videoList 空候选测试(mode=${MODE})"
if [ "${MODE}" = "multi" ]; then
# JSON 数组多个候选,模拟创维识别返回多个结果且首个为空
log_info "投递 [\"\", \"镖人\"],预期跳过空候选、取 \"镖人\" 走正常链路"
${ADB} shell "am broadcast \
-a '${ACTION_SKYWORTH_VOD}' \
${n_arg}${p_arg}--es videoList '[\"\", \"镖人\"]' \
--include-stopped-packages" 2>&1 | sed 's/^/ [adb] /'
log_ok "预期进入 VideoDetailActivity 或 SearchActivity"
else
log_info "投递 [\"\"],预期 Adapter 判定 videoList empty 走 dropped"
${ADB} shell "am broadcast \
-a '${ACTION_SKYWORTH_VOD}' \
${n_arg}${p_arg}--es videoList '[\"\"]' \
--include-stopped-packages" 2>&1 | sed 's/^/ [adb] /'
log_ok "预期被丢弃,无任何页面跳转(logcat: dropped skyworth videoList empty)"
fi
log_warn "验证方法:adb logcat -v threadtime VoiceCommand:V '*:S' 过滤 dropped 日志"

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# skyworth_security_test.sh - 创维广播白名单校验测试
# 用非白名单 sender 包名投递创维 VOD 广播,验证被 SkyworthVoiceAdapter 丢弃
# 用法: skyworth_security_test.sh [fakeSenderPkg]
# fakeSenderPkg 默认 com.attacker.malicious
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/lib/common.sh"
require_adb
check_skyworth_adapter_exists
FAKE_SENDER="${1:-com.attacker.malicious}"
print_header "创维广播安全校验(sender=${FAKE_SENDER})"
send_skyworth_broadcast "越权点播片" "${FAKE_SENDER}"
log_ok "投递完成,预期 sender 不在白名单时被丢弃(logcat: dropped sender not in whitelist)"
log_warn "验证方法:adb logcat -v threadtime VoiceCommand:V '*:S' 过滤 dropped 日志"

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# skyworth_vod.sh - 创维 VOD 语音点播正常路径测试
# 模拟创维 SDK 投递 RecognizeResult.vod 广播(白名单 sender + videoList),
# 验证链路:SkyworthVoiceAdapter -> SkyworthVodParser -> OPEN_FILM_DETAIL ->
# DefaultPageNavigator.searchAndOpenFilmDetail()
# 用法: skyworth_vod.sh [filmName] [senderPkg]
# filmName 默认 "镖人"(需是片库中真实存在的片名,否则走 SearchActivity 兜底)
# senderPkg 默认白名单 com.skyworth.smarthotel
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/lib/common.sh"
require_adb
check_skyworth_adapter_exists
FILM_NAME="${1:-镖人}"
SENDER_PKG="${2:-${DEFAULT_SKYWORTH_SENDER}}"
print_header "创维 VOD 点播(filmName=${FILM_NAME})"
send_skyworth_broadcast "${FILM_NAME}" "${SENDER_PKG}"
log_ok "预期进入 VideoDetailActivity(影片名命中时)或 SearchActivity(无命中兜底)"
log_info "验证方法:观察前台页面,或 logcat 过滤 tag=VoiceCommand 查看 parsed 日志"

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# throttle_test.sh - 节流测试
# 同类 CONTROL 命令 2 秒内连续投递多条,验证只保留最后一条(文档 §7.5)
# 用法: throttle_test.sh [action] [count]
# action 默认 PAUSE
# count 默认 5(2 秒内投递 5 次)
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/lib/common.sh"
require_adb
check_receiver_exists
ACTION="${1:-PAUSE}"
COUNT="${2:-5}"
print_header "节流测试 CONTROL/${ACTION} × ${COUNT}(2 秒内)"
log_warn "请先确保正在播放页(run_all.sh 已跳转到播放页),否则会被守卫拦截"
for i in $(seq 1 "${COUNT}"); do
PAYLOAD=$(build_control_payload "${ACTION}")
log_info "[${i}/${COUNT}] 投递 ${ACTION}"
send_voice_broadcast "CONTROL" "${PAYLOAD}" "throttle-$(date +%s%3N)-${i}" >/dev/null
sleep 0.3
done
log_ok "投递完成,预期服务端只执行最后一条 ${ACTION}"
log_info "可通过 logcat 过滤 VoiceCommandLogger 验证 throttle 日志"