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

6
.gitignore vendored Executable file → Normal file
View File

@@ -19,7 +19,13 @@ local.properties
docs/*
.agents/*
.trae/*
.worktrees/
skills-lock.json
*.log
/CLAUDE.md
.claude/*
.superpowers/*
.gitattributes
graphify-out/*
.vscode/
.screenshots/

View File

@@ -0,0 +1,146 @@
# 捷成 Android TV — 5.1 环绕声道接入成果总结
> 版本基线:v1.3.33(aishiRemoteRelease)|验证设备:Amlogic Android 14 盒子(172.16.100.175,曾用 184)
> 验证结论:**AC3 5.1 比特流经 HDMI 直通输出成功,解码器面板显示 AC3**
---
## 一、目标与结果
| 目标 | 结果 |
|---|---|
| 接入橘视厅 5.1 环绕声业务渠道,拉取 5.1 片库内容 | 完成 |
| 播放器识别并优先播放 5.1 声道片源 | 完成 |
| 5.1 音频经 HDMI 输出到外接解码器/功放 | 完成(AC3 源码直通,非 LPCM 下混) |
全链路形态:
```
EPG 点播 → SDK 鉴权(getVideoUrls) → 识别"1080P 5.1 环绕声"独立档
→ H265(硬解) + AC3(平台直通/FFmpeg 软解兜底)
→ HDMI 输出:直通设备=AC3 bitstream(RAW_DIRECT);非直通设备=6ch LPCM(FFmpeg 软解)
```
## 二、渠道参数(橘视厅5.1环绕声业务)
| 参数 | 值 |
|---|---|
| tenant | huashitv |
| accesser | JSTAS51 |
| secret | oam25ZqvGy3gVY0x |
| agcode | 1100101010200492358120169 |
| comboCode(业务组合) | 1100121044377892530980001 |
| serviceGroupCode | 1100122010201752209710182 |
| businessLine(业务线) | 4100450044057893707260002 |
| productCode(产品包) | 2100130010166202685000333 |
| 免登录公钥 | debug/release 共用同一把(见 ChannelManger) |
## 三、App 端代码改动
### 3.1 渠道参数全局替换(运行时 + 默认值三处同步)
- [ChannelManger.java L63-L73](app/src/main/java/com/pukka/jiecheng/utils/ChannelManger.java#L63-L73):`initChannel()` 运行时唯一生效入口(MainActivity.onCreate 调用),ACCESSER/SECRET/业务组合/业务线/公钥全部替换为 5.1 业务参数
- [BaseConst.java L18-L24](libcore/src/main/java/com/pukka/libcore/base/BaseConst.java#L18-L24):四个码的静态默认值同步(MyApplication 阶段请求使用)
- [JetsenxSdkUtil.java L36-L39](app/src/main/java/com/pukka/jiecheng/utils/JetsenxSdkUtil.java#L36-L39):AGCODE/ACCESSER/SECRET 默认值同步
### 3.2 产品包直连(跳过动态取包)
[JetsenxSdkUtil.java L49-L53](app/src/main/java/com/pukka/jiecheng/utils/JetsenxSdkUtil.java#L49-L53):`productCodeSp` 直接初始化为官方产品包 `2100130010166202685000333`,跳过 `getAllAssetPrices` 动态取包(避免多产品包场景取错包导致 -110 未订购)。
### 3.3 5.1 片源自动优先(核心逻辑)
[JetsenxSdkUtil.java](app/src/main/java/com/pukka/jiecheng/utils/JetsenxSdkUtil.java) 的 `getVideoUrls2()`(L785-L804)和 `getFreeVideoUrls2()`(L850-L869)两处,在 720/1080 解析循环后追加 5.1 覆盖块:
```java
// 5.1 环绕声独立档(format='1080P 5.1 环绕声',H265+AC3_51):自动优先策略下作为 1080 档播放地址。
// Android 5.1(API 22)及以下解码能力有限,跳过 5.1 流,保留普通 1080P。
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP_MR1) {
for (VideoInfo info : videoInfos) {
String fmt = info.getFormat() == null ? "" : info.getFormat();
if (fmt.contains("5.1") || fmt.contains("环绕声")) {
videoUrl_1080 = info.getVideoUrl();
// PlayCodeBean 同步 movieCode/programCode/specCode → 播放记录上报正确
...
break;
}
}
}
```
设计要点:
- **最小侵入**:复用现有 1080 档通道,档位管理(VideoDetailStatusManager)/播放调用点零改动
- 5.1 流的 movieCode/programCode/specCode 同步写入 PlayRecordManager,上报与播控正确
- API 22 及以下自动降级普通 1080P(无软解能力兜底)
### 3.4 播放器渲染器策略:PREFER → ON(关键决策)
[PkExoMediaPlayer.java L110](mediaplayertv/src/main/java/com/pukka/videoplayertv/exoplayer/PkExoMediaPlayer.java#L110):
```java
// v1.3.32: EXTENSION_RENDERER_MODE_PREFER → FFmpeg 渲染器全量优先(含视频)
// v1.3.33: EXTENSION_RENDERER_MODE_ON → 平台渲染器优先,FFmpeg 仅兜底
```
原因:
1. **直通需求**:`PREFER` 下 FFmpeg 音频渲染器优先抢走 AC3 → 永远软解为 PCM,AudioTrack 永远不会以 `ENCODING_AC3` 输出,HDMI 源码直通不可达
2. **`ON` 的行为**:平台 MediaCodec 渲染器优先。设备开编码环绕声(ALWAYS)时 ExoPlayer 检测到 AC3 输出能力 → 直通;未开启/不支持时走平台解码或回落 FFmpeg 软解
3. **额外收益**:H265 视频从 FFmpeg 软解回到平台硬解
### 3.5 FFmpeg 扩展依赖(软解兜底)
[app/build.gradle L314-L318](app/build.gradle#L314-L318):`exoplayer-ffmpeg-extension-2.19.1.aar`(定制编译,含 AC3/EAC3/DCA/TrueHD 解码器,官方默认版不含)。用于不支持直通的设备软解 AC3 → 6ch LPCM 输出。
### 3.6 其他
- versionName → v1.3.33;签名路径改为相对路径 `key/jiecheng.jks`
- 打包命令:`sh ./gradlew assembleAishiRemoteRelease`(遥控版)/ `assembleAishiTouchRelease`(触屏版)
## 四、5.1 片源数据形态
- **独立 URL 档位**(非同流多音轨):同一影片多档清晰度中有一档 `format='1080P 5.1 环绕声'`
- 该档位:H265 视频 + AC3 5.1(AC3_51)音频
- **EPG/SDK 均无 5.1 元数据字段**,唯一标识是 `VideoInfo.format` 字符串 → UI 角标需依赖此字段(待捷成侧加字段可优化)
## 五、真机验证结论(Amlogic Android 14)
| 版本 | 渲染策略 | audio_flinger 实测 | 效果 |
|---|---|---|---|
| v1.3.32 | FFmpeg PREFER | `HAL: PCM_16_BIT, 6ch, usecase: PCM_DIRECT` | 软解 6ch LPCM 输出 HDMI,有声 |
| v1.3.33 | 平台优先 ON | `HAL: AUDIO_FORMAT_AC3, usecase: RAW_DIRECT, DIRECT 线程` | **AC3 比特流直通,面板显示 AC3** |
验证命令:`adb shell dumpsys media.audio_flinger`,关注 DIRECT 输出线程的 `HAL format` 与 `usecase`。
## 六、系统侧音频配置要点(测试设备部署须知)
AC3 直通需系统侧两项配置(设备 root 后操作):
```bash
# 1. 开启编码环绕声输出(Amlogic 固件实测:2 是陷阱值,正确部署值 0/3,见下方链路关系)
adb shell settings put global encoded_surround_output 0
# 生效标志:dumpsys media.audio_flinger | grep digital_audio_mode → AUTO
# 2. 重启 App(ExoPlayer 在播放器实例化时读取系统音频能力)
```
链路关系(Amlogic 固件实测修正):
- **部署用 `0 (AUTO)` 或 `3 (MANUAL)`** → HAL `digital_audio_mode` 为 **AUTO** → AC3 流走 IEC RAW 直通
- `encoded_surround_output=1 (NEVER)` → HAL 被下发 **PCM** 模式 → 直通不可用(App 注入条件已排除 NEVER,安全回落软解 2.0,不无声)
- **`encoded_surround_output=2 (ALWAYS)` 在该 Amlogic 固件是陷阱值**:policy 照常向 App 上报 AC3 能力,但 HAL 被 SettingsObserver 切到 PCM,两层矛盾表现为无声/静默回退。本文档早前"`put ... 2`"的建议**作废**,详见《5.1声道改造-代码变更清单与复用指南.md》§2.3
设备伪装(换机测试不重新开通):root 写 `shared_prefs/config.xml` 的 `ETH_MAC` 键 → App 用伪装 MAC 请求沿用 uid/订购。
## 七、遗留问题与后续规划
### 7.1 已知问题
1. **开机重置**:设备重启后有系统组件将 `encoded_surround_output` 改回 1(NEVER),导致 HAL 变 PCM、直通失效,需重新设置并重启 App。待排查改写来源(疑似 Amlogic 设置应用/固件默认逻辑),部署设备需固化该配置
2. **生效时序**:运行时改 `encoded_surround_output` 后,HAL 的 `digital_audio_mode` 由 audio policy 在下次输出重配置时才下发 AUTO(实测延迟数分钟/需触发),改完设置应重启 App 并重新进播放
### 7.2 后续待办
| # | 事项 | 说明 |
|---|---|---|
| 1 | **设备能力检测** | App 启动/播放前检测:AC3 直通能力(AudioCapabilities/编码环绕声设置)+ 解码能力,动态决策 直通/软解/降级,并可在 UI 提示当前音频输出模式 |
| 2 | **5.1 解码开关按钮** | 设置项或播放页开关:开启=优先 5.1 档,关闭=普通 1080P(给无环绕设备的用户退出手段) |
| 3 | **5.1 节目标识** | 详情页/海报角标"5.1",基于 `VideoInfo.format` 含 "5.1"/"环绕声" 判断;EPG 无字段,可推动捷成侧补元数据 |
| 4 | 清晰度菜单区分 | 档位名显示"1080P 环绕声"替代直接替换 1080 档(当前 5.1 档覆盖 1080 档位) |

View File

@@ -0,0 +1,585 @@
# 5.1 声道改造 — 代码变更清单与复用指南
> 用途:当前验证代码直接改在 main 上(v1.3.32 → v1.3.35),后续需从 main 单独建 5.1 渠道分支重新套用本文档的变更。
> 验证设备:Amlogic Android 14 盒子(172.16.100.166,曾用 184/175),外接功放 HDMI。
> 验证版本:v1.3.35 aishiRemoteRelease,已确认 5.1/2.0 切换、AC3 直通正常。
---
## 0. 结论速览
| 项 | 结论 |
|---|---|
| 播放策略 | 三级:AC3 源码直通 > 平台硬解(6ch LPCM) > FFmpeg 软解(下混 2.0);不支持直通时回退普通 1080P AAC 档 |
| 渲染器模式 | `EXTENSION_RENDERER_MODE_ON`(平台优先,FFmpeg 仅兜底)——**不能用 PREFER**(会全量软解,直通不可达) |
| 系统设置 | 该固件 `encoded_surround_output` 必须设 **0 (AUTO)**,**不能设 2 (ALWAYS)**(详见 §3,这是本次最大的坑) |
| 切换生效 | 切 5.1 开关后无需杀 app,重新起播(换集/退出播放页重进)即生效;播放中不热切(5.1 档与 2.0 档是两条 CDN 流) |
| CDN 结构 | 档位因片而异。新片:720P×2 + 1080P AAC 2.0 + 1080P H265 AC3 5.1;老片可能只有 720P + 5.1 档 |
| 5.1 流形态 | 单 AC3 音轨、**无 2.0 兜底音轨**,不支持直通时必须整条换流(不可同流切音轨) |
| 注入原理 | **v1.3.36 关键修复**:`DefaultAudioSink.Builder()` 必须**无参构造**——传 context 时首次 `getFormatSupport()` 会懒注册 AudioCapabilitiesReceiver 用系统 caps **覆盖注入值**(反编译 2.19.1 字节码确认),v1.3.35 的注入从未生效。随 v1.3.35 版本号发布 |
| 直通降级 | 注入会话中音频初始化类错误(1000/4001/5001)自动触发:进程级禁用注入 + 重建播放器软解重试(保留进度,一次为限),播放不中断 |
| 音量边界 | 直通后 App 无法干预响度(raw 流不解码不混音,响度由接收端解码决定),与软解路径间存在感知音量差——直通固有特性,非缺陷 |
---
## 1. 代码变更清单(10 个文件)
### 1.1 [新增] `app/src/main/java/com/pukka/jiecheng/utils/Surround51Manager.java`
5.1 能力检测与播放开关的唯一入口。
```java
package com.pukka.jiecheng.utils;
import android.content.Context;
import android.media.MediaCodecInfo;
import android.media.MediaCodecList;
import android.os.Build;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.audio.AudioCapabilities;
import com.pukka.libcore.utils.LogUtils;
import com.pukka.libcore.utils.SharedPreferenceUtils;
/**
* 5.1 环绕声能力检测与播放开关。
* <p>开关(默认开启)关闭时不选 5.1 独立档(H265+AC3_51),播放普通 2.0 档;
* 开启时按设备能力自动选择输出路径:AC3直通(HDMI bitstream) > 平台硬解(6ch LPCM) > FFmpeg软解(下混 2.0)。</p>
* <p>CDN 侧 5.1 档为单 AC3 音轨流(无 2.0 兜底音轨),故能力不足或开关关闭时必须回退普通档 URL。</p>
*/
public final class Surround51Manager {
private static final String TAG = "Surround51Manager";
/** SP 键:5.1 环绕声播放开关(设备信息页可切换) */
private static final String SP_KEY_51_ENABLED = "Surround51Enabled";
private static volatile Boolean sAc3Passthrough;
private static volatile Boolean sPlatformAc3Decoder;
private Surround51Manager() {
}
/** 5.1 环绕声播放开关,默认开启 */
public static boolean is51Enabled() {
return SharedPreferenceUtils.getBoolean(SP_KEY_51_ENABLED, true);
}
public static void set51Enabled(boolean enabled) {
SharedPreferenceUtils.put(SP_KEY_51_ENABLED, enabled);
clearCache();
}
/** 清除静态缓存(开关切换后需重新检测) */
public static void clearCache() {
sAc3Passthrough = null;
sPlatformAc3Decoder = null;
}
/**
* 是否优先选用 5.1 独立档:开关开启 且 Android 5.1(API 22) 以上。
* API 22 及以下设备解码能力有限,固定走普通 2.0 档。
*/
public static boolean is51Preferred() {
return is51Enabled() && Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP_MR1;
}
/** 判断 SDK 返回档位是否 5.1 环绕声档(format='1080P 5.1 环绕声') */
public static boolean isSurroundFormat(String format) {
String fmt = format == null ? "" : format;
return fmt.contains("5.1") || fmt.contains("环绕声");
}
/**
* 设备是否支持 AC3 源码直通(HDMI/SPDIF bitstream)。
* 结果受系统编码环绕声设置(encoded_surround_output)与当前接入的音频设备能力影响。
*/
public static boolean supportsAc3Passthrough(Context context) {
if (sAc3Passthrough == null) {
try {
sAc3Passthrough = AudioCapabilities.getCapabilities(context)
.supportsEncoding(C.ENCODING_AC3);
} catch (Throwable e) {
LogUtils.d(TAG, "supportsAc3Passthrough err:" + e);
sAc3Passthrough = false;
}
}
return sAc3Passthrough;
}
/** 平台是否自带 AC3 硬解码器(如带 Dolby/MS12 授权的芯片),硬解输出 6ch LPCM 由系统按输出能力下混 */
public static boolean hasPlatformAc3Decoder() {
if (sPlatformAc3Decoder == null) {
boolean found = false;
try {
for (MediaCodecInfo info : new MediaCodecList(MediaCodecList.ALL_CODECS).getCodecInfos()) {
// v1.3.36: isAlias() 是 API 29+ 方法,API 28 及以下调用抛 NoSuchMethodError
//(腾讯T5/Android 9 实测),会中断整个循环导致检测永远为"无解码器"
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && info.isAlias())
|| info.isEncoder()) {
continue;
}
for (String type : info.getSupportedTypes()) {
if ("audio/ac3".equalsIgnoreCase(type)) {
found = true;
break;
}
}
if (found) {
break;
}
}
} catch (Throwable e) {
LogUtils.d(TAG, "hasPlatformAc3Decoder err:" + e);
found = false;
}
sPlatformAc3Decoder = found;
}
return sPlatformAc3Decoder;
}
/** 当前设备 5.1 音频输出路径描述(设备信息页展示用) */
public static String getAudioPathDesc(Context context) {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP_MR1) {
return "不支持(系统版本过低)";
}
if (supportsAc3Passthrough(context)) {
return "AC3源码直通";
}
if (hasPlatformAc3Decoder()) {
return "硬解码";
}
return "软解";
}
}
```
注意事项:
- `MediaCodecList` 遍历用 `new MediaCodecList(MediaCodecList.ALL_CODECS)` 构造(API 21+);`MediaCodecList.CodecInfo` 不是内部类,正确类型是独立的 `android.media.MediaCodecInfo`。
- `mediaplayertv` 模块不能依赖此类的选流方法(app 模块),但能力检测逻辑被 §1.2 以直接读 SP + 内联方式复用。
### 1.2 [修改] `mediaplayertv/src/main/java/com/pukka/videoplayertv/exoplayer/PkExoMediaPlayer.java`
**改动 1(v1.3.33)**:`initPlayer()` 中渲染器模式 `EXTENSION_RENDERER_MODE_PREFER` → `EXTENSION_RENDERER_MODE_ON`。原因:PREFER 让 FFmpeg 渲染器全量优先(含 AC3 软解为 PCM),直通永远不可达;ON 平台优先、FFmpeg 仅兜底。
**改动 2(v1.3.35)**:`initPlayer()` 中 `DefaultRenderersFactory` 改为匿名子类,重写 `buildAudioSink()`,在系统未报告 AC3 但 5.1 开关开启且系统未禁止环绕声时,强制注入含 AC3 的 `AudioCapabilities`。新增 import:
```java
import android.provider.Settings;
import com.google.android.exoplayer2.audio.AudioCapabilities;
import com.google.android.exoplayer2.audio.AudioSink;
import com.google.android.exoplayer2.audio.DefaultAudioSink;
import com.pukka.libcore.utils.SharedPreferenceUtils;
```
`initPlayer()` 中创建播放器的完整替换代码:
```java
// 增加缓冲区大小
DefaultLoadControl mLoadControl = new DefaultLoadControl.Builder()
.setBufferDurationsMs(50000, 50000, 1500, 2000)
.build();
if (mRenderersFactory == null) {
mRenderersFactory = new DefaultRenderersFactory(mAppContext) {
@Override
protected AudioSink buildAudioSink(Context context, boolean enableFloatOutput,
boolean enableAudioTrackPlaybackParams, boolean enableOffload) {
AudioCapabilities caps = AudioCapabilities.getCapabilities(context);
boolean ac3Supported = caps.supportsEncoding(C.ENCODING_AC3);
// 5.1 开关开启且系统未禁止环绕声时,强制注入 AC3 直通能力。
// 修复:HAL 从 PCM 切回 AUTO 有延迟,getDirectPlaybackSupportedEncodings()
// 可能返回过时结果,导致 ExoPlayer 不尝试 AC3 直通。
if (!ac3Supported) {
boolean surroundEnabled = SharedPreferenceUtils.getBoolean("Surround51Enabled", true);
if (surroundEnabled) {
int encodedSurround = Settings.Global.getInt(
context.getContentResolver(), "encoded_surround_output", 0);
// encoded_surround_output: 0=AUTO, 1=NEVER, 2=ALWAYS
if (encodedSurround != 1) {
LogUtils.d(TAG, "buildAudioSink: 系统未报告AC3,强制注入。"
+ " encodedSurround=" + encodedSurround);
caps = new AudioCapabilities(
new int[]{C.ENCODING_PCM_16BIT, C.ENCODING_AC3}, 8);
}
}
} else {
LogUtils.d(TAG, "buildAudioSink: 系统已报告AC3直通能力");
}
return new DefaultAudioSink.Builder(context)
.setAudioCapabilities(caps)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams)
.setOffloadMode(enableOffload
? DefaultAudioSink.OFFLOAD_MODE_ENABLED_GAPLESS_NOT_REQUIRED
: DefaultAudioSink.OFFLOAD_MODE_DISABLED)
.build();
}
}.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON);
}
ExoPlayer mInternalPlayer = new ExoPlayer.Builder(
mAppContext,
mRenderersFactory,
new DefaultMediaSourceFactory(mAppContext),
mTrackSelector == null ? mTrackSelector = new DefaultTrackSelector(mAppContext) : mTrackSelector,
mLoadControl,
DefaultBandwidthMeter.getSingletonInstance(mAppContext),
new DefaultAnalyticsCollector(Clock.DEFAULT))
.build();
```
**改动 3(v1.3.36,随 v1.3.35 版本号发布:修复注入失效 + 自动降级重试)**
字段(类顶部新增):
```java
/** 进程级禁用 AC3 caps 注入:直通初始化失败自动降级后置位,本进程内不再注入(设备级失败,重试无意义) */
private static volatile boolean sAc3InjectionDisabled = false;
/** 当前播放器实例的 AudioSink 是否注入了 AC3 caps(onPlayerError 降级判定用) */
private volatile boolean mAc3Injected = false;
```
`buildAudioSink()` 相对改动 2 的变化:注入条件追加 `!sAc3InjectionDisabled`;注入成功置 `mAc3Injected = true`;三分支日志(已报告/注入/注入已禁用);**`new DefaultAudioSink.Builder(context)` 改为无参 `new DefaultAudioSink.Builder()`(关键修复)**:
```java
if (!ac3Supported && !sAc3InjectionDisabled) {
boolean surroundEnabled = SharedPreferenceUtils.getBoolean("Surround51Enabled", true);
if (surroundEnabled) {
int encodedSurround = Settings.Global.getInt(
context.getContentResolver(), "encoded_surround_output", 0);
if (encodedSurround != 1) {
LogUtils.d(TAG, "buildAudioSink: 系统未报告AC3,强制注入。"
+ " encodedSurround=" + encodedSurround);
caps = new AudioCapabilities(
new int[]{C.ENCODING_PCM_16BIT, C.ENCODING_AC3}, 8);
mAc3Injected = true;
}
}
} else if (ac3Supported) {
LogUtils.d(TAG, "buildAudioSink: 系统已报告AC3直通能力");
} else {
LogUtils.d(TAG, "buildAudioSink: 注入已禁用(本进程曾直通失败),走系统 caps");
}
return new DefaultAudioSink.Builder() // 无参!传 context 会导致注入被系统 caps 覆盖
.setAudioCapabilities(caps)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams)
.setOffloadMode(enableOffload
? DefaultAudioSink.OFFLOAD_MODE_ENABLED_GAPLESS_NOT_REQUIRED
: DefaultAudioSink.OFFLOAD_MODE_DISABLED)
.build();
```
`onPlayerError()` 拦截音频初始化类错误自动降级,`degradeAndRetry()` 重建播放器(核心步骤):
```java
// onPlayerError 中(notifyOnError 之前):
if (mAc3Injected && !sAc3InjectionDisabled) {
int code = error.errorCode;
if (code == PlaybackException.ERROR_CODE_UNSPECIFIED // 1000
|| code == PlaybackException.ERROR_CODE_DECODER_INIT_FAILED // 4001
|| code == PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED) { // 5001
LogUtils.d(TAG, "onPlayerError: 注入会话音频初始化失败(code=" + code
+ "),自动降级为软解重试");
sAc3InjectionDisabled = true; // 进程级一次降级,防循环
degradeAndRetry(error);
return;
}
}
// degradeAndRetry 核心:记录进度 → 主线程移除监听并 release →
// mRenderersFactory = null(下次 initPlayer 重建 sink,因 sAc3InjectionDisabled=true 不再注入)→
// mAc3Injected = false → initPlayer()/setWakeMode/setOptions/initMediaListener →
// setMediaSource(mMediaSource)/prepare()/seekTo(resumePos);任意步骤异常才上抛原错误。
```
技术依据(反编译 2.19.1 字节码确认):
- **注入失效根因**:`DefaultAudioSink` 私有 `getAudioCapabilities()` 在 `receiver==null && context!=null` 时懒创建 `AudioCapabilitiesReceiver` 并 `register()`,用**系统实时 caps 直接覆盖 `audioCapabilities` 字段**。`Builder(context)` 传 context → 注入值只在构造到首次 `getFormatSupport()` 之间短暂存在,从未参与格式判定;**无参 `Builder()`(context=null)注入才持续生效**。
- `AudioCapabilities.getCapabilities()` **无静态缓存**,每次实时查询;API 29+ TV 走 `AudioManager.getDirectPlaybackSupportedEncodings()`,该查询反映 audio policy/HAL 当前运行时状态,在 HAL 模式切换期间会返回过时结果。
- **`PlaybackException` 位置与命名坑**:类在 **exoplayer-common-2.19.1.aar**(2.18 起模块化拆分,不在 exoplayer-core 的 classes.jar 根包);错误码常量命名是 `ERROR_CODE_*`(**非 `ERRORCODE_*`,编译期找不到符号的坑**)。白名单只收音频初始化类错误(1000/4001/5001),防误降级。
- `MediaCodecAudioRenderer` 直通准入 gate(字节码):`audioSink.supportsFormat(format) && MediaCodecUtil.getDecryptOnlyDecoderInfo() != null`(后者查 `audio/raw` decoder)——注入后有 raw decoder 但 policy 无 DIRECT 输出的设备(如部分 MTK)会在 `AudioTrack(ENCODING_AC3)` 初始化时硬失败,这正是降级重试要防的场景。
- `DefaultRenderersFactory.buildAudioSink()` 是 protected,可被子类重写;`DefaultAudioSink.Builder.setAudioCapabilities()` 是官方注入点。
- SP 键 `"Surround51Enabled"` 与 app 模块 `Surround51Manager.SP_KEY_51_ENABLED` 保持一致(mediaplayertv 依赖 libcore,可直接用 `SharedPreferenceUtils`,两模块读同一 `config.xml`)。
### 1.3 [修改] `app/src/main/java/com/pukka/jiecheng/utils/JetsenxSdkUtil.java`
`getVideoUrls2()`(约 L776)与 `getFreeVideoUrls2()`(约 L841)**两处相同改动**:
1. 主循环普通 1080P 匹配排除 5.1 档:
```java
}else if (info.getFormat().contains("1080")
&&TextUtils.isEmpty(videoUrl_1080)
&&!Surround51Manager.isSurroundFormat(info.getFormat())){
// 普通 1080P 档(排除 5.1 档)
```
2. SDK 回调尾部新增 5.1 覆盖块(原条件 `Build.VERSION.SDK_INT > LOLLIPOP_MR1` 改为开关判断):
```java
if (Surround51Manager.is51Preferred()) {
for (VideoInfo info : videoInfos) {
if (Surround51Manager.isSurroundFormat(info.getFormat())) {
videoUrl_1080 = info.getVideoUrl();
// PlayCodeBean 取码同步覆盖(照原 1080 分支写法)
break;
}
}
}
```
日志锚点:`videoUrl_51 applied:<url>`(排障用)。
### 1.4 [修改] `app/src/main/java/com/pukka/jiecheng/video/utils/VideoDetailStatusManager.java`
**改动 1**:`getDefinitionUrl()` 恒选 1080 档(约 L446):
```java
public static String getDefinitionUrl(String videoUrl_1080, String videoUrl_720) {
if (!TextUtils.isEmpty(videoUrl_1080)) {
return videoUrl_1080;
}
return videoUrl_720;
}
```
**改动 2**:`setData()` 与 `changeData()` 保持"先释放旧播放器再 setVideoPath"链路(`videoViewRelease()` + `releaseError(true)` → `setVideoPath`),否则 `openVideo()` 里 `mMediaPlayer != null` 直接 return,新 URL 不生效。
### 1.5 [修改] `app/src/main/java/com/pukka/jiecheng/video/VideoDetailActivity.java`
删除 `onDestroy()` 中强制重置清晰度到 720/1080 的逻辑(约 L3014-3020)。原因:与"5.1 档占用 1080 位置"的新模型冲突。
### 1.6 [修改] `app/src/main/java/com/pukka/jiecheng/video/manger/ResolutionManger.java`
`initResolution()`(约 L87)恒隐藏 720P 选项:
```java
public void initResolution() {
tv720.setVisibility(View.GONE);
tv1080.setVisibility(videoDetailStatusManager.isHave1080()?View.VISIBLE:View.GONE);
// ...
}
```
### 1.7 [修改] `app/src/main/java/com/pukka/jiecheng/video/utils/PlayRecordManager.java`
上报取码从读 SP `"Definition"` 改为跟随**实际播放档位**,消除 NPE 风险与错报:
```java
private PlayCodeBean getCurPlayCode() {
int def = videoDetailStatusManager != null ? videoDetailStatusManager.getCurResolutionUrl() : 0;
// def 优先当前档位 → 回落 1080 → 回落 720(按原 PlayCodeBean 组装逻辑)
}
```
`getCurResolutionUrl()` 在 `VideoDetailStatusManager` 中实现(读 `mVideoView.getUrl()` 与 1080/720 URL 比对,返回实际档位值)。
### 1.8 [修改] `app/src/main/java/com/pukka/jiecheng/device/DeviceActivity.kt`
设备信息页新增音频能力显示行 + 5.1 开关行(入口:关于我们 → 长按"我"字 3 秒 → 设备信息页):
```kotlin
// 新增字段
var mTvAudioPath: TextView? = null
var mLlSurround51: LinearLayout? = null
var mTvSurround51Value: TextView? = null
// initView() 中
mTvAudioPath = findViewById<TextView>(R.id.id_tv_audio_path)
mTvAudioPath!!.text = Surround51Manager.getAudioPathDesc(this)
mLlSurround51 = findViewById<LinearLayout>(R.id.id_ll_surround51)
mTvSurround51Value = findViewById<TextView>(R.id.id_tv_surround51_value)
updateSurround51Value()
mLlSurround51?.setOnClickListener {
Surround51Manager.set51Enabled(!Surround51Manager.is51Enabled())
updateSurround51Value()
ToastUtils.showShortToastSafe(
if (Surround51Manager.is51Enabled()) "已开启 5.1 环绕声,重新进入播放生效" else "已关闭 5.1 环绕声,播放普通 2.0 声道"
)
}
private fun updateSurround51Value() {
mTvSurround51Value?.text = if (Surround51Manager.is51Enabled()) "开" else "关"
}
```
### 1.9 [修改] `app/src/main/res/layout/activity_device.xml`
在会员到期行后新增两行:音频能力行(`id_tv_audio_path`)+ 5.1 开关行(`id_ll_surround51` / `id_tv_surround51_value`),开关行带焦点背景 `bg_setting_item_selector`。
### 1.10 [新增] `app/src/main/res/drawable/bg_setting_item_selector.xml`
焦点态半透明白色背景 selector。
### 1.11 [修改] `app/build.gradle`
`versionName` 递增(本次验证链:v1.3.32 → v1.3.33 → v1.3.34 → v1.3.35)。
依赖(已有,勿动):
```groovy
implementation 'com.google.android.exoplayer:exoplayer:2.19.1'
implementation files('libs/exoplayer-ffmpeg-extension-2.19.1.aar')
```
`mediaplayertv/build.gradle` 同版本 exoplayer 2.19.1,且依赖 `project(':libcore')`。
---
## 2. 排障经验(可直接复用的方法论)
### 2.1 问题一:"5.1 关掉后再打开不生效"(v1.3.34 现象:再开只有 2.0)
排查链(每步均有证据支撑):
| 假设 | 结论 | 证据 |
|---|---|---|
| ExoPlayer AudioCapabilities 静态缓存 | 不存在 | 反编译 2.19.1:`getCapabilities()` 每次实时查询 |
| ExoPlayer 实例未重建 | 正常重建 | `changeData()` → `videoViewRelease()` → `openVideo()` → `createPlayer()` 每次新建 |
| URL 选流错误 | 正常 | logcat `videoUrl_51 applied` 在开关后仍出现 |
| `encoded_surround_output` 被重置 | 值仍为 2 | `adb shell settings get global encoded_surround_output` |
| HAL 模式未切换 | **根因** | `dumpsys media.audio_flinger` 显示 `digital_audio_mode: PCM` |
### 2.2 问题二:v1.3.35 现象升级为"完全无声"
关键证据:
```
# HAL 日志(logcat)
audio_hw_hal_stream: [get_sink_format] format:AC3(0x9000000) digital_mode(PCM) sink cap:DOLBY_TRUEHD
audio_hw_hal_primary: [config_output] hal_internal_format:AC3 dolby_lib_type:1 reset_decoder:1 rate=48000 ch=6
# audio_flinger dump:DIRECT 线程有 AC3 流、Frames written 在涨,但全局 digital_audio_mode=PCM
```
解释:policy 层(ALWAYS)向 app 报告 AC3 能力 → ExoPlayer 走直通 → AC3 raw 流进 DIRECT 线程;但 HAL 全局 `digital_audio_mode=PCM` → HDMI 物理层按 PCM 输出 → 功放收到无法解码的数据 → 无声。
### 2.3 最终根因(本次最重要的经验)
该 Amlogic 固件 SettingsObserver 对 `encoded_surround_output` 的映射(实验实测矩阵):
| encoded_surround_output | HAL digital_audio_mode | 直通可用 |
|---|---|---|
| **0 (AUTO)** | **AUTO** | **是(正确部署值)** |
| 3 (MANUAL,Amlogic 系统设置里的"手动") | AUTO | 是 |
| 1 (NEVER) | PCM | 否 |
| **2 (ALWAYS,AOSP 标准"总是")** | **PCM** | **否(致命陷阱)** |
**ALWAYS=2 时 policy 照常向 app 报告 AC3 能力,但 HAL 被 SettingsObserver 切到 PCM** —— 两层矛盾,表现为无声或静默回退 2.0。**部署必须用 0 (AUTO)**,与 Amlogic 系统设置 UI 里的"自动"选项对应(UI 只提供 PCM/自动/手动,对应 1/0/3)。
另:设备**重启后系统组件会把 `encoded_surround_output` 重置为 1 (NEVER)**(约开机 13 秒后 SettingsObserver 生效)。此时 app 侧安全回退 FFmpeg 软解 2.0,不会无声,但 5.1 失效,需设备侧固化(见 §4 待办)。
### 2.4 恢复 HAL AUTO 的手段(已验证)
1. 重启设备(HAL 回 AUTO,但 setting 被重置为 1,需再设 0):
```bash
adb reboot
adb shell settings put global encoded_surround_output 0 # 重启后必须补设
```
2. 纯切 setting 不重启:`settings put global encoded_surround_output 0` 即可将 HAL 从 PCM 切回 AUTO(实测有效)。
### 2.5 CDN 流结构(实测修正)
以《飞驰人生2》playAuth 响应为例,4 档:
| 文件名特征 | 视频 | 音频(ffprobe 实测) | format 字段 |
|---|---|---|---|
| `.mp4.hls` | H264 1.5M 720P | AAC 2.0 | `720P` |
| `_h264_1.8m-720p` | H264 1.8M 720P | AAC 2.0 | `720P` |
| `_h264_2.5m-1080p` | H264 2.5M 1080P | **AAC 48000Hz 2ch** | `1080P` |
| `_1080P_h265_3.5M_SDR_AC3_51` | H265 3.5M 1080P | AC3 5.1(单音轨) | `1080P 5.1 环绕声` |
- 早前"1080P 只有 AC3 5.1(隐式耦合)"的结论**是误判**,只适用于部分老片;新片有独立 1080P AAC 2.0 档。
- "5.1 流单 AC3 音轨、无 2.0 兜底"结论**仍成立**——5.1 流内无 2.0 音轨,不支持直通必须整条换 URL。
- 现有选流逻辑天然兼容两种片源结构:关 5.1 → 普通 1080P AAC(老片无此档回落 720P);开 5.1 → AC3 档。
### 2.6 v1.3.35 注入失效根因(腾讯T5 光纤场景排查实录,v1.3.36 修复)
**现象**:腾讯 T5 投影(Amlogic TL1 / Android 9 / API 28),系统声音输出设为光纤 SPDIF RAW,设备页仍显示软解,AC3 走平台解码下混 2.0 PCM。
**排查链(三个 App 侧根因)**:
1. **注入被系统 caps 覆盖(核心)**:`DefaultAudioSink.Builder(context)` 传 context → `getFormatSupport()` 首次调用懒注册 `AudioCapabilitiesReceiver` 覆盖注入值 → **v1.3.35 注入从未生效**(166 的直通是系统 HDMI 自报 AC3,与注入无关;122 软解是平台无 AC3 decoder,也与注入无关)。修复 = 无参 `Builder()`,详见 §1.2 改动 3。
2. **isAlias API 兼容**:`MediaCodecInfo.isAlias()` 是 API 29+ 方法,API 28 抛 `NoSuchMethodError` 被外层 catch 吞掉 → `hasPlatformAc3Decoder()` 循环中断、永远 false。修复 = `SDK_INT >= Q` 守卫。
3. **能力检测口径(注入的价值所在)**:系统音频能力上报(HDMI plug 广播 / `getDirectPlaybackSupportedEncodings()`)**只感知 HDMI,不感知 SPDIF**——光纤场景检测 UI 显示"软解"是口径内必然;而 T5 平台实际支持 AC3 直通(policy 有 tunnel raw profile、注册 `audio/raw` 与 `audio/ac3` decoder)。caps 注入就是让 ExoPlayer 越过系统口径尝试直通,失败再由降级重试兜底。
**降级重试(L2 风险防护)**:注入生效后出现新风险面——有 raw decoder 但 policy 无 DIRECT 压缩输出的设备,`AudioTrack(ENCODING_AC3)` 初始化失败是**运行时硬失败不回落**。方案:错误码白名单(1000/4001/5001)→ 进程级 `sAc3InjectionDisabled` 置位 → 重建播放器软解重试(保留进度、一次为限、重试失败才上抛原错误)。
**验证结论(T5 光纤 SPDIF,v1.3.36 实测)**:注入生效 → `EventLogger` 音轨 `audio/ac3 channels=6 supported=YES` → HAL 日志 `SPDIF_FORMAT 2 + spdif mute 0`(AC3 raw 输出,对比修复前 `nPassThroughEnable:0` 解码模式)→ 客户环境播放出声,直通闭环成立,未触发降级(一次成功)。App 侧新增的可观察日志:`buildAudioSink: 系统未报告AC3,强制注入`。
**版本口径**:修复代码注释标记 v1.3.36,但随 versionName **v1.3.35** 发布(发版版本号未升,versionCode 按 yyMMddHH 自动递增)。
---
## 3. 验证手册
### 3.1 设备状态检查
```bash
adb connect 172.16.100.166
adb shell settings get global encoded_surround_output # 期望 0
adb shell dumpsys media.audio_flinger | grep digital_audio_mode # 期望 AUTO
adb shell cat /sys/class/audiodsp/digital_raw # 播放 AC3 中期望 1,空闲可能 0
```
### 3.2 播放中直通链路验证
```bash
# DIRECT 线程应为 AC3 + RAW_DIRECT + active
adb shell dumpsys media.audio_flinger | grep -E "Output thread|HAL format|usecase|active"
# 期望:
# Output thread ... type 1 (DIRECT)
# HAL format: 0x9000000 (AUDIO_FORMAT_AC3)
# -usecase: RAW_DIRECT
# N Tracks of which M are active
# App 侧关键日志
adb logcat -d | grep -E "buildAudioSink|videoUrl_51 applied"
# 期望:
# buildAudioSink: 系统已报告AC3直通能力(正常路径)
# 或 buildAudioSink: 系统未报告AC3,强制注入(注入路径)
# videoUrl_51 applied:<AC3_51 档 URL>
# 直通初始化失败自动降级时(罕见,Amlogic 光纤直通一次成功的场景看不到):
# onPlayerError: 注入会话音频初始化失败(code=...),自动降级为软解重试
# degradeAndRetry: 关闭AC3注入重建播放器, resumePos=...
# buildAudioSink: 注入已禁用(本进程曾直通失败),走系统 caps
```
### 3.3 功能验收用例
1. 开 5.1 播放 → 功放面板显示 AC3、5.1 效果 ✓(v1.3.35 + setting=0 已验收)
2. 关 5.1 重新起播 → 普通档 URL(`_h264_*` 非 AC3_51)、2.0 ✓
3. 再开 5.1 重新起播 → AC3 档、直通恢复 ✓
4. 切换仅重新起播即生效,无需杀 app ✓
5. `encoded_surround_output=1`(NEVER)时播放 → 回退软解 2.0,不无声 ✓(HAL PCM + policy 不报 AC3 + 注入条件排除 NEVER,三者一致)
6. 光纤 SPDIF 设备(系统能力上报不感知 SPDIF):SPDIF RAW 模式 + 5.1 开启 → 注入路径直通 ✓(腾讯T5/Amlogic TL1/Android 9,客户环境实测出声,未触发降级)
### 3.4 CDN 档位探测(排查片源问题用)
```bash
# 从 logcat 拿 playAuth response 里的目标档位 URL(含 auth_key),然后:
curl -s "<index.m3u8 URL>" | head -8 # 拿首段 ts 的 auth_key
curl -s -o /tmp/probe.ts "<index0.ts URL>"
ffprobe -v error -show_entries stream=index,codec_name,codec_type,channels,sample_rate -of csv /tmp/probe.ts
```
---
## 4. 遗留待办
1. **设备侧固化 `encoded_surround_output=0`**:排查开机把它重置为 1 的系统组件(疑似 Amlogic 设置应用/固件默认逻辑),部署设备需固化,否则每次重启后 5.1 静默降级 2.0。
2. **5.1 节目标识角标**:详情页/海报角标"5.1",基于 `VideoInfo.format` 判断(EPG 无字段)。
3. **清晰度菜单档位名**:5.1 开启时显示"1080P 环绕声"替代直接替换 1080 档文案。
4. (可选)播放器内 5.1 实时状态指示:读取当前流音轨格式显示直通/软解状态。
5. **122 (MTK) 降级重试回归实测**:v1.3.36 注入真正生效后,有 `audio/raw` decoder 的设备将首次尝试 `AudioTrack(AC3)` 直通,预期触发 `degradeAndRetry` 回落软解——降级路径尚未真机验证,装正式包后需顺带回归(日志关键字见 §3.2)。
---
## 5. 分支重建操作建议
```bash
# 从 main 建 5.1 渠道分支
git checkout main && git pull
git checkout -b feature/51-surround
# 按 §1 清单逐文件套用(1.1/1.10 为新增文件,其余为修改)
# 编译验证
sh gradlew :app:assembleaishiRemoteRelease
# 按本机既有命名约定打 AS 包(aishiRemoteRelease 变体)
```
验收顺序:§3.1 设备状态 → §3.2 直通链路 → §3.3 五个用例。

0
README.md Executable file → Normal file
View File

0
app/.gitignore vendored Executable file → Normal file
View File

147
app/build.gradle Executable file → Normal file
View File

@@ -1,5 +1,3 @@
import com.android.build.api.variant.BuildConfigField
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
@@ -16,13 +14,21 @@ android {
checkReleaseBuilds false
abortOnError false
}
// 单元测试跑在 JVM 上,org.json 等 Android SDK 类默认抛 "not mocked";
// voice/parser(SkyworthVodParser 等)依赖 org.json 解析 payload,
// 需要真实实现。这里引入官方 json.org 的 JVM 版本(仅 test classpath)。
testOptions {
unitTests {
includeAndroidResources = true
}
}
def versionSuffix = "0" // 定义版本后缀变量,必须小于或等于9大于0
defaultConfig {
applicationId "tv.huashi.jetsenx.jetsenfilx.jsttv"
minSdk app_android.minSdkVersion
targetSdk app_android.targetSdkVersion
versionCode Integer.parseInt(new Date().format("yyMMddHH") + versionSuffix)
versionName "v1.3.28"
versionName "v1.3.35"
multiDexEnabled true
//压缩语言种类,目前只支持中文语言
resConfigs('zh-rCN')
@@ -30,6 +36,10 @@ android {
// Define flavor dimensions
flavorDimensions "channel", "control"
// ===== 语音协议占位符默认值(文档 §6.2 signature-level permission) =====
// debug 通过 flavor 覆盖为 normal 方便联调,release 维持 signature。
manifestPlaceholders = [voiceProtectionLevel: "signature"]
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
//酒店 ANNING 安柠 AISHI 爱视(不要手柄)
buildConfigField "String", "HOTEL_TYPE", "\"AISHI\""
@@ -39,9 +49,11 @@ android {
buildConfigField "boolean", "NEED_ACTIVATION_CODE", "true"
//是否为扫码变体(as8Scan 覆盖为 true;asAScan 同样覆盖为 true)
buildConfigField "boolean", "SCAN_VARIANT", "false"
//是否为家用版(home flavor 覆盖为 true)
buildConfigField "boolean", "IS_HOME_EDITION", "false"
//默认激活码:扫码变体在 NEED_ACTIVATION_CODE=false 时使用,普通渠道为空字符串
buildConfigField "String", "DEFAULT_ACTIVATION_CODE", "\"\""
buildConfigField "String", "BUGLY_APP_ID", "\"01f1b10fca\""
//是否为第三方变体(thirdparty flavor 覆盖为 true)
buildConfigField "boolean", "THIRDPARTY_VARIANT", "false"
ndk {
abiFilters "armeabi-v7a", "arm64-v8a"
@@ -71,6 +83,8 @@ android {
zipAlignEnabled true // 是否开启zip压缩
signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
// release 严格按文档 §6.2 使用 signature-level permission
manifestPlaceholders = [voiceProtectionLevel: "signature"]
}
// preview {
@@ -86,12 +100,14 @@ android {
debug {
// AS 域 baseUrl:debug 走测试/内网域名
buildConfigField "String", "AS_BASE_URL", "\"https://jst-test.ainetv.cn/api/\"" //http://as-partner-end.172.16.100.110.sslip.io/
buildConfigField "String", "AS_BASE_URL", "\"http://as-partner-end.172.16.100.105.sslip.io/\"" //http://as-partner-end.172.16.100.105.sslip.io/
minifyEnabled false
shrinkResources false
zipAlignEnabled true // 是否开启zip压缩
signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
// 联调期间把 voice 协议自定义 permission 降级为 normal,让 adb shell 可投递(release 仍走 signature)。
manifestPlaceholders = [voiceProtectionLevel: "normal"]
}
}
@@ -138,16 +154,29 @@ android {
buildConfigField "String", "CHANNEL_ID", "\"TS\""
}
// --- aishi 子方向:扫码版(无激活码 默认 8888 的激活码) ---
// --- aishi 子方向:扫码版(无激活码 默认 88888 的激活码) ---
// 共享 aishi 渠道路由(X-Channel-Id="AS"),仅客户端激活流程差异
as8Scan {
dimension "channel"
manifestPlaceholders = [
APP_CHANNEL_ID : "aishi",
APP_CHANNEL_ID : "as8Scan",
splash_theme : "@style/SplashTheme"]
buildConfigField "String", "CHANNEL_ID", "\"AS\""
buildConfigField "boolean", "NEED_ACTIVATION_CODE", "false"
buildConfigField "boolean", "SCAN_VARIANT", "true"
buildConfigField "String", "DEFAULT_ACTIVATION_CODE", "\"88888\""
}
// --- 声立方:扫码版(无激活码 默认 UK4KQ 的激活码) ---
fxScan {
dimension "channel"
manifestPlaceholders = [
APP_CHANNEL_ID : "fxScan",
splash_theme : "@style/SplashTheme"]
buildConfigField "String", "CHANNEL_ID", "\"FX\""
buildConfigField "boolean", "NEED_ACTIVATION_CODE", "false"
buildConfigField "boolean", "SCAN_VARIANT", "true"
buildConfigField "String", "DEFAULT_ACTIVATION_CODE", "\"UK4KQ\""
}
// --- aishi 子方向:激活扫码版(有激活码) ---
@@ -157,23 +186,58 @@ android {
asAScan {
dimension "channel"
manifestPlaceholders = [
APP_CHANNEL_ID : "aishi",
APP_CHANNEL_ID : "asAScan",
splash_theme : "@style/SplashTheme"]
buildConfigField "String", "CHANNEL_ID", "\"AS\""
buildConfigField "boolean", "SCAN_VARIANT", "true"
}
// --- 第三方渠道(thirdparty) ---
thirdparty {
// 测试渠道分销
qnAScan {
dimension "channel"
applicationIdSuffix ".thirdparty"
manifestPlaceholders = [
APP_CHANNEL_ID : "thirdparty",
APP_CHANNEL_ID : "qnAScan",
splash_theme : "@style/SplashTheme"]
buildConfigField "String", "CHANNEL_ID", "\"TP\""
buildConfigField "boolean", "THIRDPARTY_VARIANT", "true"
// 注意:AS_BASE_URL 不能在此处覆盖,buildType 的 buildConfigField 优先级更高会将其覆盖。
// 第三方域名通过下方 androidComponents.onVariants 设置(Variant API 优先级最高)。
buildConfigField "String", "CHANNEL_ID", "\"QN\""
buildConfigField "boolean", "SCAN_VARIANT", "true"
}
// --- 家用版:独立 applicationId,与商用渠道并行发行 ---
// 暂与商用版共用 BUGLY_APP_ID / versionCode(家用版需独立时再覆盖)。
// 未来若需按地区/子渠道拆分,加为 sibling flavor 并各自挂 applicationIdSuffix 即可。
//
// CHANNEL_ID="HM":与 CLAUDE.md 渠道表一致,且避免与 fxScan("FX") 在 X-Channel-ID 头冲突
// (OkHttpClientUtil + HttpRequestInterceptor 注入该头,相同 CHANNEL_ID 会让后端把家用版路由为声立方扫码版)。
// SCAN_VARIANT=true + DEFAULT_ACTIVATION_CODE="UK4KQ":保持与 as8Scan/fxScan 同款的"扫码 + 静默激活"语义,
// 配合 tryAutoActivateWithDefaultCode 在 MainActivity 静默激活家用版设备。
// 若后续家用版切换为需要激活码输入面板,可将 NEED_ACTIVATION_CODE 改回 true 并清空 DEFAULT_ACTIVATION_CODE。
home {
dimension "channel"
applicationIdSuffix ".home"
manifestPlaceholders = [
APP_CHANNEL_ID : "home",
splash_theme : "@style/SplashTheme"]
buildConfigField "String", "CHANNEL_ID", "\"FX\""
buildConfigField "String", "HOTEL_TYPE", "\"HOME\""
buildConfigField "boolean", "IS_HOME_EDITION", "true"
buildConfigField "boolean", "NEED_ACTIVATION_CODE", "false"
buildConfigField "boolean", "SCAN_VARIANT", "false"
buildConfigField "String", "DEFAULT_ACTIVATION_CODE", "\"UK4KQ\""
}
ashome {
dimension "channel"
applicationIdSuffix ".home"
manifestPlaceholders = [
APP_CHANNEL_ID : "home",
splash_theme : "@style/SplashTheme"]
buildConfigField "String", "CHANNEL_ID", "\"AS\""
buildConfigField "String", "HOTEL_TYPE", "\"HOME\""
buildConfigField "boolean", "IS_HOME_EDITION", "true"
buildConfigField "boolean", "NEED_ACTIVATION_CODE", "false"
buildConfigField "boolean", "SCAN_VARIANT", "false"
buildConfigField "String", "DEFAULT_ACTIVATION_CODE", "\"88888\""
}
// Add more channels to this list as needed
@@ -222,18 +286,6 @@ android {
}
}
// 第三方变体 AS_BASE_URL 覆盖:
// buildType 的 buildConfigField 优先级高于 flavor,因此 thirdparty flavor 内直接设置会被
// debug/release buildType 覆盖为 jst-test/jst.ainetv.cn。Variant API 的优先级最高,在此统一覆盖。
androidComponents {
onVariants(selector().withFlavor("channel", "thirdparty")) { variant ->
variant.buildConfigFields.put(
"AS_BASE_URL",
new BuildConfigField("String", "\"http://58.220.83.58:8084/\"", null)
)
}
}
dependencies {
@@ -260,12 +312,13 @@ dependencies {
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'com.google.android.exoplayer:exoplayer:2.19.1'
//implementation(name: 'exoplayer-ffmpeg-extension-2.19.1', ext: 'aar')
// 5.1声道兜底(AC3/EAC3/DTS/TrueHD),配合 PkExoMediaPlayer 的 EXTENSION_RENDERER_MODE_ON:
// 平台渲染器优先(支持AC3直通/HW解码的设备走平台),FFmpeg仅在平台不支持时兜底软解为PCM
implementation files('libs/exoplayer-ffmpeg-extension-2.19.1.aar')
// implementation 'com.sensorsdata.analytics.android:SensorsAnalyticsSDK:6.8.3'
implementation 'commons-codec:commons-codec:1.15'
// 3.12.0:3.12.1+(含 3.12.13)引入 Android10Platform 触发 Dalvik VerifyError,必须精确锁 3.12.0
implementation 'com.squareup.okhttp3:okhttp:3.12.0'
implementation 'com.squareup.okhttp3:okhttp:3.12.13'
implementation 'com.google.code.gson:gson:2.8.9'
implementation 'com.github.gzu-liyujiang:Android_CN_OAID:4.1.3'
implementation('io.github.volcengine-tls:tls-android-producer:2.0.2-api16') {
@@ -279,32 +332,30 @@ dependencies {
implementation 'com.google.guava:guava:31.1-android'
// ===== 网络层(脚手架新引入,与既有 HttpUtils 双栈共存) =====
// ===== Android 4.2 兼容(对齐 mediaapp 已验证组合,见 Android4.2兼容性改动文档)=====
// - OkHttp 4.x 官方要求 API 21+;3.13+ 含 Android10Platform(API 10 SSL API)触发 Dalvik VerifyError。
// 3.12.0 是支持 API 9-20 的长期分支(与 libcore/libdata 老栈版本一致),配合下方 force 全局锁定。
// - Retrofit 2.6.0:suspend 函数 2.6.0 起支持;2.9+ 传递依赖 OkHttp 3.14.9(含 Android10Platform)。
// - Coroutines 1.6.4:1.7+ 要求 minSdk 21,1.6.4 兼容 API 16+。
implementation 'com.squareup.retrofit2:retrofit:2.6.0'
implementation 'com.squareup.retrofit2:converter-moshi:2.6.0'
implementation 'com.squareup.okhttp3:okhttp:3.12.0'
implementation 'com.squareup.okhttp3:logging-interceptor:3.12.0'
// 为什么显式指定版本:app 模块此前未声明 retrofit/okhttp/moshi/coroutines,避免被根 config.gradle 隐式拉入不同版本。
// Kotlin 1.9.22 -> KSP 1.9.22-1.0.17 已对齐。
// 为什么用 `platform(enforcedPlatform)` 而非 `implementation`:okhttp-bom 是 Bill-of-Materials,本身仅提供版本对齐,
// 不能被当作运行时产物拉到 runtime classpath(这正是上一个编译报错的根因)。
implementation platform('com.squareup.okhttp3:okhttp-bom:4.12.0')
implementation 'com.squareup.retrofit2:retrofit:2.11.0'
implementation 'com.squareup.retrofit2:converter-moshi:2.11.0'
implementation 'com.squareup.okhttp3:okhttp'
implementation 'com.squareup.okhttp3:logging-interceptor'
implementation 'com.squareup.moshi:moshi:1.15.1'
implementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
ksp 'com.squareup.moshi:moshi-kotlin-codegen:1.15.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
// 防止其他依赖(tls-producer/libdata 等)传递引入更高版本 OkHttp/Retrofit 破坏 4.2 兼容
configurations.all {
resolutionStrategy.force 'com.squareup.okhttp3:okhttp:3.12.0'
resolutionStrategy.force 'com.squareup.retrofit2:retrofit:2.6.0'
}
implementation 'net.jpountz.lz4:lz4:1.3.0'
implementation 'org.slf4j:slf4j-android:1.7.36'
//腾讯buggly
implementation 'com.tencent.bugly:crashreport:latest.release'
//爱奇艺本地奔溃日志
implementation 'com.iqiyi.xcrash:xcrash-android-lib:3.1.0'
// org.json 的 JVM 实现:Android SDK 自带的 org.json 在本地单测里是空壳(not mocked),
// 供 voice/parser 下基于 JSONObject 的解析逻辑做真实单测。
testImplementation 'org.json:json:20180813'
}

0
app/key/jiecheng.jks Executable file → Normal file
View File

0
app/key/keystore.properties Executable file → Normal file
View File

0
app/libs/JetsenxSdk-api-future-release-v1.23.aar Executable file → Normal file
View File

0
app/libs/JetsenxSdk-api-release_v1.14.0.aar Executable file → Normal file
View File

0
app/libs/JetsenxSdk-external-future-release-v1.23.aar Executable file → Normal file
View File

0
app/libs/JetsenxSdk-external-release_v1.14.0.aar Executable file → Normal file
View File

0
app/libs/NewTVSDK-v4.0.3-app-lite.jar Executable file → Normal file
View File

0
app/libs/arm64-v8a/libnewtvsdk.so Executable file → Normal file
View File

0
app/libs/armeabi-v7a/libnewtvsdk.so Executable file → Normal file
View File

0
app/libs/exoplayer-ffmpeg-extension-2.19.1.aar Executable file → Normal file
View File

0
app/proguard-rules.pro vendored Executable file → Normal file
View File

0
app/src/androidTest/androidmanifest.xml Executable file → Normal file
View File

View File

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">橘视厅家用版</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">橘视厅扫码版</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">橘视厅家用版</string>
</resources>

96
app/src/main/AndroidManifest.xml Executable file → Normal file
View File

@@ -4,10 +4,6 @@
package="com.pukka.jiecheng"
tools:ignore="MissingLeanbackLauncher">
<!-- Android 4.2 兼容(minSdk 17):覆盖 minSdk>17 的依赖库声明,运行时兼容性靠
降级依赖版本 + 不触碰高版本 API 路径保证。清单随探测编译逐步补充。 -->
<uses-sdk tools:overrideLibrary="com.github.gzuliyujiang.oaid" />
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
@@ -41,6 +37,23 @@
<uses-permission android:name="android.permission.REPLACE_EXISTING_PACKAGE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- ===== 语音协议(文档 §6.2):自定义 signature-level permission ===== -->
<!-- debug flavor 通过 voiceProtectionLevel=normal 临时放开(联调期 adb shell 可投递);
release 走 signature 严格白名单。 -->
<permission
android:name="com.pukka.jiecheng.permission.VOICE_COMMAND"
android:protectionLevel="${voiceProtectionLevel}" />
<uses-permission android:name="com.pukka.jiecheng.permission.VOICE_COMMAND" />
<!-- ===== 语音冷启动保活(文档 2026-08-28-语音冷启动链路设计方案) ===== -->
<!-- BOOT_COMPLETED 启 FGS 必须先声明权限;FOREGROUND_SERVICE 是 Android 9+ 启前台服务的前置 -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- 语音冷启动绕过 Background Activity Start 限制::voice 进程挂 1px 悬浮窗,
AMS 视为有 visible window,后续 startActivity 走豁免路径。
标准权限,部分 OEM ROM 自动授予;无需运行时申请。 -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<application
android:name=".MyApplication"
android:allowBackup="false"
@@ -71,6 +84,10 @@
android:name=".device.DeviceActivity"
android:exported="false"
android:screenOrientation="landscape" />
<activity
android:name=".setting.SettingActivity"
android:exported="false"
android:screenOrientation="landscape" />
<activity
android:name=".order.OrderActivity"
android:exported="false"
@@ -85,6 +102,11 @@
android:exported="false"
android:screenOrientation="landscape"
android:windowSoftInputMode="stateHidden|adjustResize" />
<!-- 家用版微信扫码登录 Activity(第一阶段占位),仅应用内启动 -->
<activity
android:name=".login.WxMpLoginActivity"
android:exported="false"
android:screenOrientation="landscape" />
<activity
android:name=".MainActivity"
android:exported="true"
@@ -98,17 +120,6 @@
</intent-filter>
</activity>
<activity
android:name=".ThirdPartyEntryActivity"
android:exported="true"
android:screenOrientation="landscape"
android:theme="@style/whiteTheme">
<intent-filter>
<action android:name="com.pukka.jiecheng.action.THIRD_PARTY_PLAY" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".TestActivity"
android:exported="true"
@@ -156,6 +167,61 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- 语音命令接收器(文档 §5.2) -->
<receiver
android:name=".voice.receiver.VoiceCommandReceiver"
android:exported="true"
android:permission="com.pukka.jiecheng.permission.VOICE_COMMAND">
<intent-filter>
<action android:name="com.pukka.jiecheng.action.VOICE_COMMAND" />
</intent-filter>
</receiver>
<!-- 语音冷启动链路:开机拉起 :voice 进程 FGS(文档 2026-08-28-语音冷启动链路设计方案) -->
<receiver
android:name=".voice.receiver.BootCompletedReceiver"
android:enabled="true"
android:exported="true"
android:directBootAware="true"
android:process=":voice"
android:permission="android.permission.RECEIVE_BOOT_COMPLETED">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- 自广播兜底接收器:处理 KEEPALIVE_BOOT 自定义 action。
解决 BOOT_COMPLETED 因 STOPPED / OEM 拦截失败时 :voice 进程无法自启的问题。
同包 explicit broadcast(Intent.setPackage)不受 STOPPED 状态限制。
exported=false 不暴露给外部,仅接收应用自身 AlarmManager 派发的 PendingIntent。 -->
<receiver
android:name=".voice.receiver.KeepAlivePingReceiver"
android:enabled="true"
android:exported="false"
android:process=":voice">
<intent-filter>
<action android:name="com.pukka.jiecheng.action.KEEPALIVE_BOOT" />
</intent-filter>
</receiver>
<!-- 语音冷启动保活服务:保持 :voice 进程隔离。
设计意图:主进程被 LMK 杀掉后 receiver 仍能存活,持续监听 OEM 创维广播。
cold-launch 受 Android 12+ Background Activity Start 限制的问题,由
VoiceOverlayHelper 在 :voice 进程挂 1px TYPE_APPLICATION_OVERLAY 悬浮窗
提供 visible window 豁免(AMS 视为前台可见窗口),配合
SkyworthVoiceAdapter.coldLaunchMainActivity 优先路径
showOverlay → ctx.startActivity(cold) → hideOverlay 绕过。
FullScreenIntent 通知作为 OEM ROM 拦截悬浮窗场景下的次选兜底保留。
当前设备 Android 11 不需要 USE_FULL_SCREEN_INTENT 权限;如后续适配
Android 14 设备需在 manifest 声明该权限。 -->
<service
android:name=".voice.service.VoiceKeepAliveService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":voice" />
</application>
</manifest>

0
app/src/main/java/com/pukka/jiecheng/JcConfig.java Executable file → Normal file
View File

335
app/src/main/java/com/pukka/jiecheng/MainActivity.java Executable file → Normal file
View File

@@ -4,6 +4,8 @@ package com.pukka.jiecheng;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
@@ -33,8 +35,10 @@ import com.pukka.jiecheng.downapk.UpgradeHelper;
import com.pukka.jiecheng.BuildConfig;
import com.pukka.jiecheng.index.IndexActivity;
import com.pukka.jiecheng.login.bean.LoginLogonBean;
import com.pukka.jiecheng.monitor.NetworkMonitor;
import com.pukka.jiecheng.newtv.NewTvView;
import com.pukka.jiecheng.permission.AgreementStoragePermissionGateway;
import com.pukka.jiecheng.request.AsApiErrorCode;
import com.pukka.jiecheng.request.AsApiRequester;
import com.pukka.jiecheng.request.AsHeartBeatManager;
import com.pukka.jiecheng.utils.ActivitedDialog;
@@ -63,6 +67,11 @@ import com.pukka.libcore.utils.SpTimeUtils;
import com.pukka.libcore.utils.StringUtils;
import com.tencent.bugly.crashreport.BuglyLog;
import com.pukka.jiecheng.voice.parser.SkyworthVodParser;
import com.pukka.jiecheng.voice.protocol.VoiceCommand;
import com.pukka.jiecheng.voice.protocol.VoiceExtras;
import com.pukka.jiecheng.voice.service.VoiceCommandService;
import org.json.JSONObject;
import java.util.HashMap;
@@ -76,7 +85,8 @@ import io.reactivex.rxjava3.core.Single;
* @Date:2025-05-14-15
* @Description:启动页 路由控制
*/
public class MainActivity extends AppCompatActivity implements AsApiRequester.CheckStatusCallback {
public class MainActivity extends AppCompatActivity
implements AsApiRequester.CheckStatusCallback, NetworkMonitor.NetworkReceiver {
private static final String TAG = "MainActivity";
private final ViewTreeObserver.OnGlobalFocusChangeListener globalFocusChangeListener = (oldFocus, newFocus) ->
LogUtils.d(TAG, "focus changed old=" + formatFocusView(oldFocus) + ", new=" + formatFocusView(newFocus));
@@ -84,6 +94,8 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
* 用户协议中的同意按钮点击后发送通知
*/
private boolean isShowToLogin = true;
// 家用版标志:激活界面全程不可见,未激活时静默调用家用版激活码激活
private final boolean isHomeEdition = AppConfiguration.getInstance().isHomeEdition();
private final AsApiRequester asApiRequester = AsApiRequester.INSTANCE;
private Context context;
private UserAgreementView userAgreementView;
@@ -108,38 +120,64 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
private String intentRoomNo;
// 防重入:checkBenefits 多次回调(如 onPreset/onUnbound 等)时只触发一次自动激活
private boolean autoActivateAttempted = false;
// 家用版静默激活进行中:避免并发回调在第一次静默激活 in-flight 期间误拉网络错误全屏。
// 仅由 onActivateFailed(家用版静默激活的真正失败回调)清掉。
private boolean isSilentActivationInFlight = false;
// 无激活码版(as8Scan,NEED_ACTIVATION_CODE=false)默认激活码:
// 该变体不展示激活码输入面板,由后端按固定激活码 "8888" 完成激活,
// 网络恢复自动重试:true 时收到网络恢复事件直接 return,防并发重试
private boolean isRetryingNetworkError = false;
// NetworkMonitor 实例:与 BroadcastReceiver 同生命周期
private NetworkMonitor networkMonitor;
// 无激活码版(扫码变体,NEED_ACTIVATION_CODE=false)默认激活码:
// 该变体不展示激活码输入面板,由后端按 BuildConfig.DEFAULT_ACTIVATION_CODE 配置的激活码完成激活,
// 房间号为空字符串。由 tryAutoActivateWithDefaultCode 直接提交。
private static final String DEFAULT_ACTIVATION_CODE = "88888";
// 各渠道默认值由 build.gradle 的 productFlavors 定义:as8Scan="88888",fxScan="UK4KQ",普通渠道=""
private static final String DEFAULT_ACTIVATION_CODE = BuildConfig.DEFAULT_ACTIVATION_CODE;
private final AgreementStoragePermissionGateway agreementStoragePermissionGateway = new AgreementStoragePermissionGateway();
// 语音冷启动待入队命令:见 docs/2026-08-28-语音冷启动链路设计方案.md §4.1
// extras / SP 键统一定义在 VoiceExtras,本处只持有内存态。
private Bundle pendingVoiceExtras;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
context = this;
LogUtils.d("appStart --- activity.onCreate:start");
setContentView(R.layout.activity_main);
// 第三方变体:直接跳转 ThirdPartyEntryActivity
if (AppConfiguration.getInstance().isThirdPartyVariant()) {
Intent intent = new Intent(MainActivity.this, ThirdPartyEntryActivity.class);
// 透传当前 Intent 的 extras(包含 EPG 传入的参数)
intent.putExtras(getIntent());
startActivity(intent);
finish();
return;
}
//registerGlobalFocusListener();
AppManager.getAppManager().addActivity(this);
BuildConfigUtils.initBuildConfig(ContextUtils.getApplicationContext());
ChannelManger.initChannel();
// 解析启动 Intent 中的激活参数(用于自动激活场景)
parseAutoActivateIntent();
// 捕获语音冷启动待入队 extras(见 docs/2026-08-28-语音冷启动链路设计方案.md §4.1)
capturePendingVoiceExtras();
initView();
}
/**
* Cold-launch 经 FLAG_ACTIVITY_CLEAR_TOP 重用现有 MainActivity 实例时(launchMode=standard),
* Android 不会回调 onCreate,新 Intent 通过本方法进入。{@code getIntent()} 默认仍返回原始
* Intent,必须 setIntent(intent) 才能让 capturePendingVoiceExtras 看到新 Intent 上的
* {@code __voice_pending} extras,否则主进程未激活路径下入队会静默丢失。
*
* <p>详见 docs/2026-08-28-语音冷启动链路设计方案.md §4.1。
*/
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
if (intent == null) return;
LogUtils.d(TAG, "onNewIntent, hasVoicePending="
+ intent.getBooleanExtra(VoiceExtras.EXTRA_VOICE_PENDING, false)
+ ", hasActivateCode=" + !TextUtils.isEmpty(intent.getStringExtra("activate_code")));
setIntent(intent);
// 入队仍走 capturePendingVoiceExtras → goToActivity → enqueuePendingVoiceCommand 单点。
capturePendingVoiceExtras();
}
/**
* 解析启动 Intent 中的 activate_code / room_no 参数。
* 仅当两个参数同时存在时,启用后续的 checkBenefits 自动激活流程。
@@ -238,6 +276,8 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
submitDeviceActivateCode(activateCode, roomNo);
}
});
// 注册网络变化广播:用于 init 网络失败全屏页展示期间,网络恢复时自动重试
registerNetworkMonitor();
nextInit();
if (userAgreementView.isShow()) {
userAgreementView.setVisibility(View.VISIBLE);
@@ -402,6 +442,11 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
() -> {
// 家用版:静默激活成功不触碰任何激活 UI;真正的进首页由 onActivated 回调驱动
if (isHomeEdition) {
LogUtils.d(TAG, "home edition activate submitted, wait onActivated to route");
return;
}
deviceNotActivatedView.setSubmitting(false);
deviceNotActivatedView.showActivateMessage("激活成功,正在刷新服务状态...");
ToastUtils.showShortToastSafe("激活成功");
@@ -473,7 +518,45 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
});
}
/**
* 家用版非激活终态统一入口:
* - 尚未尝试过静默激活 → 用家用版默认激活码静默激活一次(tryAutoActivateFromIntent 内部短路到默认码)
* - 已尝试过仍未激活 → 降级到网络错误全屏(含退出按钮),绝不展示激活输入面板
*
* 竞态防护:AsApiRequester 异步派发 onPreset/onUnbound/onActivateFailed 等多个非激活回调时,
* 仅有第一个回调触发 tryAutoActivateFromIntent;后续回调在 autoActivateAttempted=true 短路到
* showHomeActivationFailure。但若静默激活仍在 in-flight 期间被多个回调排队,它们会全部跳到
* showNetworkErrorFullscreen 形成"网络错误"全屏闪现。解决:用 isSilentActivationInFlight
* 标记静默激活进行中,期间到达的非激活回调静默忽略(不拉全屏),让真正的失败回调(onActivateFailed
* 或 onUnbound 终态)统一收口。
*/
private void onHomeNonActivatedState(AsApiRequester.CheckBenefitsResult result) {
if (autoActivateAttempted) {
// 静默激活进行中:忽略并发回调,避免拉网络错误全屏闪现。
if (isSilentActivationInFlight) {
LogUtils.d(TAG, "home edition: silent activation in-flight, ignore duplicate non-activated callback");
return;
}
showHomeActivationFailure(result == null ? null : result.msg);
return;
}
tryAutoActivateFromIntent();
}
/**
* 家用版静默激活失败降级:不弹激活输入面板,改用网络错误全屏(含退出按钮)兜底。
* @param message 后端返回的错误信息,可为空(空则使用通用兜底文案)
*/
private void showHomeActivationFailure(String message) {
LogUtils.e(TAG, "home edition silent activation failed: " + message);
showNetworkErrorFullscreen(message);
}
private void showActivateFail(String message) {
if (isHomeEdition) {
showHomeActivationFailure(message);
return;
}
String safeMessage = StringUtils.isEmpty(message) ? "激活失败,请稍后重试" : message;
runOnUiThread(() -> {
deviceNotActivatedView.setSubmitting(false);
@@ -566,12 +649,97 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
return;
}
LogUtils.d(TAG,"goToActivity --- start: ");
// 语音冷启动待入队命令:捕获到的 extras 入队到 VoiceCommandService.coldStartQueue,
// IndexActivity 首帧渲染完成后由既有 replayColdStartVoiceCommands 钩子回放。
if (pendingVoiceExtras != null) {
enqueuePendingVoiceCommand();
}
newIntent = new Intent();
newIntent.setClass(MainActivity.this, IndexActivity.class);
startActivity(newIntent);
finish();
}
/**
* 从启动 Intent 或 SP 复原语音冷启动 extras。
*
* <p>两路来源:
* <ol>
* <li>当前 Intent 带 {@code __voice_pending=true}(典型场景::voice 进程 cold launch MainActivity,
* 或主进程已存活但未激活时 VoiceInternalReceiver 冷启 MainActivity)</li>
* <li>Intent 不带 extras 但 SP 标记为 true(典型场景:强制升级后新版 App 启动 / appExit 后再次启动)</li>
* </ol>
*
* <p>复原后写入 SP 持久化标志,覆盖"激活完成后才走 goToActivity"或"升级后 MainActivity 重启"
* 两条路径——只要没消费就一直保留直到 enqueuePendingVoiceCommand 显式清掉。
*/
private void capturePendingVoiceExtras() {
Intent it = getIntent();
if (it != null && it.getBooleanExtra(VoiceExtras.EXTRA_VOICE_PENDING, false)) {
pendingVoiceExtras = cloneVoiceExtras(it);
persistPendingVoice(pendingVoiceExtras);
} else if (SharedPreferenceUtils.getBoolean(VoiceExtras.SP_PENDING_VOICE_FLAG, false)) {
pendingVoiceExtras = restorePendingVoice();
}
}
private static Bundle cloneVoiceExtras(Intent src) {
Bundle bundle = new Bundle();
for (String key : new String[]{VoiceExtras.EXTRA_OEM_FILM_NAME, VoiceExtras.EXTRA_OEM_AUTOPLAY,
VoiceExtras.EXTRA_OEM_REQUEST_ID, VoiceExtras.EXTRA_OEM_SOURCE_APP}) {
String value = src.getStringExtra(key);
if (value != null) bundle.putString(key, value);
}
return bundle;
}
private static void persistPendingVoice(Bundle bundle) {
if (bundle == null) return;
SharedPreferenceUtils.put(VoiceExtras.SP_KEY_PENDING_FILM_NAME, bundle.getString(VoiceExtras.EXTRA_OEM_FILM_NAME, ""));
SharedPreferenceUtils.put(VoiceExtras.SP_KEY_PENDING_AUTOPLAY, bundle.getString(VoiceExtras.EXTRA_OEM_AUTOPLAY, ""));
SharedPreferenceUtils.put(VoiceExtras.SP_KEY_PENDING_REQUEST_ID, bundle.getString(VoiceExtras.EXTRA_OEM_REQUEST_ID, ""));
SharedPreferenceUtils.put(VoiceExtras.SP_KEY_PENDING_SENDER, bundle.getString(VoiceExtras.EXTRA_OEM_SOURCE_APP, ""));
SharedPreferenceUtils.put(VoiceExtras.SP_PENDING_VOICE_FLAG, true);
}
private static Bundle restorePendingVoice() {
Bundle bundle = new Bundle();
bundle.putString(VoiceExtras.EXTRA_OEM_FILM_NAME, SharedPreferenceUtils.getString(VoiceExtras.SP_KEY_PENDING_FILM_NAME, ""));
bundle.putString(VoiceExtras.EXTRA_OEM_AUTOPLAY, SharedPreferenceUtils.getString(VoiceExtras.SP_KEY_PENDING_AUTOPLAY, ""));
bundle.putString(VoiceExtras.EXTRA_OEM_REQUEST_ID, SharedPreferenceUtils.getString(VoiceExtras.SP_KEY_PENDING_REQUEST_ID, ""));
bundle.putString(VoiceExtras.EXTRA_OEM_SOURCE_APP, SharedPreferenceUtils.getString(VoiceExtras.SP_KEY_PENDING_SENDER, ""));
return bundle;
}
/**
* 把 pendingVoiceExtras 解析成 VoiceCommand 并入队 coldStartQueue。
*
* <p>状态清理规则(修复 code-review finding #8):
* 仅当 parseJump 成功入队后才清 pendingVoiceExtras + SP 标志。
* parseJump 失败时保留状态,下一次 MainActivity.onCreate 重试;
* 避免 parser reject 时命令被静默丢失。
*/
private void enqueuePendingVoiceCommand() {
try {
String filmName = pendingVoiceExtras.getString(VoiceExtras.EXTRA_OEM_FILM_NAME);
String autoPlay = pendingVoiceExtras.getString(VoiceExtras.EXTRA_OEM_AUTOPLAY);
String requestId = pendingVoiceExtras.getString(VoiceExtras.EXTRA_OEM_REQUEST_ID);
String sender = pendingVoiceExtras.getString(VoiceExtras.EXTRA_OEM_SOURCE_APP);
VoiceCommandService service = VoiceCommandService.getInstance(getApplicationContext());
VoiceCommand cmd = SkyworthVodParser.parseJump(service, filmName, autoPlay, requestId, sender);
if (cmd != null) {
service.enqueueForColdStart(cmd);
pendingVoiceExtras = null;
SharedPreferenceUtils.put(VoiceExtras.SP_PENDING_VOICE_FLAG, false);
LogUtils.d(TAG, "voice command enqueued for cold start: " + requestId);
} else {
LogUtils.w(TAG, "voice command parse returned null, keep pending for retry. requestId=" + requestId);
}
} catch (Throwable t) {
LogUtils.e(TAG, "enqueuePendingVoiceCommand failed: " + t.getMessage());
}
}
/**
* 获取用户信息接口
*/
@@ -592,6 +760,8 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
asApiRequester.requestAsInitDeviceApi(this, new AsApiRequester.InitCallback() {
@Override
public void onSuccess(AsApiRequester.InitResult result) {
// 无论初次调用还是重试,结束即允许下一次网络事件再触发
isRetryingNetworkError = false;
// 初始化成功,启动心跳调度(应用生命周期内保持活跃)
AsHeartBeatManager.getInstance().start(getApplicationContext());
@@ -619,6 +789,8 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
@Override
public void onFail(int errorCode, String message) {
// 重试回调与初次回调共用同一入口,统一清标志
isRetryingNetworkError = false;
MainActivity.this.onFail(errorCode, message);
// runOnUiThread(()->{
// deviceNotActivatedView.setErrorInfo(String.valueOf(errorCode));
@@ -704,6 +876,8 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
// }
// }
AppManager.getAppManager().finishActivity(this);
// 反注册网络变化广播
unregisterNetworkMonitor();
if(asApiRequester != null){
asApiRequester.onDestroy();
}
@@ -796,7 +970,7 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
}
@Override
public void onFail(String message) {
public void onFail(int errorCode, String message) {
handleSdkInitFailure(thirdCardNo, retryCount, "init_fail", message);
}
}
@@ -884,6 +1058,20 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
public void onActivated(AsApiRequester.CheckBenefitsResult result) {
hideNetworkErrorFullscreen();
dismissLoadingDialog();
// 家用版:激活成功后直接进首页,不弹激活成功 dialog、不展示激活界面。
// thirdCardNo 为空是合法终态(与 as8Scan/asAScan line 1069–1083 一致,
// 此处不展示"缺少设备码"提示且不调用 initJetsenxSdk,否则下游依赖
// JetsenxSdkUtil.cachedUid 的代码(如 IndexActivity 视频初始化)会拿到空 uid)。
if (isHomeEdition) {
if (result != null && !TextUtils.isEmpty(result.thirdCardNo)) {
AppConfiguration.getInstance().setExpireTime(result.expireTime);
JetsenxSdkUtil.cacheReusableUid(result.thirdCardNo);
initJetsenxSdkWithRetry(result.thirdCardNo, 0);
}
LogUtils.d(TAG, "home edition onActivated, route to home");
goToActivity();
return;
}
if(result != null && !TextUtils.isEmpty(result.thirdCardNo)) {
AppConfiguration.getInstance().setExpireTime(result.expireTime);
@@ -939,8 +1127,20 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
}
}
@Override
public void onCodeActivated(AsApiRequester.CheckBenefitsResult result) {
// 10900(已通过激活码激活):onActivated 内已按 result.code 特判 10900 分支,委托复用
onActivated(result);
}
@Override
public void onPreset(AsApiRequester.CheckBenefitsResult result) {
// 家用版:未激活(预置)状态 → 静默调用家用版激活码激活,不展示激活界面
if (isHomeEdition) {
LogUtils.d(TAG, "home edition onPreset: trigger silent activation");
onHomeNonActivatedState(result);
return;
}
showActivationLoadingDialog(getString(R.string.common_loading_default_text));
// 设备处于预置状态(未激活):仅展示 loading,由后续异常回调(onActivateFailed/onUnbound 等)
// 或用户主动提交激活码触发真正的激活流程;此处不再自动调用 tryAutoActivateFromIntent
@@ -948,6 +1148,11 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
@Override
public void onActivating(AsApiRequester.CheckBenefitsResult result) {
// 家用版:激活中 → 静默,不展示激活 loading
if (isHomeEdition) {
LogUtils.d(TAG, "home edition onActivating: silent");
return;
}
String loadingMessage = result == null || StringUtils.isEmpty(result.msg)
? getString(R.string.common_loading_default_text)
: result.msg;
@@ -958,6 +1163,14 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
@Override
public void onActivateFailed(AsApiRequester.CheckBenefitsResult result) {
hideNetworkErrorFullscreen();
// 家用版:激活失败 → 静默激活一次或降级网络错误屏,不展示激活界面。
// onActivateFailed 是家用版静默激活的"终态失败"回调,清掉 in-flight 标记
// 让 onHomeNonActivatedState 把降级全屏的决策接管过去。
if (isHomeEdition) {
isSilentActivationInFlight = false;
onHomeNonActivatedState(result);
return;
}
runOnUiThread(()->{
dismissLoadingDialog();
deviceNotActivatedView.setErrorInfo(String.valueOf(result.code));
@@ -978,6 +1191,11 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
@Override
public void onUnbound(AsApiRequester.CheckBenefitsResult result) {
hideNetworkErrorFullscreen();
// 家用版:未绑定 → 静默激活一次或降级网络错误屏,不展示激活界面
if (isHomeEdition) {
onHomeNonActivatedState(result);
return;
}
runOnUiThread(()->{
dismissLoadingDialog();
deviceNotActivatedView.setErrorInfo(String.valueOf(result.code));
@@ -997,6 +1215,11 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
LogUtils.d(TAG, "onExpired checkBenefits code=" + (result == null ? "null" : result.code)
+ ", cardNos already cleared by AsApiRequester dispatcher");
hideNetworkErrorFullscreen();
// 家用版:过期 → 静默激活一次或降级网络错误屏,不展示激活界面
if (isHomeEdition) {
onHomeNonActivatedState(result);
return;
}
runOnUiThread(() -> {
dismissLoadingDialog();
deviceNotActivatedView.setErrorInfo(String.valueOf(result.code));
@@ -1018,6 +1241,11 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
LogUtils.d(TAG, "onOccupied checkBenefits code=" + (result == null ? "null" : result.code)
+ ", cardNos already cleared by AsApiRequester dispatcher");
hideNetworkErrorFullscreen();
// 家用版:占用 → 静默激活一次或降级网络错误屏,不展示激活界面
if (isHomeEdition) {
onHomeNonActivatedState(result);
return;
}
runOnUiThread(() -> {
dismissLoadingDialog();
deviceNotActivatedView.setErrorInfo(String.valueOf(result.code));
@@ -1039,6 +1267,11 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
LogUtils.d(TAG, "onUnknown checkBenefits code=" + (result == null ? "null" : result.code)
+ ", invalid card status (expired/occupied/code-activated) cardNos already cleared by dispatcher");
hideNetworkErrorFullscreen();
// 家用版:未知状态 → 静默激活一次或降级网络错误屏,不展示激活界面
if (isHomeEdition) {
onHomeNonActivatedState(result);
return;
}
runOnUiThread(() -> {
dismissLoadingDialog();
@@ -1053,11 +1286,18 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
@Override
public void onFail(int errorCode, String message) {
if (errorCode == 0) {
// 1007 = 网络错误(无网络/无法访问服务器/超时,message 已是友好文案):
// 拉起全屏网络错误页(含退出按钮),避免网络问题被误提示为"激活失败"
if (errorCode == 0 || errorCode == AsApiErrorCode.ERROR_NETWORK.getCode()) {
showNetworkErrorFullscreen(message);
return;
}
hideNetworkErrorFullscreen();
// 家用版:激活相关失败不弹激活 UI,统一降级到网络错误屏
if (isHomeEdition) {
showHomeActivationFailure(message);
return;
}
runOnUiThread(()->{
dismissLoadingDialog();
deviceNotActivatedView.setErrorInfo(String.valueOf(errorCode));
@@ -1118,9 +1358,72 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
});
}
/**
* 注册网络变化广播:监听 CONNECTIVITY_CHANGE / WIFI_STATE_CHANGED,
* 用于 init 网络错误全屏页期间的网络恢复自动重试。
*
* 与 BaseActivity 的注册方式保持一致(含 SDK 33+ 的 RECEIVER_NOT_EXPORTED 标记)。
*/
private void registerNetworkMonitor() {
networkMonitor = new NetworkMonitor();
networkMonitor.setNetworkNativeReceiver(this);
IntentFilter filter = new IntentFilter();
filter.addAction("android.net.conn.CONNECTIVITY_CHANGE");
filter.addAction("android.net.wifi.WIFI_STATE_CHANGED");
filter.addAction("android.net.wifi.STATE_CHANGE");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(networkMonitor, filter, Context.RECEIVER_NOT_EXPORTED);
} else {
registerReceiver(networkMonitor, filter);
}
}
private void unregisterNetworkMonitor() {
if (networkMonitor != null) {
try {
unregisterReceiver(networkMonitor);
} catch (IllegalArgumentException e) {
// 极少数情况:onDestroy 前已 unregister,忽略避免崩溃
LogUtils.d(TAG, "networkMonitor already unregistered");
}
networkMonitor = null;
}
}
/**
* 网络状态回调(CONNECTIVITY_CHANGE 广播触发,主线程)。
*
* 行为:
* - 网络可用且全屏网络错误页正显示中(说明 init 刚因网络失败)→ 自动触发重试
* - 正在重试 in-flight → 忽略本次事件(防重入)
* - 非"unknown/none"视为可用(覆盖 wifi/mobile/ethernet/vpn/other 五种)
*/
@Override
public void currentNetType(String netType) {
LogUtils.d(TAG, "currentNetType:" + netType);
boolean connected = !"unknown".equals(netType) && !"none".equals(netType);
if (connected && isNetworkErrorFullscreen && !isRetryingNetworkError) {
// 全屏错误页展示期间收到网络恢复 → 自动重试 init
isRetryingNetworkError = true;
hideNetworkErrorFullscreen();
showActivationLoadingDialog("正在重新连接…");
LogUtils.d(TAG, "network restored, auto-retry checkUpgradeThenMembership");
checkUpgradeThenMembership();
}
}
private void exitApp() {
AppManager.getAppManager().setKeep(false);
// setKeep(true) 让 AppManager.appExit() 走"仅 finishAllActivity"分支,
// 不延迟 1s、不 killProcess;主进程降为无 UI 后台但保留内存态,
// :voice 进程与 FGS 不受影响,用户退出后仍能接收 OEM 语音广播(见 §4.4)。
// 脆弱点守卫:setKeep(true) 与 appExit() 之间禁止插入任何异步。
// isKeep 是 AppManager 单例状态;本次执行完后立即 reset 回 false,避免污染
// 后续其它 appExit() 调用方(BaseActivity / IndexActivity / DownApkDialog 等)
// 的进程语义。env 切换(changeToPreview/changeToDebug)与 BenefitRevokedDialog.exitApp
// 仍走旧路径——见设计文档 §4.4。
AppManager.getAppManager().setKeep(true);
AppManager.getAppManager().appExit();
AppManager.getAppManager().setKeep(false);
}

64
app/src/main/java/com/pukka/jiecheng/MyApplication.java Executable file → Normal file
View File

@@ -1,10 +1,13 @@
package com.pukka.jiecheng;
import android.app.ActivityManager;
import android.app.Application;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import androidx.lifecycle.ProcessLifecycleOwner;
import androidx.core.content.ContextCompat;
import com.bytedance.boost_multidex.BoostMultiDex;
import com.facebook.drawee.backends.pipeline.Fresco;
@@ -14,6 +17,9 @@ import com.pukka.jiecheng.device.DeviceIdentityInitManager;
import com.pukka.jiecheng.utils.ActivityMonitor;
import com.pukka.jiecheng.utils.BuglyInitManager;
import com.pukka.jiecheng.utils.CrashHandler;
import com.pukka.jiecheng.voice.receiver.KeepAlivePingReceiver;
import com.pukka.jiecheng.voice.receiver.VoiceInternalReceiver;
import com.pukka.jiecheng.voice.service.VoiceKeepAliveService;
import com.pukka.libcore.fresco.Tls12SocketFactory;
import com.pukka.libcore.fresco.TrustAllManager;
import com.pukka.libcore.utils.AppLifecycleObserver;
@@ -31,6 +37,7 @@ import javax.net.ssl.TrustManager;
*/
public class MyApplication extends Application {
private static final String PROCESS_NAME_MAIN = "tv.huashi.jetsenx.jetsenfilx.jsttv";
private static final String VOICE_PROCESS_SUFFIX = ":voice";
@Override
public void onCreate() {
@@ -59,12 +66,67 @@ public class MyApplication extends Application {
// 网络层初始化(必须在 Fresco.initialize 之前,避免首签请求受 SSL 状态影响)
initNetworkLayer();
// 跨进程语音命令接收器::voice 进程 SkyworthVoiceAdapter 投递 INTERNAL_FORWARD 时由本接收器处理
registerVoiceInternalReceiver();
// 兜底拉起 :voice 进程 FGS,覆盖桌面图标 cold start / appExit 后再次进入 / force-stop 后首次启动场景
ensureVoiceKeepAliveRunning(this);
//preInitWebCore();
//LogUtils.d("onCreate: channel_id : "+ AppConfiguration.getInstance().getChannelId()+ " isNeadMy : "+ AppConfiguration.getInstance().isNeedMy() + " control(Touch): "+AppConfiguration.getInstance().isTouchMode());
}
Fresco.initialize(this);
}
/**
* 动态注册主进程语音命令接收器。
* 接收 :voice 进程 {@link com.pukka.jiecheng.voice.adapter.SkyworthVoiceAdapter}
* 投递的 {@link VoiceInternalReceiver#ACTION_VOICE_FORWARD} 显式广播。
*
* <p>OEM 创维广播({@code ACTION_VOD})的注册已不在此处——移到 :voice 进程
* {@link VoiceKeepAliveService} 的 FGS 内动态注册,避免主进程死亡时丢失接收能力。
*/
private void registerVoiceInternalReceiver() {
try {
IntentFilter filter = new IntentFilter(VoiceInternalReceiver.ACTION_VOICE_FORWARD);
registerReceiver(new VoiceInternalReceiver(), filter);
LogUtils.d("onCreate: voice internal receiver registered");
} catch (Throwable t) {
LogUtils.e("onCreate: voice internal receiver register failed: " + t.getMessage());
}
}
/**
* 兜底拉起 :voice 进程 FGS:仅当 :voice 进程不存在时启动,避免通知闪烁。
*
* <p>触发场景:
* <ul>
* <li>桌面图标 cold start(开机后用户首次点开 App)</li>
* <li>appExit() 后再次进入(主进程降为后台但 :voice 已被 LMK 杀掉)</li>
* <li>force-stop 后用户首次启动</li>
* </ul>
*
* <p>BOOT_COMPLETED 拉起由 {@link com.pukka.jiecheng.voice.receiver.BootCompletedReceiver}
* 负责;本方法只在 MyApplication.onCreate 时(即用户进 App 的时刻)兜底。
*
* <p>另外通过 {@link KeepAlivePingReceiver#scheduleFirstPing} 注册 5s 后的自广播 ping,
* 保证即便 BOOT_COMPLETED 主路径失败,本兜底也失败的情况下,仍有第三层保险接力。
*/
private void ensureVoiceKeepAliveRunning(Context ctx) {
try {
ActivityManager am = (ActivityManager) ctx.getSystemService(Context.ACTIVITY_SERVICE);
if (am == null) return;
String voiceProcess = ctx.getPackageName() + VOICE_PROCESS_SUFFIX;
for (ActivityManager.RunningAppProcessInfo p : am.getRunningAppProcesses()) {
if (voiceProcess.equals(p.processName)) return;
}
ContextCompat.startForegroundService(ctx, new Intent(ctx, VoiceKeepAliveService.class));
LogUtils.d("onCreate: voice keepalive service start requested");
// 第三层兜底:5s 后 KeepAlivePingReceiver 自广播接力(即便上面失败也接续)
KeepAlivePingReceiver.scheduleFirstPing(ctx);
} catch (Throwable t) {
LogUtils.e("onCreate: voice keepalive service start failed: " + t.getMessage());
}
}
/**
* 初始化脚手架新网络层(Retrofit + OkHttp + Moshi + Coroutines)。
*

69
app/src/main/java/com/pukka/jiecheng/TestActivity.java Executable file → Normal file
View File

@@ -8,14 +8,28 @@ import android.widget.Button;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import com.pukka.jiecheng.order.OrderActivity;
import com.pukka.jiecheng.request.AsApiRequester;
import com.pukka.jiecheng.request.AsHeartBeatManager;
import com.pukka.libcore.utils.LogUtils;
/**
* @file TestActivity.java
* @description 用于测试的 Activity,包含跳转到 MainActivity 的按钮,以实现简单的联调和入口测试
* @description 用于测试的 Activity:进入页面即触发首签拿到 sign,启动心跳;
* 然后通过按钮跳转到 OrderActivity,验证 sign 已自动同步到后续接口
* @author ainetv
* @date 2026-03-20
*/
public class TestActivity extends AppCompatActivity {
private static final String TAG = "TestActivity";
/**
* AsApiRequester 单例(Kotlin object),由 @JvmStatic 暴露给 Java 端调用。
* 与 MainActivity 保持一致:业务侧持有单例引用,避免每次现取。
*/
private final AsApiRequester asApiRequester = AsApiRequester.INSTANCE;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -25,9 +39,60 @@ public class TestActivity extends AppCompatActivity {
btnGoToMain.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent intent = new Intent(TestActivity.this, MainActivity.class);
Intent intent = new Intent(TestActivity.this, OrderActivity.class);
startActivity(intent);
}
});
// 进入页面即触发首签:拿到 sign 后才允许后续接口访问,否则 OrderActivity
// 等业务页面的请求都会因缺 sign 被服务端 401
triggerInitAndStartHeartBeat();
}
/**
* 触发首签接口,并在成功后启动心跳调度。
*
* <p>sign 同步链路(关键,整个项目都依赖这条路径):</p>
* <ol>
* <li>requestAsInitDeviceApi 成功后,AsApiRequester 内部调用
* {@code Network.tokenStore.refreshAndPersist(sign)}:</li>
* <ul>
* <li>写入 TokenStore(SpTokenStore,内存 + SP 异步)—— HeaderInterceptor 每次发请求都从这里读</li>
* <li>同步写入 SharedPreferenceUtils(key="as_sign")—— 兼容旧 Java 代码直接读 SP</li>
* </ul>
* <li>HeaderInterceptor.intercept() 在每个 OkHttp 请求发出前注入 {@code sign} 请求头
* (首签/刷新自身接口除外),所以从 TestActivity 跳转到 OrderActivity 后,
* OrderActivity 发起的任意接口(订单列表、扫码等)都会自动带上正确的 sign,
* 无需在跳转处做任何额外传参。</li>
* <li>AsHeartBeatManager 内部的心跳请求也走同一套 HeaderInterceptor,
* 因此心跳能持续携带有效 sign;服务端后续若在心跳响应里下发新的 sign
* ({@code refreshSign} 等命令),TokenStore 也会被同步刷新,下一次请求自动接力。</li>
* </ol>
*
* <p>心跳启动必须用 getApplicationContext():AsHeartBeatManager 内部用
* DispatchQueue.global.timer 启动了一个常驻定时器(应用生命周期内保持活跃),
* Activity context 会泄漏,applicationContext 安全。</p>
*/
private void triggerInitAndStartHeartBeat() {
asApiRequester.requestAsInitDeviceApi(this, new AsApiRequester.InitCallback() {
@Override
public void onSuccess(AsApiRequester.InitResult result) {
// 初始化成功,启动心跳调度(应用生命周期内保持活跃)
// 心跳本身也带 sign(HeaderInterceptor 自动注入),并在响应里可下发控制命令
AsHeartBeatManager.getInstance().start(getApplicationContext());
String signPreview = (result != null && result.sign != null && !result.sign.isEmpty())
? result.sign.substring(0, Math.min(8, result.sign.length())) + "…"
: "null";
LogUtils.i(TAG, "init success, sign=" + signPreview + ", roomNo=" + result.roomNo);
}
@Override
public void onFail(int errorCode, String message) {
// 首签失败:心跳无法启动(缺 sign 触发前置校验 1001),OrderActivity 跳转后
// 所有接口都会 401。仅打日志,不静默吞掉——调试页应该把失败暴露给开发者。
LogUtils.e(TAG, "requestAsInitDeviceApi fail, code=" + errorCode + ", msg=" + message);
}
});
}
}

View File

@@ -1,500 +0,0 @@
package com.pukka.jiecheng;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
import com.pukka.jiecheng.config.AppConfiguration;
import com.pukka.jiecheng.index.IndexActivity;
import com.pukka.jiecheng.request.AsApiRequester;
import com.pukka.jiecheng.utils.BuildConfigUtils;
import com.pukka.jiecheng.utils.ChannelManger;
import com.pukka.jiecheng.utils.JetsenxSdkUtil;
import com.pukka.jiecheng.video.VideoDetailActivity;
import com.pukka.jiecheng.widget.DeviceUnregisteredView;
import com.pukka.libcore.threading.DispatchQueue;
import com.pukka.libcore.utils.AppManager;
import com.pukka.libcore.utils.ContextUtils;
import com.pukka.libcore.utils.LogUtils;
import com.pukka.libcore.utils.StringUtils;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.net.NetworkInterface;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
/**
* 第三方 EPG 调用入口 Activity。
*
* 仅在 thirdparty flavor 中使用,通过 Intent action
* {@code com.pukka.jiecheng.action.THIRD_PARTY_PLAY} 启动。
*
* 流程:
* 1. 解析 Intent extras(mac/wifiMac/deviceSn/deviceUid/programCode)
* 2. 注入 ThirdPartyDeviceParams 到 AsApiRequester
* 3. 执行 init -> checkBenefits 流程
* 4. 权益通过:初始化 JetsenxSdk -> 路由到 VideoDetailActivity 或 IndexActivity
* 5. 权益未通过:展示 DeviceUnregisteredView 阻断页
*/
public class ThirdPartyEntryActivity extends AppCompatActivity implements AsApiRequester.CheckStatusCallback {
private static final String TAG = "ThirdPartyEntryActivity";
private static final String EXTRA_MAC = "mac";
private static final String EXTRA_WIFI_MAC = "wifiMac";
private static final String EXTRA_DEVICE_SN = "deviceSn";
private static final String EXTRA_DEVICE_UID = "deviceUid";
private static final String EXTRA_PROGRAM_CODE = "programCode";
/** init 业务错误(第三方后端 code=400 设备未注册)提示文案 */
private static final String MSG_DEVICE_NOT_REGISTERED = "设备未注册,请返回首页购买";
/** checkBenefits 权益未生效(非 ACTIVE 终态/轮询超时)提示文案 */
private static final String MSG_BENEFIT_NOT_BOUND = "设备未绑定权益,请返回首页购买";
/** MAC 硬件锚定校验失败(EPG 传入的 mac 不属于本机网卡)提示文案 */
private static final String MSG_DEVICE_MISMATCH = "设备校验失败,请返回首页重新进入";
/** 第三方后端 init 设备未注册业务错误码 */
private static final int INIT_CODE_DEVICE_NOT_REGISTERED = 400;
/** 权益轮询超时内部错误码(对齐 AsApiRequester POLLING_TIMEOUT 路径) */
private static final int CHECK_CODE_POLLING_TIMEOUT = 1008;
private final AsApiRequester asApiRequester = AsApiRequester.INSTANCE;
private DeviceUnregisteredView deviceUnregisteredView;
private View networkErrorContainer;
private TextView networkErrorMessageView;
private Button networkErrorRetryButton;
private View loadingContainer;
private TextView loadingMessageView;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private String programCode;
/** EPG 通过 Intent 传入的 mac(用于 MAC 硬件锚定校验) */
private String passedMac;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_thirdparty_entry);
AppManager.getAppManager().addActivity(this);
// 与原版 MainActivity 对齐的全局初始化:
// 1. BuildConfigUtils:同步 FLAVOR/BUILD_TYPE/VERSION 到 libcore(JcConfig 域名选择依赖)
// 2. ChannelManger.initChannel():覆盖 SDK 凭证为正式业务参数(ACCESSER/AGCODE/comboCode)。
// 缺失会导致 SDK 以默认测试凭证(JSTPKTEST)初始化,后端天卡权益挂在正式凭证下,
// playAuth 鉴权返回 -110 未订购。
BuildConfigUtils.initBuildConfig(ContextUtils.getApplicationContext());
ChannelManger.initChannel();
parseIntentExtras();
initView();
// MAC 硬件锚定校验:EPG 传入的 mac 必须属于本机网卡,防止一个有效凭证多设备共用
if (!verifyEpgMacAnchoring()) {
showDeviceUnregistered(0, MSG_DEVICE_MISMATCH);
return;
}
initCheckBenefitsFlow();
}
/**
* 解析 Intent extras,组装 ThirdPartyDeviceParams 并注入 AsApiRequester。
*/
private void parseIntentExtras() {
Intent intent = getIntent();
if (intent == null) {
return;
}
String mac = intent.getStringExtra(EXTRA_MAC);
String wifiMac = intent.getStringExtra(EXTRA_WIFI_MAC);
String deviceSn = intent.getStringExtra(EXTRA_DEVICE_SN);
String deviceUid = intent.getStringExtra(EXTRA_DEVICE_UID);
programCode = intent.getStringExtra(EXTRA_PROGRAM_CODE);
passedMac = mac;
AsApiRequester.ThirdPartyDeviceParams params = new AsApiRequester.ThirdPartyDeviceParams(
mac, wifiMac, deviceSn, deviceUid);
AsApiRequester.setThirdPartyDeviceParams(params);
// EPG 传入的 mac 同步覆盖到 SDK init:后端权益以 EPG 注册的 MAC 绑定,
// JetsenxSdkUtil.init 简化重载内部不再回退本机自采集 MAC(否则权益不生效)
JetsenxSdkUtil.setMacOverride(mac);
LogUtils.d(TAG, "parseIntentExtras: mac=" + mac + ", deviceSn=" + deviceSn
+ ", deviceUid=" + deviceUid + ", programCode=" + programCode);
}
/**
* MAC 硬件锚定校验(防一个有效凭证多设备共用)。
*
* 背景:app 完全信任 EPG 传入的 mac 做设备识别与 SDK 鉴权,若恶意 EPG 把已注册设备的
* mac 传给任意其他设备,即可用一个凭证给所有设备轮流播放。本校验把设备身份锚定到硬件:
* EPG 传入的 mac 必须匹配本机任一网卡的真实硬件 MAC。
*
* 规则:
* - 未传 mac:不校验(走自采集,由后端注册匹配兜底)
* - 传入 mac 匹配本机任一网卡(eth0/wlan0 等):通过
* - 传入 mac 不匹配任何本机网卡:阻断
* - 本机网卡 MAC 全部读取失败:放行(fail-open,避免极端权限环境误伤正常设备)
*
* 兼容性:EPG 可能读取与本 app 不同的网卡(如 EPG 读 wlan0、app 默认自采集 eth0),
* 因此校验集包含本机全部网卡的 MAC,而非仅 app 自采集那一个。
*/
private boolean verifyEpgMacAnchoring() {
if (TextUtils.isEmpty(passedMac)) {
LogUtils.d(TAG, "MAC anchoring skip: no mac passed");
return true;
}
Set<String> deviceMacs = collectDeviceMacAddresses();
if (deviceMacs.isEmpty()) {
LogUtils.w(TAG, "MAC anchoring fail-open: no device mac readable");
return true;
}
boolean matched = deviceMacs.contains(normalizeMac(passedMac));
LogUtils.d(TAG, "MAC anchoring result=" + matched
+ ", passedMac=" + passedMac + ", deviceMacCount=" + deviceMacs.size());
return matched;
}
/**
* 收集本机所有网卡的硬件 MAC(归一化形式:小写、去冒号/横线)。
* 双通道读取:NetworkInterface 遍历 + sysfs 网卡 address 文件兜底。
* 过滤回环、全零、格式非法的地址。
*/
private Set<String> collectDeviceMacAddresses() {
Set<String> macs = new HashSet<>();
// 通道一:NetworkInterface 遍历(覆盖 eth0/wlan0/物理及虚拟网卡)
try {
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
while (interfaces != null && interfaces.hasMoreElements()) {
NetworkInterface ni = interfaces.nextElement();
if (ni == null || ni.isLoopback() || ni.isPointToPoint()) {
continue;
}
byte[] hw = ni.getHardwareAddress();
addNormalizedMac(macs, bytesToMac(hw));
}
} catch (Exception e) {
LogUtils.e(TAG, "collectDeviceMacAddresses NetworkInterface error: " + e.getMessage());
}
// 通道二:/sys/class/net/*/address 文件(部分系统 NetworkInterface 拿不到硬件地址)
try {
File[] ifaces = new File("/sys/class/net").listFiles();
if (ifaces != null) {
for (File iface : ifaces) {
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader(new File(iface, "address")));
String line = reader.readLine();
addNormalizedMac(macs, line);
} catch (Exception ignore) {
// 单个接口读取失败不影响其他接口
} finally {
if (reader != null) {
try {
reader.close();
} catch (Exception ignore) {
}
}
}
}
}
} catch (Exception e) {
LogUtils.e(TAG, "collectDeviceMacAddresses sysfs error: " + e.getMessage());
}
return macs;
}
/** 归一化并加入集合;过滤空值、全零、长度非 12 的非法地址 */
private void addNormalizedMac(Set<String> out, String mac) {
String normalized = normalizeMac(mac);
if (normalized.length() == 12
&& !"000000000000".equals(normalized)
&& !normalized.matches("0+")) {
out.add(normalized);
}
}
/** MAC 归一化:小写、去掉冒号/横线、去首尾空白 */
private String normalizeMac(String mac) {
if (mac == null) {
return "";
}
return mac.trim().toLowerCase(Locale.ROOT).replace(":", "").replace("-", "");
}
/** 字节数组转冒号分隔的 MAC 字符串 */
private String bytesToMac(byte[] bytes) {
if (bytes == null || bytes.length != 6) {
return "";
}
StringBuilder sb = new StringBuilder(17);
for (int i = 0; i < bytes.length; i++) {
if (i > 0) {
sb.append(':');
}
sb.append(String.format("%02x", bytes[i]));
}
return sb.toString();
}
private void initView() {
deviceUnregisteredView = findViewById(R.id.device_unregistered_view);
networkErrorContainer = findViewById(R.id.network_error_container);
networkErrorMessageView = findViewById(R.id.network_error_message);
networkErrorRetryButton = findViewById(R.id.network_error_retry_btn);
loadingContainer = findViewById(R.id.loading_container);
loadingMessageView = findViewById(R.id.loading_message);
networkErrorRetryButton.setFocusable(true);
networkErrorRetryButton.setFocusableInTouchMode(true);
networkErrorRetryButton.setClickable(true);
networkErrorRetryButton.setEnabled(true);
networkErrorRetryButton.setOnClickListener(v -> initCheckBenefitsFlow());
deviceUnregisteredView.setOnExitListener(this::exitApp);
}
/**
* init -> checkBenefits 流程。
* 参考 MainActivity.checkUpgradeThenMembership,跳过升级检查。
*/
private void initCheckBenefitsFlow() {
hideNetworkError();
hideDeviceUnregistered();
showLoading("正在初始化...");
asApiRequester.requestAsInitDeviceApi(this, new AsApiRequester.InitCallback() {
@Override
public void onSuccess(AsApiRequester.InitResult result) {
asApiRequester.requestAsCheckBenefitsApi(
ThirdPartyEntryActivity.this, ThirdPartyEntryActivity.this);
}
@Override
public void onFail(int errorCode, String message) {
if (errorCode == INIT_CODE_DEVICE_NOT_REGISTERED) {
// 服务端业务错误:设备未在后端注册,展示阻断页(文案固定)
showDeviceUnregistered(0, MSG_DEVICE_NOT_REGISTERED);
} else {
// 网络/内部错误:展示重试页
showNetworkError(message);
}
}
});
}
// ====== CheckStatusCallback ======
@Override
public void onActivated(AsApiRequester.CheckBenefitsResult result) {
hideLoading();
hideNetworkError();
routeAfterActivated(result);
}
@Override
public void onPreset(AsApiRequester.CheckBenefitsResult result) {
String msg = (result == null || StringUtils.isEmpty(result.msg))
? "正在激活..." : result.msg;
showLoading(msg);
}
@Override
public void onActivating(AsApiRequester.CheckBenefitsResult result) {
String msg = (result == null || StringUtils.isEmpty(result.msg))
? "正在激活..." : result.msg;
showLoading(msg);
}
@Override
public void onActivateFailed(AsApiRequester.CheckBenefitsResult result) {
showDeviceUnregistered(0, MSG_BENEFIT_NOT_BOUND);
}
@Override
public void onUnbound(AsApiRequester.CheckBenefitsResult result) {
showDeviceUnregistered(0, MSG_BENEFIT_NOT_BOUND);
}
@Override
public void onExpired(AsApiRequester.CheckBenefitsResult result) {
showDeviceUnregistered(0, MSG_BENEFIT_NOT_BOUND);
}
@Override
public void onOccupied(AsApiRequester.CheckBenefitsResult result) {
showDeviceUnregistered(0, MSG_BENEFIT_NOT_BOUND);
}
@Override
public void onUnknown(AsApiRequester.CheckBenefitsResult result) {
showDeviceUnregistered(0, MSG_BENEFIT_NOT_BOUND);
}
@Override
public void onFail(int errorCode, String message) {
if (errorCode == CHECK_CODE_POLLING_TIMEOUT) {
// 权益轮询超时(设备已注册但卡一直未生效 ACTIVE):权益未绑定,展示阻断页
showDeviceUnregistered(0, MSG_BENEFIT_NOT_BOUND);
} else {
// 网络错误(0)或内部错误(1001/1003/1004/1007):展示重试页
showNetworkError(message);
}
}
// ====== 权益通过后的路由 ======
/**
* 权益通过后:
* 1. 清理 ThirdPartyDeviceParams
* 2. 用 thirdCardNo 初始化 JetsenxSdk
* 3. 有 programCode -> VideoDetailActivity 播放;无 programCode -> IndexActivity
* 4. finish 当前 Activity
*/
private void routeAfterActivated(AsApiRequester.CheckBenefitsResult result) {
AsApiRequester.clearThirdPartyDeviceParams();
String thirdCardNo = result != null ? result.thirdCardNo : "";
if (TextUtils.isEmpty(thirdCardNo)) {
showDeviceUnregistered(0, MSG_BENEFIT_NOT_BOUND);
return;
}
if (result.expireTime != null) {
AppConfiguration.getInstance().setExpireTime(result.expireTime);
}
JetsenxSdkUtil.cacheReusableUid(thirdCardNo);
initJetsenxSdkWithRetry(thirdCardNo, 0);
if (!TextUtils.isEmpty(programCode)) {
VideoDetailActivity.start(this, programCode, "",
VideoDetailActivity.ModuleSourceType.HOME_PAGE, false);
} else {
Intent intent = new Intent(this, IndexActivity.class);
startActivity(intent);
}
finish();
}
/**
* 用 thirdCardNo 初始化 JetsenxSdk,失败最多重试 3 次。
* 参考 MainActivity.initJetsenxSdkWithRetry,在子线程执行避免 ANR。
*/
private void initJetsenxSdkWithRetry(String thirdCardNo, int retryCount) {
DispatchQueue.global.async(() -> {
try {
JetsenxSdkUtil.init(
ContextUtils.getApplicationContext(),
thirdCardNo, new JetsenxSdkUtil.InitMemberCodeCallback() {
@Override
public void onSuccess(String memberCode) {
JetsenxSdkUtil.cacheReusableUid(thirdCardNo);
LogUtils.d(TAG, "Jetsenx SDK init success, cache thirdCardNo");
}
@Override
public void onFail(String message) {
LogUtils.e(TAG, "Jetsenx SDK init fail: " + message);
if (retryCount < 3) {
mainHandler.postDelayed(
() -> initJetsenxSdkWithRetry(thirdCardNo, retryCount + 1), 1500);
}
}
}
);
} catch (Exception e) {
LogUtils.e(TAG, "Jetsenx SDK init exception: " + e.getMessage());
if (retryCount < 3) {
mainHandler.postDelayed(
() -> initJetsenxSdkWithRetry(thirdCardNo, retryCount + 1), 1500);
}
}
});
}
// ====== UI 状态控制 ======
private void showLoading(String message) {
runOnUiThread(() -> {
hideNetworkError();
hideDeviceUnregistered();
loadingContainer.setVisibility(View.VISIBLE);
if (!TextUtils.isEmpty(message)) {
loadingMessageView.setText(message);
}
});
}
private void hideLoading() {
runOnUiThread(() -> loadingContainer.setVisibility(View.GONE));
}
/**
* 展示设备未注册阻断页。
*/
private void showDeviceUnregistered(int errorCode, String message) {
runOnUiThread(() -> {
hideLoading();
hideNetworkError();
deviceUnregisteredView.setStatusInfo(errorCode, message);
deviceUnregisteredView.setVisibility(View.VISIBLE);
});
}
private void hideDeviceUnregistered() {
deviceUnregisteredView.setVisibility(View.GONE);
}
/**
* 展示网络错误页,重试按钮重新执行 initCheckBenefitsFlow。
*/
private void showNetworkError(String message) {
runOnUiThread(() -> {
hideLoading();
hideDeviceUnregistered();
String displayMessage = StringUtils.isEmpty(message)
? getString(R.string.str_network_error_fallback) : message;
networkErrorMessageView.setText(displayMessage);
networkErrorContainer.setVisibility(View.VISIBLE);
networkErrorRetryButton.requestFocus();
});
}
private void hideNetworkError() {
networkErrorContainer.setVisibility(View.GONE);
}
// ====== 生命周期 ======
private void exitApp() {
AppManager.getAppManager().setKeep(false);
AppManager.getAppManager().appExit();
}
@Override
public void onBackPressed() {
super.onBackPressed();
exitApp();
}
@Override
protected void onDestroy() {
super.onDestroy();
AppManager.getAppManager().finishActivity(this);
if (asApiRequester != null) {
asApiRequester.onDestroy();
}
mainHandler.removeCallbacksAndMessages(null);
}
}

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

@@ -12,6 +12,7 @@ import com.pukka.jiecheng.BuildConfig;
* 3. 提供构建类型判断 (isRelease)
* 4. 提供酒店类型获取 (getHotelType)
* 5. 提供是否显示我的页面配置 (isNeedMy)
* 6. 提供家用版判断 (isHomeEdition)
*
* - 屏蔽底层 BuildConfig 细节,解耦业务逻辑。
* - 统一入口,便于未来修改配置源。
@@ -108,7 +109,7 @@ public class AppConfiguration {
}
/**
* 是否需要显示"我的"页面
* 是否需要显示“我的”页面
* @return true: 显示; false: 隐藏
*/
public boolean isNeedMy() {
@@ -116,11 +117,21 @@ public class AppConfiguration {
}
/**
* 是否为第三方变体
* @return true: thirdparty flavor; false: 其他渠道
* 是否为家用版(home edition)
* @return true: 家用版; false: 商用版
*/
public boolean isThirdPartyVariant() {
return BuildConfig.THIRDPARTY_VARIANT;
public boolean isHomeEdition() {
return BuildConfig.IS_HOME_EDITION;
}
/**
* 是否为行业版(商用版中非扫码变体):aishi / ruigeer / ceshi 等。
* 行业版语义上必须先激活,checkBenefits 业务码只能是 10200;落到 10900 / 10300-10800
* 视为后端异常,由 {@link com.pukka.jiecheng.video.VideoDetailActivity}
* 走 showEntitlementError 错误视图,不引导付费。
*/
public boolean isIndustryEdition() {
return !isHomeEdition() && !BuildConfig.SCAN_VARIANT;
}
/**
@@ -128,6 +139,10 @@ public class AppConfiguration {
* @return 心跳请求间隔周期 (毫秒)
*/
public long getHeartBeatInterval() {
// Debug环境下心跳间隔10秒,其他环境使用默认5分钟
if (!isRelease()) {
return 30000L;
}
return HEART_BEAT_INTERVAL;
}

View File

@@ -5,11 +5,13 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import com.pukka.jiecheng.R
import com.pukka.jiecheng.base.BaseActivity
import com.pukka.jiecheng.config.AppConfiguration
import com.pukka.jiecheng.utils.Surround51Manager
import com.pukka.jiecheng.utils.ToastUtils
import com.pukka.libcore.base.BaseConst
import com.pukka.libcore.boxShared.BoxSharedParamsUtils
@@ -37,7 +39,10 @@ class DeviceActivity : BaseActivity() {
var mTvMac: TextView? = null
var mTvSn: TextView? = null
var mTvExpireTime: TextView? = null
var mClCheckUpdate: ConstraintLayout? = null
var mTvAudioPath: TextView? = null
var mLlSurround51: LinearLayout? = null
var mTvSurround51Value: TextView? = null
var mClConfirm: ConstraintLayout? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_device)
@@ -68,20 +73,40 @@ class DeviceActivity : BaseActivity() {
mTvExpireTime!!.text = "暂无数据"
}
mClCheckUpdate = findViewById<ConstraintLayout>(R.id.id_cl_check_update)
// 音频能力检测:AC3直通 > 平台硬解 > FFmpeg软解
mTvAudioPath = findViewById<TextView>(R.id.id_tv_audio_path)
mTvAudioPath!!.text = Surround51Manager.getAudioPathDesc(this)
// 5.1 环绕声播放开关:关闭时播放普通 2.0 档
mLlSurround51 = findViewById<LinearLayout>(R.id.id_ll_surround51)
mTvSurround51Value = findViewById<TextView>(R.id.id_tv_surround51_value)
updateSurround51Value()
mLlSurround51?.setOnClickListener {
Surround51Manager.set51Enabled(!Surround51Manager.is51Enabled())
updateSurround51Value()
ToastUtils.showShortToastSafe(
if (Surround51Manager.is51Enabled()) "已开启 5.1 环绕声,重新进入播放生效" else "已关闭 5.1 环绕声,播放普通 2.0 声道"
)
}
mClConfirm = findViewById<ConstraintLayout>(R.id.id_cl_confirm)
initGamePad()
enableBackButton()
}
private fun updateSurround51Value() {
mTvSurround51Value?.text = if (Surround51Manager.is51Enabled()) "开" else "关"
}
private fun initListener() {
// mClCheckUpdate!!.setOnClickListener(View.OnClickListener { v: View? ->
// // TODO: 2025/6/10 APK更新
//// DownApkDialog(this).initDown2()
//
// //检测更新
// // TODO: 2025/6/10 APK更新
//// new DownApkDialog(getActivity()).initDown2();
mClConfirm?.setOnClickListener(View.OnClickListener { v: View? ->
finish()
// TODO: 2025/6/10 APK更新
// DownApkDialog(this).initDown2()
//检测更新
// TODO: 2025/6/10 APK更新
// new DownApkDialog(getActivity()).initDown2();
// ToastUtils.showLongBottomToast("暂无新版本")
// })
})
}
}

View File

@@ -9,7 +9,7 @@ import com.pukka.jiecheng.config.AppConfiguration;
import com.pukka.libcore.boxShared.BoxSharedParamsUtils;
import com.pukka.libcore.utils.LogUtils;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Locale;
@@ -87,7 +87,7 @@ public class DeviceIdentityProvider {
return "";
}
MessageDigest digest = MessageDigest.getInstance("MD5");
byte[] hashBytes = digest.digest(source.getBytes(Charset.forName("UTF-8")));
byte[] hashBytes = digest.digest(source.getBytes(StandardCharsets.UTF_8));
int usedBytes = Math.min(MD5_TRUNCATED_BYTES, hashBytes.length);
if (usedBytes <= 0) {
return "";
@@ -112,7 +112,7 @@ public class DeviceIdentityProvider {
return "";
}
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = digest.digest(source.getBytes(Charset.forName("UTF-8")));
byte[] hashBytes = digest.digest(source.getBytes(StandardCharsets.UTF_8));
StringBuilder hexBuilder = new StringBuilder(hashBytes.length * 2);
for (byte hashByte : hashBytes) {
hexBuilder.append(String.format(Locale.ROOT, "%02X", hashByte));

View File

View File

View File

View File

View File

@@ -0,0 +1,181 @@
package com.pukka.jiecheng.downapk
import android.app.Activity
import com.pukka.jiecheng.config.AppConfiguration
import com.pukka.jiecheng.device.DeviceIdentityInitManager
import com.pukka.jiecheng.network.ApiRequesters
import com.pukka.jiecheng.network.api.StoreVersionApi
import com.pukka.jiecheng.network.dto.StoreVersionInfoDto
import com.pukka.jiecheng.network.dto.StoreVersionReq
import com.pukka.jiecheng.network.dto.StoreVersionDto
import com.pukka.jiecheng.network.from
import com.pukka.jiecheng.network.infra.ApiError
import com.pukka.jiecheng.network.map
import com.pukka.jiecheng.utils.ToastUtils
import com.pukka.libcore.boxShared.MacUtils
import com.pukka.libcore.utils.AppUtils
import com.pukka.libcore.utils.LogUtils
/**
* App 内手动触发升级入口(酒店版"我的"页面专用)。
*
* 流程:
* 1. 拼装 store-version 请求(设备标识 + 渠道/控制类型)
* 2. 调 POST /api/jst/device/store-version
* 3. 服务端返回 data.version 后,按当前编译变体选 touchUrl / keyUrl
* 4. 复用 [UpgradeHelper.upgrade] 弹框 + 下载 + 安装链路(不引入新的弹框)
*
* Java 调用方通过 [checkUpdate] 直接进入;采用 Kotlin object + @JvmStatic 是
* 为了让 Java 侧只需一行静态调用即可,无需实例化或处理 Kotlin object 单例语义。
*/
object StoreVersionUpgrader {
private const val TAG = "StoreVersionUpgrader"
private const val MSG_UP_TO_DATE = "已是最新版本"
private const val MSG_NETWORK_ERROR = "网络异常,请稍后重试"
/**
* 触发升级检查入口。Java 侧直接调用:
*
* StoreVersionUpgrader.checkUpdate(activity)
*
* 已在 App.onCreate 中完成 Network.init,调用前无需额外初始化。
*/
@JvmStatic
fun checkUpdate(activity: Activity?) {
if (activity == null || activity.isFinishing()) return
val appContext = activity.applicationContext
// 1) 拼装请求字段:与 AsApiRequester.buildInitReq 同源(设备标识、MAC、渠道、控制类型)
// 注意:DeviceIdentityInitManager.getCachedOrInit 首次会同步走 AIDL,冷启动首点可能略慢;
// 正常流程下走缓存分支,几乎瞬时返回。
val macPair: Array<String> = runCatching { MacUtils.getDeviceMacPair(appContext) }
.getOrDefault(arrayOf("", ""))
val ethMac = macPair.getOrNull(0).orEmpty()
val wifiMac = macPair.getOrNull(1).orEmpty()
val identity: DeviceIdentityInitManager.Result? =
try {
DeviceIdentityInitManager.getCachedOrInit(appContext)
} catch (e: Throwable) {
LogUtils.w(TAG, "getCachedOrInit failed: ${e.message}")
null
}
val cfg = AppConfiguration.getInstance()
val touch = try { cfg.isTouchMode } catch (e: Throwable) { false }
val orgCode = cfg.channelId.orEmpty()
// 与 buildInitReq 完全一致:deviceType / controlType 同值;touch→"touch",否则→"remote"
val deviceType = if (touch) "touch" else "remote"
val req = StoreVersionReq(
mac = ethMac,
wifiMac = wifiMac,
deviceSn = identity?.deviceSn.orEmpty(),
deviceUid = identity?.deviceUid.orEmpty(),
orgCode = orgCode,
deviceType = deviceType,
controlType = deviceType
)
// 2) 通过 DSL 发起请求。用 app() 而非 ui(lifecycleScope):手动点击是单次过程级动作,
// 不需要把 Java 侧的 Activity 桥接到 Kotlin 的 lifecycleScope;同时也避免 Java
// 侧接入 lifecycleScope 的样板代码。
ApiRequesters.app()
.from(StoreVersionApi::class.java)
.action { api -> api.query(req) }
.map<StoreVersionDto>()
.onSuccess { dto -> handleSuccess(activity, touch, dto) }
.onFailure { err -> showFailureToast(activity, err) }
.launch()
}
/**
* 处理成功响应。版本信息完整 + URL 非空 + 远程版本高于本地时才走 [UpgradeHelper.upgrade];
* 任一条件不满足则提示"已是最新版本",避免出现空白弹框或无意义升级。
*/
private fun handleSuccess(activity: Activity, touch: Boolean, dto: StoreVersionDto) {
if (activity.isFinishing) return
val v: StoreVersionInfoDto = dto.version ?: run {
ToastUtils.showLongBottomToast(MSG_UP_TO_DATE)
return
}
val verCode = v.verCode.orEmpty()
if (verCode.isEmpty()) {
ToastUtils.showLongBottomToast(MSG_UP_TO_DATE)
return
}
if (!isRemoteVersionHigher(AppUtils.getVersionCode(), verCode)) {
ToastUtils.showLongBottomToast(MSG_UP_TO_DATE)
return
}
val url = pickUrl(touch, v)
if (url.isEmpty()) {
ToastUtils.showLongBottomToast(MSG_UP_TO_DATE)
return
}
val isForce = isCompulsory(v.compulsory)
activity.runOnUiThread {
// lambda 在主线程队列中执行,可能晚于上方 isFinishing 检查,需再次校验
if (activity.isFinishing || activity.isDestroyed) return@runOnUiThread
UpgradeHelper.upgrade(activity, verCode, v.verName.orEmpty(), url, isForce, null)
}
}
/**
* 远程版本号是否高于本地版本号。逻辑与 MainActivity.isRemoteVersionHigher 完全一致:
* 任一为空 → false;转 Int 比较;异常兜底 → false。
*
* 暴露为 internal 便于单元测试覆盖所有分支。
*/
internal fun isRemoteVersionHigher(currentVersion: String?, remoteVersion: String?): Boolean {
if (currentVersion.isNullOrEmpty() || remoteVersion.isNullOrEmpty()) {
return false
}
return try {
currentVersion.toInt() < remoteVersion.toInt()
} catch (e: Exception) {
LogUtils.d(TAG, "version compare error: ${e.message}")
false
}
}
/**
* 错误兜底:网络/HTTP/解析/业务错误统一转成可读消息并 toast。
* 保留服务端 msg(如业务错误码说明)作为首选文案。
*/
private fun showFailureToast(activity: Activity, err: ApiError) {
if (activity.isFinishing) return
val msg = err.message.takeIf { !it.isNullOrEmpty() } ?: MSG_NETWORK_ERROR
ToastUtils.showLongBottomToast(msg)
}
/**
* URL 选择策略:touch 模式优先 [StoreVersionInfoDto.touchUrl],空则回退 [keyUrl];
* remote 模式始终取 [keyUrl]。版本信息缺失返回空串。
*
* 暴露为 internal 便于单元测试覆盖所有分支。
*/
internal fun pickUrl(touch: Boolean, v: StoreVersionInfoDto?): String {
if (v == null) return ""
return if (touch) {
v.touchUrl.takeUnless { it.isNullOrEmpty() } ?: v.keyUrl.orEmpty()
} else {
v.keyUrl.orEmpty()
}
}
/**
* 是否强制升级。判定与 MainActivity.isCompulsory 完全一致:
* "true" (大小写不敏感) 或 "1" → 强制;其余一律非强制。
*
* 暴露为 internal 便于单元测试。
*/
internal fun isCompulsory(compulsory: String?): Boolean {
if (compulsory.isNullOrEmpty()) return false
return "true".equals(compulsory, ignoreCase = true) || compulsory == "1"
}
}

View File

View File

View File

View File

@@ -90,7 +90,7 @@ public class ContentAdapter extends RecyclerView.Adapter<ContentAdapter.ItemHold
// itemHolder.id_tv_playtime.setText("");
// }
String info = ContentDetailUtils.getProgramInformation(contentInfoChooseList.get(position).getContenttype(),
contentInfoChooseList.get(position).getEpisodecount());
contentInfoChooseList.get(position).getEpisodeindex());
itemHolder.mask.setVisibility(TextUtils.isEmpty(info) ? View.GONE : View.VISIBLE);
itemHolder.id_tv_playtime.setText(info);

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

@@ -1,6 +1,7 @@
package com.pukka.jiecheng.index;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
@@ -37,12 +38,14 @@ import com.pukka.jiecheng.index.bean.NavResideBean;
import com.pukka.jiecheng.index.dialog.BackImgDialog;
import com.pukka.jiecheng.index.dialog.FullimgDialog;
import com.pukka.jiecheng.index.fragment.ContentFragment;
import com.pukka.jiecheng.index.fragment.HomeMyFragment;
import com.pukka.jiecheng.index.fragment.HotelMyFragment;
import com.pukka.jiecheng.index.fragment.MyFragment;
import com.pukka.jiecheng.index.fragment.ShortVideoFragment;
import com.pukka.jiecheng.index.viewmodel.IndexViewModel;
import com.pukka.jiecheng.index.viewmodel.NavViewModel;
import com.pukka.jiecheng.index.viewmodel.TopViewModel;
import com.pukka.jiecheng.login.WxMpLoginManager;
import com.pukka.jiecheng.login.viewmodel.IsLoginViewModel;
import com.pukka.jiecheng.newtv.NewTvListner;
import com.pukka.jiecheng.newtv.NewTvUtils;
@@ -54,7 +57,7 @@ import com.pukka.jiecheng.widget.HomeNavLinearLayoutManagerTv;
import com.pukka.jiecheng.widget.TopView;
import com.pukka.jiecheng.widget.VideoContainer;
import com.pukka.jiecheng.widget.banner.Banner;
import com.pukka.libcore.base.BuildConfig;
import com.pukka.jiecheng.BuildConfig;
import com.pukka.libcore.boxShared.BoxSharedParameters;
import com.pukka.libcore.boxShared.BoxSharedParamsUtils;
import com.pukka.libcore.threading.DispatchQueue;
@@ -93,6 +96,8 @@ public class IndexActivity extends BaseActivity {
private MyFragment mMyFragment;
private HotelMyFragment mHotelMyFragment;
// 家用版(home flavor)专用"我的"Fragment:未登录态、隐藏 MAC
private HomeMyFragment mHomeMyFragment;
private ShortVideoFragment mShortVideoFragment;
private FilterFragment filterFragment;
@@ -310,20 +315,80 @@ public class IndexActivity extends BaseActivity {
return isContentLayoutReady;
}
/**
* 是否为扫码版变体(as8Scan / asAScan)。
* <p>判断依据:{@code BuildConfig.SCAN_VARIANT} 由 build.gradle 的 flavor 维度注入,
* 扫码版自动置为 true。扫码版默认 tab 落「电影」+ 切 contentFragment;行业版默认 tab
* 按 navName 包含"推荐"定位(接口 type 字段不可靠) + 动态切换对应 Fragment。</p>
*/
private boolean isScanVariant() {
return BuildConfig.SCAN_VARIANT;
}
/**
* 行业版定位默认「推荐」tab:接口下发的 type 字段不可靠(如 navList[0]=「我的」type=2),
* 直接遍历 navList 按 navName 包含"推荐"来定位(与扫码版找「电影」的方式一致)。
*
* <p>找不到时兜底回退到第 0 个 tab(与之前行为兼容)。</p>
*/
private NavFocusBean findDefaultNavIndex(List<GetLayoutNavBean.DataBean> navList) {
NavFocusBean result = new NavFocusBean();
if (ListUtils.isEmpty(navList)) {
result.setIndex(0);
result.setIndexSub(-1);
return result;
}
for (int i = 0; i < navList.size(); i++) {
GetLayoutNavBean.DataBean group = navList.get(i);
List<GetLayoutNavBean.DataBean.SubNavDataBean> subList = group.getNavList();
if (ListUtils.isEmpty(subList)) continue;
boolean isSingle = TextUtils.isEmpty(group.getNavColor())
&& TextUtils.isEmpty(group.getCornerUrl())
&& subList.size() == 1;
if (isSingle) {
String navName = subList.get(0).getNavname();
if (navName != null && navName.contains("推荐")) {
result.setIndex(i);
result.setIndexSub(-1);
return result;
}
} else {
for (int j = 0; j < subList.size(); j++) {
String navName = subList.get(j).getNavname();
if (navName != null && navName.contains("推荐")) {
result.setIndex(i);
result.setIndexSub(j);
return result;
}
}
}
}
// 兜底:找不到「筛选」回退到 0
result.setIndex(0);
result.setIndexSub(-1);
return result;
}
private boolean applyInitialNavFocus() {
if (mRvNav.getChildCount() <= 0 || mRvNav.getLayoutManager() == null) {
return false;
}
NavFocusBean firstFocusBean = mIndexNavAdapter.getFistFocusIndex(mediaId);
View focusView = mRvNav.getLayoutManager().findViewByPosition(firstFocusBean.getIndex());
// 默认落焦位置由 registerTopNavDataObserver 按渠道设置:扫码版→「电影」,行业版→「推荐」
DefaultNavFocusBean defaultBean = mNavViewModel.defaultFocusIndex.getValue();
if (defaultBean == null || defaultBean.getDefaultIndex() < 0) {
return false;
}
int focusIndex = defaultBean.getDefaultIndex();
int focusIndexSub = defaultBean.getDefaultIndexSub();
View focusView = mRvNav.getLayoutManager().findViewByPosition(focusIndex);
if (focusView == null) {
mRvNav.getLayoutManager().scrollToPosition(firstFocusBean.getIndex());
mRvNav.getLayoutManager().scrollToPosition(focusIndex);
mRvNav.post(mFocusDispatcher::attemptFocus);
return false;
}
RecyclerView recyclerView = focusView.findViewById(R.id.id_rv_sub_nav);
if (recyclerView != null && recyclerView.getLayoutManager() != null) {
View focusViewSub = recyclerView.getLayoutManager().findViewByPosition(firstFocusBean.getIndexSub());
if (recyclerView != null && recyclerView.getLayoutManager() != null && focusIndexSub >= 0) {
View focusViewSub = recyclerView.getLayoutManager().findViewByPosition(focusIndexSub);
if (focusViewSub != null) {
focusViewSub.requestFocus();
mTopView.mIvLogo.setFocusable(false);
@@ -437,6 +502,43 @@ public class IndexActivity extends BaseActivity {
return false;
}
private View findNavResideView() {
if (mRvNav.getLayoutManager() == null || mNavViewModel.resideIndex.getValue() == null) {
return null;
}
int resideIndex = mNavViewModel.resideIndex.getValue().getResideIndex();
int resideSubIndex = mNavViewModel.resideIndex.getValue().getResideIndexSub();
View focusView = mRvNav.getLayoutManager().findViewByPosition(resideIndex);
if (focusView == null) {
return null;
}
if (resideSubIndex != -1) {
RecyclerView subNavRv = focusView.findViewById(R.id.id_rv_sub_nav);
if (subNavRv != null && subNavRv.getLayoutManager() != null) {
View sub = subNavRv.getLayoutManager().findViewByPosition(resideSubIndex);
if (sub != null) {
return sub;
}
}
}
return focusView;
}
private boolean isViewInside(View child, View ancestor) {
if (child == null || ancestor == null) {
return false;
}
View current = child;
while (current != null) {
if (current == ancestor) {
return true;
}
android.view.ViewParent vp = current.getParent();
current = vp instanceof View ? (View) vp : null;
}
return false;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -571,28 +673,62 @@ public class IndexActivity extends BaseActivity {
LogUtils.d("mNavList.size() == " + mNavList.size());
mIndexNavAdapter.setLayoutNavList(mNavList);
isNavDataReady = true;
NavFocusBean firstFocusBean = mIndexNavAdapter.getFistFocusIndex(mediaId);
// 扫码版:默认选中「电影」;行业版:默认选中「推荐」(type 字段不清晰,按 navName 兜底)
NavFocusBean defaultFocus;
if (isScanVariant()) {
defaultFocus = mIndexNavAdapter.getFistFocusIndex(mediaId);
} else {
defaultFocus = findDefaultNavIndex(mNavList);
}
DefaultNavFocusBean defaultBean = mNavViewModel.defaultFocusIndex.getValue();
if (defaultBean != null && defaultBean.getDefaultIndex() < 0) {
defaultBean.setDefaultIndex(firstFocusBean.getIndex());
defaultBean.setDefaultIndexSub(firstFocusBean.getIndexSub());
defaultBean.setDefaultIndex(defaultFocus.getIndex());
defaultBean.setDefaultIndexSub(defaultFocus.getIndexSub());
}
if (mNavList.size() == 1 && mNavList.get(0).getNavList().size() == 1) {
dealOnlyOneTabUi(mNavList);
} else {
mRvNav.setVisibility(View.VISIBLE);
// 根据默认 Tab 的 navType 和 navId 切换到对应 Fragment 并加载内容
// (优先「电影」tab,找不到回退到第 0 个 tab)
switchToDefaultFragmentByNavList(mNavList, firstFocusBean);
if (isScanVariant()) {
// 扫码版:按 defaultFocus(指向「电影」)切到对应 Fragment
switchToDefaultFragmentByNavList(mNavList, defaultFocus);
} else {
// 行业版:按 defaultFocus(指向「推荐」)切到对应 Fragment
switchToDefaultFragmentByNavList(mNavList, defaultFocus);
}
}
mRvNav.postDelayed(() -> mFocusDispatcher.request(FOCUS_REQUEST_INITIAL), 500);
if (isFirstLoad == 1) {
isFirstLoad = 0;
replayColdStartVoiceCommands();
}
}
});
}
/**
* 冷启动语音命令回放:导航就绪、首帧渲染完成后触发。
* <p>
* 设计要点(DIP 解耦):IndexActivity 属于 UI 层,voice 模块属于业务层,
* 为避免相互 import 产生循环依赖,这里用反射调用
* {@code VoiceCommandService.getInstance(context).replayColdStartQueue()},
* 方法签名固定、零性能影响(仅首帧触发一次)。
* </p>
*/
private void replayColdStartVoiceCommands() {
try {
Class<?> svcClz = Class.forName(
"com.pukka.jiecheng.voice.service.VoiceCommandService");
java.lang.reflect.Method getInstance = svcClz.getMethod(
"getInstance", Context.class);
Object svc = getInstance.invoke(null, IndexActivity.this);
java.lang.reflect.Method replay = svcClz.getMethod("replayColdStartQueue");
replay.invoke(svc);
} catch (Throwable t) {
LogUtils.w("replayColdStartQueue failed: " + t.getMessage());
}
}
private void dealOnlyOneTabUi(List<GetLayoutNavBean.DataBean> mNavList) {
mRvNav.setVisibility(View.GONE);
GetLayoutNavBean.DataBean.SubNavDataBean tempNavObj = mNavList.get(0).getNavList().get(0);
@@ -653,9 +789,21 @@ public class IndexActivity extends BaseActivity {
IndexAmberManager.navName = navName;
LogUtils.d("default nav: name=" + navName + " navId=" + navId + " navType=" + navType);
if (navType == 6) {
onNavTypeChanged(navType, i);
} else {
onTabSelected(i);
}
mFocusDispatcher.requestPosition(i, j);
if (navType == 2) {
// 我的页面(电影不可能是该类型,保留以兼容后端特殊配置)
if (BoxSharedParamsUtils.isJcHotel()) {
// 家用版优先:home flavor 走专用 HomeMyFragment
if (AppConfiguration.getInstance().isHomeEdition()) {
if (null != mHomeMyFragment) {
switchFragment(mHomeMyFragment);
}
} else if (BoxSharedParamsUtils.isJcHotel()) {
switchFragment(mHotelMyFragment);
} else if (mMyFragment != null) {
mMyFragment.navId = mCurrentNavId;
@@ -663,7 +811,6 @@ public class IndexActivity extends BaseActivity {
}
} else if (navType == 6) {
// 筛选页
onNavTypeChanged(navType, i);
switchFragment(filterFragment);
filterFragment.initPos();
} else if (navType == 5 || navType == 7) {
@@ -723,11 +870,22 @@ public class IndexActivity extends BaseActivity {
ft.commit();
filterFragment = FilterFragment.newInstance(true);
currentFragment = mContentFragment;
if (BoxSharedParamsUtils.isJcHotel()) {
// 家用版优先:home flavor 走专用 HomeMyFragment(未登录态、隐藏 MAC)
if (AppConfiguration.getInstance().isHomeEdition()) {
mHomeMyFragment = HomeMyFragment.newInstance();
} else if (BoxSharedParamsUtils.isJcHotel()) {
mHotelMyFragment = HotelMyFragment.newInstance();
} else {
mMyFragment = MyFragment.newInstance();
}
// 家用版:监听扫码登录成功事件,刷新"我的"Tab 真实状态
if (AppConfiguration.getInstance().isHomeEdition()) {
WxMpLoginManager.INSTANCE.getLoginSuccessEvent().observe(this, success -> {
if (Boolean.TRUE.equals(success) && null != mHomeMyFragment) {
mHomeMyFragment.refreshUserState();
}
});
}
mShortVideoFragment = ShortVideoFragment.newInstance();
mBrowseFrameLayout = findViewById(R.id.id_nav_bfl);
@@ -854,7 +1012,12 @@ public class IndexActivity extends BaseActivity {
mediaId = mCurrentNavId;
// 如果是我的页面,则切换到我的fragment上
if (navType == 2) {
if (BoxSharedParamsUtils.isJcHotel()) {
// 家用版优先:home flavor 走专用 HomeMyFragment
if (AppConfiguration.getInstance().isHomeEdition()) {
if (null != mHomeMyFragment) {
switchFragment(mHomeMyFragment);
}
} else if (BoxSharedParamsUtils.isJcHotel()) {
switchFragment(mHotelMyFragment);
} else {
if (null != mMyFragment) {
@@ -1038,25 +1201,35 @@ public class IndexActivity extends BaseActivity {
mShortVideoFragment.setAdapterDirection(4);
}
}
// BOTTOM 模板列表失去焦点,导航列表获取焦点
// 模板的RecyclerView
if (mRvNav.getFocusedChild() == null) {
View navReside = findNavResideView();
if (navReside != null) {
ScrollView mySv = null;
if (currentFragment == mMyFragment && mMyFragment != null) {
mySv = mMyFragment.getScrollView();
} else if (currentFragment == mHotelMyFragment && mHotelMyFragment != null) {
mySv = mHotelMyFragment.getScrollView();
}
if (mySv != null) {
if (!mySv.canScrollVertically(-1) && isViewInside(focused, mySv)) {
return navReside;
}
}
if (currentFragment == mHomeMyFragment && mHomeMyFragment != null) {
View root = mHomeMyFragment.getView();
if (root != null && !root.canScrollVertically(-1) && isViewInside(focused, root)) {
return navReside;
}
}
}
}
RecyclerView recyclerView = mContentFragment.getRvContent();
if (recyclerView.getFocusedChild() != null && mRvNav.getFocusedChild() == null
&& null != mRvNav.getLayoutManager() && null != mNavViewModel.resideIndex.getValue()) {
if (mNavViewModel.resideIndex.getValue().getResideIndexSub() != -1) {
View focusView = mRvNav.getLayoutManager()
.findViewByPosition(mNavViewModel.resideIndex.getValue().getResideIndex());
if (null != focusView) {
RecyclerView subNavRv = focusView.findViewById(R.id.id_rv_sub_nav);
if (null != subNavRv && null != subNavRv.getLayoutManager()) {
return subNavRv.getLayoutManager()
.findViewByPosition(mNavViewModel.resideIndex.getValue().getResideIndexSub());
if (recyclerView.getFocusedChild() != null && mRvNav.getFocusedChild() == null) {
View navReside = findNavResideView();
if (navReside != null) {
return navReside;
}
}
}
return mRvNav.getLayoutManager()
.findViewByPosition(mNavViewModel.resideIndex.getValue().getResideIndex());
}
if ((null != mNavViewModel.resideIndex.getValue() && null != mNavViewModel.myIndex.getValue())
&& (mNavViewModel.resideIndex.getValue().getResideIndex() == mNavViewModel.myIndex.getValue()
@@ -1067,22 +1240,13 @@ public class IndexActivity extends BaseActivity {
.getMyIndexSub()
|| mNavViewModel.resideIndex.getValue().getResideIndexSub() == mNavViewModel.myIndex.getValue()
.getMyIndex())
&& mRvNav.getFocusedChild() == null && null != mRvNav.getLayoutManager()) {
if (mNavViewModel.resideIndex.getValue().getResideIndexSub() != -1) {
View focusView = mRvNav.getLayoutManager()
.findViewByPosition(mNavViewModel.resideIndex.getValue().getResideIndex());
if (null != focusView) {
RecyclerView subNavRv = focusView.findViewById(R.id.id_rv_sub_nav);
if (null != subNavRv && null != subNavRv.getLayoutManager()) {
return subNavRv.getLayoutManager()
.findViewByPosition(mNavViewModel.resideIndex.getValue().getResideIndexSub());
&& mRvNav.getFocusedChild() == null) {
View navReside = findNavResideView();
if (navReside != null) {
return navReside;
}
}
}
return mRvNav.getLayoutManager()
.findViewByPosition(mNavViewModel.resideIndex.getValue().getResideIndex());
}
}
return null;
});
}
@@ -1105,6 +1269,14 @@ public class IndexActivity extends BaseActivity {
transaction.hide(currentFragment).show(targetFragment).commit();
}
currentFragment = targetFragment;
// 家用版:首页 Tab 切换走 hide/show,Fragment.onResume 不会被触发。
// 切到"我的"时主动调一次 refreshUserState(),确保扫码登录/退出登录等异步态变更
// 落到 UI 上。
if (AppConfiguration.getInstance().isHomeEdition()
&& targetFragment == mHomeMyFragment
&& mHomeMyFragment != null) {
mHomeMyFragment.refreshUserState();
}
}
private static void releasePlayer() {
@@ -1149,6 +1321,15 @@ public class IndexActivity extends BaseActivity {
if (currentFragment == filterFragment) {
filterFragment.requestFocusFirst();
return super.onKeyDown(keyCode, event);
} else if (currentFragment == mMyFragment && mMyFragment != null) {
mMyFragment.requestFocusFirst();
return super.onKeyDown(keyCode, event);
} else if (currentFragment == mHotelMyFragment && mHotelMyFragment != null) {
mHotelMyFragment.requestFocusFirst();
return super.onKeyDown(keyCode, event);
} else if (currentFragment == mHomeMyFragment && mHomeMyFragment != null) {
mHomeMyFragment.requestFocusFirst();
return super.onKeyDown(keyCode, event);
}
}
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == android.view.KeyEvent.KEYCODE_ENTER) {

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

Some files were not shown because too many files have changed in this diff Show More