Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7b5518834 |
6
.gitignore
vendored
Executable file → Normal file
6
.gitignore
vendored
Executable file → Normal file
@@ -19,7 +19,13 @@ local.properties
|
|||||||
docs/*
|
docs/*
|
||||||
.agents/*
|
.agents/*
|
||||||
.trae/*
|
.trae/*
|
||||||
|
.worktrees/
|
||||||
skills-lock.json
|
skills-lock.json
|
||||||
*.log
|
*.log
|
||||||
/CLAUDE.md
|
/CLAUDE.md
|
||||||
.claude/*
|
.claude/*
|
||||||
|
.superpowers/*
|
||||||
|
.gitattributes
|
||||||
|
graphify-out/*
|
||||||
|
.vscode/
|
||||||
|
.screenshots/
|
||||||
|
|||||||
146
5.1声道接入成果总结.md
Normal file
146
5.1声道接入成果总结.md
Normal 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 档位) |
|
||||||
585
5.1声道改造-代码变更清单与复用指南.md
Normal file
585
5.1声道改造-代码变更清单与复用指南.md
Normal 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
app/.gitignore
vendored
Executable file → Normal file
0
app/.gitignore
vendored
Executable file → Normal file
147
app/build.gradle
Executable file → Normal file
147
app/build.gradle
Executable file → Normal file
@@ -1,5 +1,3 @@
|
|||||||
import com.android.build.api.variant.BuildConfigField
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application'
|
id 'com.android.application'
|
||||||
id 'org.jetbrains.kotlin.android'
|
id 'org.jetbrains.kotlin.android'
|
||||||
@@ -16,13 +14,21 @@ android {
|
|||||||
checkReleaseBuilds false
|
checkReleaseBuilds false
|
||||||
abortOnError 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
|
def versionSuffix = "0" // 定义版本后缀变量,必须小于或等于9大于0
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "tv.huashi.jetsenx.jetsenfilx.jsttv"
|
applicationId "tv.huashi.jetsenx.jetsenfilx.jsttv"
|
||||||
minSdk app_android.minSdkVersion
|
minSdk app_android.minSdkVersion
|
||||||
targetSdk app_android.targetSdkVersion
|
targetSdk app_android.targetSdkVersion
|
||||||
versionCode Integer.parseInt(new Date().format("yyMMddHH") + versionSuffix)
|
versionCode Integer.parseInt(new Date().format("yyMMddHH") + versionSuffix)
|
||||||
versionName "v1.3.28"
|
versionName "v1.3.35"
|
||||||
multiDexEnabled true
|
multiDexEnabled true
|
||||||
//压缩语言种类,目前只支持中文语言
|
//压缩语言种类,目前只支持中文语言
|
||||||
resConfigs('zh-rCN')
|
resConfigs('zh-rCN')
|
||||||
@@ -30,6 +36,10 @@ android {
|
|||||||
// Define flavor dimensions
|
// Define flavor dimensions
|
||||||
flavorDimensions "channel", "control"
|
flavorDimensions "channel", "control"
|
||||||
|
|
||||||
|
// ===== 语音协议占位符默认值(文档 §6.2 signature-level permission) =====
|
||||||
|
// debug 通过 flavor 覆盖为 normal 方便联调,release 维持 signature。
|
||||||
|
manifestPlaceholders = [voiceProtectionLevel: "signature"]
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
//酒店 ANNING 安柠 AISHI 爱视(不要手柄)
|
//酒店 ANNING 安柠 AISHI 爱视(不要手柄)
|
||||||
buildConfigField "String", "HOTEL_TYPE", "\"AISHI\""
|
buildConfigField "String", "HOTEL_TYPE", "\"AISHI\""
|
||||||
@@ -39,9 +49,11 @@ android {
|
|||||||
buildConfigField "boolean", "NEED_ACTIVATION_CODE", "true"
|
buildConfigField "boolean", "NEED_ACTIVATION_CODE", "true"
|
||||||
//是否为扫码变体(as8Scan 覆盖为 true;asAScan 同样覆盖为 true)
|
//是否为扫码变体(as8Scan 覆盖为 true;asAScan 同样覆盖为 true)
|
||||||
buildConfigField "boolean", "SCAN_VARIANT", "false"
|
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\""
|
buildConfigField "String", "BUGLY_APP_ID", "\"01f1b10fca\""
|
||||||
//是否为第三方变体(thirdparty flavor 覆盖为 true)
|
|
||||||
buildConfigField "boolean", "THIRDPARTY_VARIANT", "false"
|
|
||||||
|
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters "armeabi-v7a", "arm64-v8a"
|
abiFilters "armeabi-v7a", "arm64-v8a"
|
||||||
@@ -71,6 +83,8 @@ android {
|
|||||||
zipAlignEnabled true // 是否开启zip压缩
|
zipAlignEnabled true // 是否开启zip压缩
|
||||||
signingConfig signingConfigs.release
|
signingConfig signingConfigs.release
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
// release 严格按文档 §6.2 使用 signature-level permission
|
||||||
|
manifestPlaceholders = [voiceProtectionLevel: "signature"]
|
||||||
}
|
}
|
||||||
|
|
||||||
// preview {
|
// preview {
|
||||||
@@ -86,12 +100,14 @@ android {
|
|||||||
|
|
||||||
debug {
|
debug {
|
||||||
// AS 域 baseUrl: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
|
minifyEnabled false
|
||||||
shrinkResources false
|
shrinkResources false
|
||||||
zipAlignEnabled true // 是否开启zip压缩
|
zipAlignEnabled true // 是否开启zip压缩
|
||||||
signingConfig signingConfigs.release
|
signingConfig signingConfigs.release
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
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\""
|
buildConfigField "String", "CHANNEL_ID", "\"TS\""
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- aishi 子方向:扫码版(无激活码 默认 8888 的激活码) ---
|
// --- aishi 子方向:扫码版(无激活码 默认 88888 的激活码) ---
|
||||||
// 共享 aishi 渠道路由(X-Channel-Id="AS"),仅客户端激活流程差异
|
// 共享 aishi 渠道路由(X-Channel-Id="AS"),仅客户端激活流程差异
|
||||||
as8Scan {
|
as8Scan {
|
||||||
dimension "channel"
|
dimension "channel"
|
||||||
manifestPlaceholders = [
|
manifestPlaceholders = [
|
||||||
APP_CHANNEL_ID : "aishi",
|
APP_CHANNEL_ID : "as8Scan",
|
||||||
splash_theme : "@style/SplashTheme"]
|
splash_theme : "@style/SplashTheme"]
|
||||||
buildConfigField "String", "CHANNEL_ID", "\"AS\""
|
buildConfigField "String", "CHANNEL_ID", "\"AS\""
|
||||||
buildConfigField "boolean", "NEED_ACTIVATION_CODE", "false"
|
buildConfigField "boolean", "NEED_ACTIVATION_CODE", "false"
|
||||||
buildConfigField "boolean", "SCAN_VARIANT", "true"
|
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 子方向:激活扫码版(有激活码) ---
|
// --- aishi 子方向:激活扫码版(有激活码) ---
|
||||||
@@ -157,23 +186,58 @@ android {
|
|||||||
asAScan {
|
asAScan {
|
||||||
dimension "channel"
|
dimension "channel"
|
||||||
manifestPlaceholders = [
|
manifestPlaceholders = [
|
||||||
APP_CHANNEL_ID : "aishi",
|
APP_CHANNEL_ID : "asAScan",
|
||||||
splash_theme : "@style/SplashTheme"]
|
splash_theme : "@style/SplashTheme"]
|
||||||
buildConfigField "String", "CHANNEL_ID", "\"AS\""
|
buildConfigField "String", "CHANNEL_ID", "\"AS\""
|
||||||
buildConfigField "boolean", "SCAN_VARIANT", "true"
|
buildConfigField "boolean", "SCAN_VARIANT", "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 第三方渠道(thirdparty) ---
|
// 测试渠道分销
|
||||||
thirdparty {
|
qnAScan {
|
||||||
dimension "channel"
|
dimension "channel"
|
||||||
applicationIdSuffix ".thirdparty"
|
|
||||||
manifestPlaceholders = [
|
manifestPlaceholders = [
|
||||||
APP_CHANNEL_ID : "thirdparty",
|
APP_CHANNEL_ID : "qnAScan",
|
||||||
splash_theme : "@style/SplashTheme"]
|
splash_theme : "@style/SplashTheme"]
|
||||||
buildConfigField "String", "CHANNEL_ID", "\"TP\""
|
buildConfigField "String", "CHANNEL_ID", "\"QN\""
|
||||||
buildConfigField "boolean", "THIRDPARTY_VARIANT", "true"
|
buildConfigField "boolean", "SCAN_VARIANT", "true"
|
||||||
// 注意:AS_BASE_URL 不能在此处覆盖,buildType 的 buildConfigField 优先级更高会将其覆盖。
|
}
|
||||||
// 第三方域名通过下方 androidComponents.onVariants 设置(Variant API 优先级最高)。
|
|
||||||
|
|
||||||
|
// --- 家用版:独立 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
|
// 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 {
|
dependencies {
|
||||||
|
|
||||||
@@ -260,12 +312,13 @@ dependencies {
|
|||||||
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
||||||
|
|
||||||
implementation 'com.google.android.exoplayer:exoplayer:2.19.1'
|
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 'com.sensorsdata.analytics.android:SensorsAnalyticsSDK:6.8.3'
|
||||||
implementation 'commons-codec:commons-codec:1.15'
|
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.13'
|
||||||
implementation 'com.squareup.okhttp3:okhttp:3.12.0'
|
|
||||||
implementation 'com.google.code.gson:gson:2.8.9'
|
implementation 'com.google.code.gson:gson:2.8.9'
|
||||||
implementation 'com.github.gzu-liyujiang:Android_CN_OAID:4.1.3'
|
implementation 'com.github.gzu-liyujiang:Android_CN_OAID:4.1.3'
|
||||||
implementation('io.github.volcengine-tls:tls-android-producer:2.0.2-api16') {
|
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'
|
implementation 'com.google.guava:guava:31.1-android'
|
||||||
|
|
||||||
// ===== 网络层(脚手架新引入,与既有 HttpUtils 双栈共存) =====
|
// ===== 网络层(脚手架新引入,与既有 HttpUtils 双栈共存) =====
|
||||||
// ===== Android 4.2 兼容(对齐 mediaapp 已验证组合,见 Android4.2兼容性改动文档)=====
|
// 为什么显式指定版本:app 模块此前未声明 retrofit/okhttp/moshi/coroutines,避免被根 config.gradle 隐式拉入不同版本。
|
||||||
// - OkHttp 4.x 官方要求 API 21+;3.13+ 含 Android10Platform(API 10 SSL API)触发 Dalvik VerifyError。
|
// Kotlin 1.9.22 -> KSP 1.9.22-1.0.17 已对齐。
|
||||||
// 3.12.0 是支持 API 9-20 的长期分支(与 libcore/libdata 老栈版本一致),配合下方 force 全局锁定。
|
// 为什么用 `platform(enforcedPlatform)` 而非 `implementation`:okhttp-bom 是 Bill-of-Materials,本身仅提供版本对齐,
|
||||||
// - Retrofit 2.6.0:suspend 函数 2.6.0 起支持;2.9+ 传递依赖 OkHttp 3.14.9(含 Android10Platform)。
|
// 不能被当作运行时产物拉到 runtime classpath(这正是上一个编译报错的根因)。
|
||||||
// - Coroutines 1.6.4:1.7+ 要求 minSdk 21,1.6.4 兼容 API 16+。
|
implementation platform('com.squareup.okhttp3:okhttp-bom:4.12.0')
|
||||||
implementation 'com.squareup.retrofit2:retrofit:2.6.0'
|
implementation 'com.squareup.retrofit2:retrofit:2.11.0'
|
||||||
implementation 'com.squareup.retrofit2:converter-moshi:2.6.0'
|
implementation 'com.squareup.retrofit2:converter-moshi:2.11.0'
|
||||||
implementation 'com.squareup.okhttp3:okhttp:3.12.0'
|
implementation 'com.squareup.okhttp3:okhttp'
|
||||||
implementation 'com.squareup.okhttp3:logging-interceptor:3.12.0'
|
implementation 'com.squareup.okhttp3:logging-interceptor'
|
||||||
implementation 'com.squareup.moshi:moshi:1.15.1'
|
implementation 'com.squareup.moshi:moshi:1.15.1'
|
||||||
implementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
|
implementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
|
||||||
ksp 'com.squareup.moshi:moshi-kotlin-codegen: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-runtime-ktx:2.6.2'
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-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 'net.jpountz.lz4:lz4:1.3.0'
|
||||||
implementation 'org.slf4j:slf4j-android:1.7.36'
|
implementation 'org.slf4j:slf4j-android:1.7.36'
|
||||||
//腾讯buggly
|
//腾讯buggly
|
||||||
implementation 'com.tencent.bugly:crashreport:latest.release'
|
implementation 'com.tencent.bugly:crashreport:latest.release'
|
||||||
//爱奇艺本地奔溃日志
|
//爱奇艺本地奔溃日志
|
||||||
implementation 'com.iqiyi.xcrash:xcrash-android-lib:3.1.0'
|
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
0
app/key/jiecheng.jks
Executable file → Normal file
0
app/key/keystore.properties
Executable file → Normal file
0
app/key/keystore.properties
Executable file → Normal file
0
app/libs/JetsenxSdk-api-future-release-v1.23.aar
Executable file → Normal file
0
app/libs/JetsenxSdk-api-future-release-v1.23.aar
Executable file → Normal file
0
app/libs/JetsenxSdk-api-release_v1.14.0.aar
Executable file → Normal file
0
app/libs/JetsenxSdk-api-release_v1.14.0.aar
Executable file → Normal file
0
app/libs/JetsenxSdk-external-future-release-v1.23.aar
Executable file → Normal file
0
app/libs/JetsenxSdk-external-future-release-v1.23.aar
Executable file → Normal file
0
app/libs/JetsenxSdk-external-release_v1.14.0.aar
Executable file → Normal file
0
app/libs/JetsenxSdk-external-release_v1.14.0.aar
Executable file → Normal file
0
app/libs/NewTVSDK-v4.0.3-app-lite.jar
Executable file → Normal file
0
app/libs/NewTVSDK-v4.0.3-app-lite.jar
Executable file → Normal file
0
app/libs/arm64-v8a/libnewtvsdk.so
Executable file → Normal file
0
app/libs/arm64-v8a/libnewtvsdk.so
Executable file → Normal file
0
app/libs/armeabi-v7a/libnewtvsdk.so
Executable file → Normal file
0
app/libs/armeabi-v7a/libnewtvsdk.so
Executable file → Normal file
0
app/libs/exoplayer-ffmpeg-extension-2.19.1.aar
Executable file → Normal file
0
app/libs/exoplayer-ffmpeg-extension-2.19.1.aar
Executable file → Normal file
0
app/proguard-rules.pro
vendored
Executable file → Normal file
0
app/proguard-rules.pro
vendored
Executable file → Normal file
0
app/src/androidTest/androidmanifest.xml
Executable file → Normal file
0
app/src/androidTest/androidmanifest.xml
Executable file → Normal file
0
app/src/androidTest/java/com/migu/jiecheng/ExampleInstrumentedTest.java
Executable file → Normal file
0
app/src/androidTest/java/com/migu/jiecheng/ExampleInstrumentedTest.java
Executable file → Normal file
0
app/src/androidTest/java/com/migu/jiecheng/IndexNavType6TestActivity.java
Executable file → Normal file
0
app/src/androidTest/java/com/migu/jiecheng/IndexNavType6TestActivity.java
Executable file → Normal file
0
app/src/androidTest/java/com/migu/jiecheng/IndexNavType6UiTest.java
Executable file → Normal file
0
app/src/androidTest/java/com/migu/jiecheng/IndexNavType6UiTest.java
Executable file → Normal file
0
app/src/aishiActivation/res/values/strings.xml → app/src/as8Scan/res/values/strings.xml
Executable file → Normal file
0
app/src/aishiActivation/res/values/strings.xml → app/src/as8Scan/res/values/strings.xml
Executable file → Normal file
0
app/src/aishiScan/res/values/strings.xml → app/src/asAScan/res/values/strings.xml
Executable file → Normal file
0
app/src/aishiScan/res/values/strings.xml → app/src/asAScan/res/values/strings.xml
Executable file → Normal file
4
app/src/ashome/res/values/strings.xml
Normal file
4
app/src/ashome/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">橘视厅家用版</string>
|
||||||
|
</resources>
|
||||||
4
app/src/fxScan/res/values/strings.xml
Normal file
4
app/src/fxScan/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">橘视厅扫码版</string>
|
||||||
|
</resources>
|
||||||
4
app/src/home/res/values/strings.xml
Normal file
4
app/src/home/res/values/strings.xml
Normal 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
96
app/src/main/AndroidManifest.xml
Executable file → Normal file
@@ -4,10 +4,6 @@
|
|||||||
package="com.pukka.jiecheng"
|
package="com.pukka.jiecheng"
|
||||||
tools:ignore="MissingLeanbackLauncher">
|
tools:ignore="MissingLeanbackLauncher">
|
||||||
|
|
||||||
<!-- Android 4.2 兼容(minSdk 17):覆盖 minSdk>17 的依赖库声明,运行时兼容性靠
|
|
||||||
降级依赖版本 + 不触碰高版本 API 路径保证。清单随探测编译逐步补充。 -->
|
|
||||||
<uses-sdk tools:overrideLibrary="com.github.gzuliyujiang.oaid" />
|
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.telephony"
|
android:name="android.hardware.telephony"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
@@ -41,6 +37,23 @@
|
|||||||
<uses-permission android:name="android.permission.REPLACE_EXISTING_PACKAGE" />
|
<uses-permission android:name="android.permission.REPLACE_EXISTING_PACKAGE" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<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
|
<application
|
||||||
android:name=".MyApplication"
|
android:name=".MyApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
@@ -71,6 +84,10 @@
|
|||||||
android:name=".device.DeviceActivity"
|
android:name=".device.DeviceActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:screenOrientation="landscape" />
|
android:screenOrientation="landscape" />
|
||||||
|
<activity
|
||||||
|
android:name=".setting.SettingActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="landscape" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".order.OrderActivity"
|
android:name=".order.OrderActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
@@ -85,6 +102,11 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:screenOrientation="landscape"
|
android:screenOrientation="landscape"
|
||||||
android:windowSoftInputMode="stateHidden|adjustResize" />
|
android:windowSoftInputMode="stateHidden|adjustResize" />
|
||||||
|
<!-- 家用版微信扫码登录 Activity(第一阶段占位),仅应用内启动 -->
|
||||||
|
<activity
|
||||||
|
android:name=".login.WxMpLoginActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="landscape" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -98,17 +120,6 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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
|
<activity
|
||||||
android:name=".TestActivity"
|
android:name=".TestActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -156,6 +167,61 @@
|
|||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_paths" />
|
android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
0
app/src/main/java/com/pukka/jiecheng/JcConfig.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/JcConfig.java
Executable file → Normal file
335
app/src/main/java/com/pukka/jiecheng/MainActivity.java
Executable file → Normal file
335
app/src/main/java/com/pukka/jiecheng/MainActivity.java
Executable file → Normal file
@@ -4,6 +4,8 @@ package com.pukka.jiecheng;
|
|||||||
import android.annotation.SuppressLint;
|
import android.annotation.SuppressLint;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.content.IntentFilter;
|
||||||
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
@@ -33,8 +35,10 @@ import com.pukka.jiecheng.downapk.UpgradeHelper;
|
|||||||
import com.pukka.jiecheng.BuildConfig;
|
import com.pukka.jiecheng.BuildConfig;
|
||||||
import com.pukka.jiecheng.index.IndexActivity;
|
import com.pukka.jiecheng.index.IndexActivity;
|
||||||
import com.pukka.jiecheng.login.bean.LoginLogonBean;
|
import com.pukka.jiecheng.login.bean.LoginLogonBean;
|
||||||
|
import com.pukka.jiecheng.monitor.NetworkMonitor;
|
||||||
import com.pukka.jiecheng.newtv.NewTvView;
|
import com.pukka.jiecheng.newtv.NewTvView;
|
||||||
import com.pukka.jiecheng.permission.AgreementStoragePermissionGateway;
|
import com.pukka.jiecheng.permission.AgreementStoragePermissionGateway;
|
||||||
|
import com.pukka.jiecheng.request.AsApiErrorCode;
|
||||||
import com.pukka.jiecheng.request.AsApiRequester;
|
import com.pukka.jiecheng.request.AsApiRequester;
|
||||||
import com.pukka.jiecheng.request.AsHeartBeatManager;
|
import com.pukka.jiecheng.request.AsHeartBeatManager;
|
||||||
import com.pukka.jiecheng.utils.ActivitedDialog;
|
import com.pukka.jiecheng.utils.ActivitedDialog;
|
||||||
@@ -63,6 +67,11 @@ import com.pukka.libcore.utils.SpTimeUtils;
|
|||||||
import com.pukka.libcore.utils.StringUtils;
|
import com.pukka.libcore.utils.StringUtils;
|
||||||
import com.tencent.bugly.crashreport.BuglyLog;
|
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 org.json.JSONObject;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -76,7 +85,8 @@ import io.reactivex.rxjava3.core.Single;
|
|||||||
* @Date:2025-05-14-15
|
* @Date:2025-05-14-15
|
||||||
* @Description:启动页 路由控制
|
* @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 static final String TAG = "MainActivity";
|
||||||
private final ViewTreeObserver.OnGlobalFocusChangeListener globalFocusChangeListener = (oldFocus, newFocus) ->
|
private final ViewTreeObserver.OnGlobalFocusChangeListener globalFocusChangeListener = (oldFocus, newFocus) ->
|
||||||
LogUtils.d(TAG, "focus changed old=" + formatFocusView(oldFocus) + ", new=" + formatFocusView(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 boolean isShowToLogin = true;
|
||||||
|
// 家用版标志:激活界面全程不可见,未激活时静默调用家用版激活码激活
|
||||||
|
private final boolean isHomeEdition = AppConfiguration.getInstance().isHomeEdition();
|
||||||
private final AsApiRequester asApiRequester = AsApiRequester.INSTANCE;
|
private final AsApiRequester asApiRequester = AsApiRequester.INSTANCE;
|
||||||
private Context context;
|
private Context context;
|
||||||
private UserAgreementView userAgreementView;
|
private UserAgreementView userAgreementView;
|
||||||
@@ -108,38 +120,64 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
|||||||
private String intentRoomNo;
|
private String intentRoomNo;
|
||||||
// 防重入:checkBenefits 多次回调(如 onPreset/onUnbound 等)时只触发一次自动激活
|
// 防重入:checkBenefits 多次回调(如 onPreset/onUnbound 等)时只触发一次自动激活
|
||||||
private boolean autoActivateAttempted = false;
|
private boolean autoActivateAttempted = false;
|
||||||
|
// 家用版静默激活进行中:避免并发回调在第一次静默激活 in-flight 期间误拉网络错误全屏。
|
||||||
|
// 仅由 onActivateFailed(家用版静默激活的真正失败回调)清掉。
|
||||||
|
private boolean isSilentActivationInFlight = false;
|
||||||
|
|
||||||
// 无激活码版(as8Scan,NEED_ACTIVATION_CODE=false)默认激活码:
|
// 网络恢复自动重试:true 时收到网络恢复事件直接 return,防并发重试
|
||||||
// 该变体不展示激活码输入面板,由后端按固定激活码 "8888" 完成激活,
|
private boolean isRetryingNetworkError = false;
|
||||||
|
// NetworkMonitor 实例:与 BroadcastReceiver 同生命周期
|
||||||
|
private NetworkMonitor networkMonitor;
|
||||||
|
|
||||||
|
// 无激活码版(扫码变体,NEED_ACTIVATION_CODE=false)默认激活码:
|
||||||
|
// 该变体不展示激活码输入面板,由后端按 BuildConfig.DEFAULT_ACTIVATION_CODE 配置的激活码完成激活,
|
||||||
// 房间号为空字符串。由 tryAutoActivateWithDefaultCode 直接提交。
|
// 房间号为空字符串。由 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();
|
private final AgreementStoragePermissionGateway agreementStoragePermissionGateway = new AgreementStoragePermissionGateway();
|
||||||
|
|
||||||
|
// 语音冷启动待入队命令:见 docs/2026-08-28-语音冷启动链路设计方案.md §4.1
|
||||||
|
// extras / SP 键统一定义在 VoiceExtras,本处只持有内存态。
|
||||||
|
private Bundle pendingVoiceExtras;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
context = this;
|
context = this;
|
||||||
LogUtils.d("appStart --- activity.onCreate:start");
|
LogUtils.d("appStart --- activity.onCreate:start");
|
||||||
setContentView(R.layout.activity_main);
|
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();
|
//registerGlobalFocusListener();
|
||||||
AppManager.getAppManager().addActivity(this);
|
AppManager.getAppManager().addActivity(this);
|
||||||
BuildConfigUtils.initBuildConfig(ContextUtils.getApplicationContext());
|
BuildConfigUtils.initBuildConfig(ContextUtils.getApplicationContext());
|
||||||
ChannelManger.initChannel();
|
ChannelManger.initChannel();
|
||||||
// 解析启动 Intent 中的激活参数(用于自动激活场景)
|
// 解析启动 Intent 中的激活参数(用于自动激活场景)
|
||||||
parseAutoActivateIntent();
|
parseAutoActivateIntent();
|
||||||
|
// 捕获语音冷启动待入队 extras(见 docs/2026-08-28-语音冷启动链路设计方案.md §4.1)
|
||||||
|
capturePendingVoiceExtras();
|
||||||
initView();
|
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 参数。
|
* 解析启动 Intent 中的 activate_code / room_no 参数。
|
||||||
* 仅当两个参数同时存在时,启用后续的 checkBenefits 自动激活流程。
|
* 仅当两个参数同时存在时,启用后续的 checkBenefits 自动激活流程。
|
||||||
@@ -238,6 +276,8 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
|||||||
submitDeviceActivateCode(activateCode, roomNo);
|
submitDeviceActivateCode(activateCode, roomNo);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// 注册网络变化广播:用于 init 网络失败全屏页展示期间,网络恢复时自动重试
|
||||||
|
registerNetworkMonitor();
|
||||||
nextInit();
|
nextInit();
|
||||||
if (userAgreementView.isShow()) {
|
if (userAgreementView.isShow()) {
|
||||||
userAgreementView.setVisibility(View.VISIBLE);
|
userAgreementView.setVisibility(View.VISIBLE);
|
||||||
@@ -402,6 +442,11 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
|||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(
|
.subscribe(
|
||||||
() -> {
|
() -> {
|
||||||
|
// 家用版:静默激活成功不触碰任何激活 UI;真正的进首页由 onActivated 回调驱动
|
||||||
|
if (isHomeEdition) {
|
||||||
|
LogUtils.d(TAG, "home edition activate submitted, wait onActivated to route");
|
||||||
|
return;
|
||||||
|
}
|
||||||
deviceNotActivatedView.setSubmitting(false);
|
deviceNotActivatedView.setSubmitting(false);
|
||||||
deviceNotActivatedView.showActivateMessage("激活成功,正在刷新服务状态...");
|
deviceNotActivatedView.showActivateMessage("激活成功,正在刷新服务状态...");
|
||||||
ToastUtils.showShortToastSafe("激活成功");
|
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) {
|
private void showActivateFail(String message) {
|
||||||
|
if (isHomeEdition) {
|
||||||
|
showHomeActivationFailure(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
String safeMessage = StringUtils.isEmpty(message) ? "激活失败,请稍后重试" : message;
|
String safeMessage = StringUtils.isEmpty(message) ? "激活失败,请稍后重试" : message;
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
deviceNotActivatedView.setSubmitting(false);
|
deviceNotActivatedView.setSubmitting(false);
|
||||||
@@ -566,12 +649,97 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
LogUtils.d(TAG,"goToActivity --- start: ");
|
LogUtils.d(TAG,"goToActivity --- start: ");
|
||||||
|
// 语音冷启动待入队命令:捕获到的 extras 入队到 VoiceCommandService.coldStartQueue,
|
||||||
|
// IndexActivity 首帧渲染完成后由既有 replayColdStartVoiceCommands 钩子回放。
|
||||||
|
if (pendingVoiceExtras != null) {
|
||||||
|
enqueuePendingVoiceCommand();
|
||||||
|
}
|
||||||
newIntent = new Intent();
|
newIntent = new Intent();
|
||||||
newIntent.setClass(MainActivity.this, IndexActivity.class);
|
newIntent.setClass(MainActivity.this, IndexActivity.class);
|
||||||
startActivity(newIntent);
|
startActivity(newIntent);
|
||||||
finish();
|
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() {
|
asApiRequester.requestAsInitDeviceApi(this, new AsApiRequester.InitCallback() {
|
||||||
@Override
|
@Override
|
||||||
public void onSuccess(AsApiRequester.InitResult result) {
|
public void onSuccess(AsApiRequester.InitResult result) {
|
||||||
|
// 无论初次调用还是重试,结束即允许下一次网络事件再触发
|
||||||
|
isRetryingNetworkError = false;
|
||||||
// 初始化成功,启动心跳调度(应用生命周期内保持活跃)
|
// 初始化成功,启动心跳调度(应用生命周期内保持活跃)
|
||||||
AsHeartBeatManager.getInstance().start(getApplicationContext());
|
AsHeartBeatManager.getInstance().start(getApplicationContext());
|
||||||
|
|
||||||
@@ -619,6 +789,8 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onFail(int errorCode, String message) {
|
public void onFail(int errorCode, String message) {
|
||||||
|
// 重试回调与初次回调共用同一入口,统一清标志
|
||||||
|
isRetryingNetworkError = false;
|
||||||
MainActivity.this.onFail(errorCode, message);
|
MainActivity.this.onFail(errorCode, message);
|
||||||
// runOnUiThread(()->{
|
// runOnUiThread(()->{
|
||||||
// deviceNotActivatedView.setErrorInfo(String.valueOf(errorCode));
|
// deviceNotActivatedView.setErrorInfo(String.valueOf(errorCode));
|
||||||
@@ -704,6 +876,8 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
|||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
AppManager.getAppManager().finishActivity(this);
|
AppManager.getAppManager().finishActivity(this);
|
||||||
|
// 反注册网络变化广播
|
||||||
|
unregisterNetworkMonitor();
|
||||||
if(asApiRequester != null){
|
if(asApiRequester != null){
|
||||||
asApiRequester.onDestroy();
|
asApiRequester.onDestroy();
|
||||||
}
|
}
|
||||||
@@ -796,7 +970,7 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onFail(String message) {
|
public void onFail(int errorCode, String message) {
|
||||||
handleSdkInitFailure(thirdCardNo, retryCount, "init_fail", 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) {
|
public void onActivated(AsApiRequester.CheckBenefitsResult result) {
|
||||||
hideNetworkErrorFullscreen();
|
hideNetworkErrorFullscreen();
|
||||||
dismissLoadingDialog();
|
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)) {
|
if(result != null && !TextUtils.isEmpty(result.thirdCardNo)) {
|
||||||
|
|
||||||
AppConfiguration.getInstance().setExpireTime(result.expireTime);
|
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
|
@Override
|
||||||
public void onPreset(AsApiRequester.CheckBenefitsResult result) {
|
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));
|
showActivationLoadingDialog(getString(R.string.common_loading_default_text));
|
||||||
// 设备处于预置状态(未激活):仅展示 loading,由后续异常回调(onActivateFailed/onUnbound 等)
|
// 设备处于预置状态(未激活):仅展示 loading,由后续异常回调(onActivateFailed/onUnbound 等)
|
||||||
// 或用户主动提交激活码触发真正的激活流程;此处不再自动调用 tryAutoActivateFromIntent
|
// 或用户主动提交激活码触发真正的激活流程;此处不再自动调用 tryAutoActivateFromIntent
|
||||||
@@ -948,6 +1148,11 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onActivating(AsApiRequester.CheckBenefitsResult result) {
|
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)
|
String loadingMessage = result == null || StringUtils.isEmpty(result.msg)
|
||||||
? getString(R.string.common_loading_default_text)
|
? getString(R.string.common_loading_default_text)
|
||||||
: result.msg;
|
: result.msg;
|
||||||
@@ -958,6 +1163,14 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
|||||||
@Override
|
@Override
|
||||||
public void onActivateFailed(AsApiRequester.CheckBenefitsResult result) {
|
public void onActivateFailed(AsApiRequester.CheckBenefitsResult result) {
|
||||||
hideNetworkErrorFullscreen();
|
hideNetworkErrorFullscreen();
|
||||||
|
// 家用版:激活失败 → 静默激活一次或降级网络错误屏,不展示激活界面。
|
||||||
|
// onActivateFailed 是家用版静默激活的"终态失败"回调,清掉 in-flight 标记
|
||||||
|
// 让 onHomeNonActivatedState 把降级全屏的决策接管过去。
|
||||||
|
if (isHomeEdition) {
|
||||||
|
isSilentActivationInFlight = false;
|
||||||
|
onHomeNonActivatedState(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
runOnUiThread(()->{
|
runOnUiThread(()->{
|
||||||
dismissLoadingDialog();
|
dismissLoadingDialog();
|
||||||
deviceNotActivatedView.setErrorInfo(String.valueOf(result.code));
|
deviceNotActivatedView.setErrorInfo(String.valueOf(result.code));
|
||||||
@@ -978,6 +1191,11 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
|||||||
@Override
|
@Override
|
||||||
public void onUnbound(AsApiRequester.CheckBenefitsResult result) {
|
public void onUnbound(AsApiRequester.CheckBenefitsResult result) {
|
||||||
hideNetworkErrorFullscreen();
|
hideNetworkErrorFullscreen();
|
||||||
|
// 家用版:未绑定 → 静默激活一次或降级网络错误屏,不展示激活界面
|
||||||
|
if (isHomeEdition) {
|
||||||
|
onHomeNonActivatedState(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
runOnUiThread(()->{
|
runOnUiThread(()->{
|
||||||
dismissLoadingDialog();
|
dismissLoadingDialog();
|
||||||
deviceNotActivatedView.setErrorInfo(String.valueOf(result.code));
|
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)
|
LogUtils.d(TAG, "onExpired checkBenefits code=" + (result == null ? "null" : result.code)
|
||||||
+ ", cardNos already cleared by AsApiRequester dispatcher");
|
+ ", cardNos already cleared by AsApiRequester dispatcher");
|
||||||
hideNetworkErrorFullscreen();
|
hideNetworkErrorFullscreen();
|
||||||
|
// 家用版:过期 → 静默激活一次或降级网络错误屏,不展示激活界面
|
||||||
|
if (isHomeEdition) {
|
||||||
|
onHomeNonActivatedState(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
dismissLoadingDialog();
|
dismissLoadingDialog();
|
||||||
deviceNotActivatedView.setErrorInfo(String.valueOf(result.code));
|
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)
|
LogUtils.d(TAG, "onOccupied checkBenefits code=" + (result == null ? "null" : result.code)
|
||||||
+ ", cardNos already cleared by AsApiRequester dispatcher");
|
+ ", cardNos already cleared by AsApiRequester dispatcher");
|
||||||
hideNetworkErrorFullscreen();
|
hideNetworkErrorFullscreen();
|
||||||
|
// 家用版:占用 → 静默激活一次或降级网络错误屏,不展示激活界面
|
||||||
|
if (isHomeEdition) {
|
||||||
|
onHomeNonActivatedState(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
dismissLoadingDialog();
|
dismissLoadingDialog();
|
||||||
deviceNotActivatedView.setErrorInfo(String.valueOf(result.code));
|
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)
|
LogUtils.d(TAG, "onUnknown checkBenefits code=" + (result == null ? "null" : result.code)
|
||||||
+ ", invalid card status (expired/occupied/code-activated) cardNos already cleared by dispatcher");
|
+ ", invalid card status (expired/occupied/code-activated) cardNos already cleared by dispatcher");
|
||||||
hideNetworkErrorFullscreen();
|
hideNetworkErrorFullscreen();
|
||||||
|
// 家用版:未知状态 → 静默激活一次或降级网络错误屏,不展示激活界面
|
||||||
|
if (isHomeEdition) {
|
||||||
|
onHomeNonActivatedState(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
dismissLoadingDialog();
|
dismissLoadingDialog();
|
||||||
@@ -1053,11 +1286,18 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onFail(int errorCode, String message) {
|
public void onFail(int errorCode, String message) {
|
||||||
if (errorCode == 0) {
|
// 1007 = 网络错误(无网络/无法访问服务器/超时,message 已是友好文案):
|
||||||
|
// 拉起全屏网络错误页(含退出按钮),避免网络问题被误提示为"激活失败"
|
||||||
|
if (errorCode == 0 || errorCode == AsApiErrorCode.ERROR_NETWORK.getCode()) {
|
||||||
showNetworkErrorFullscreen(message);
|
showNetworkErrorFullscreen(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hideNetworkErrorFullscreen();
|
hideNetworkErrorFullscreen();
|
||||||
|
// 家用版:激活相关失败不弹激活 UI,统一降级到网络错误屏
|
||||||
|
if (isHomeEdition) {
|
||||||
|
showHomeActivationFailure(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
runOnUiThread(()->{
|
runOnUiThread(()->{
|
||||||
dismissLoadingDialog();
|
dismissLoadingDialog();
|
||||||
deviceNotActivatedView.setErrorInfo(String.valueOf(errorCode));
|
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() {
|
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().appExit();
|
||||||
|
AppManager.getAppManager().setKeep(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
64
app/src/main/java/com/pukka/jiecheng/MyApplication.java
Executable file → Normal file
64
app/src/main/java/com/pukka/jiecheng/MyApplication.java
Executable file → Normal file
@@ -1,10 +1,13 @@
|
|||||||
|
|
||||||
package com.pukka.jiecheng;
|
package com.pukka.jiecheng;
|
||||||
|
|
||||||
|
import android.app.ActivityManager;
|
||||||
import android.app.Application;
|
import android.app.Application;
|
||||||
import android.content.Context;
|
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.bytedance.boost_multidex.BoostMultiDex;
|
||||||
import com.facebook.drawee.backends.pipeline.Fresco;
|
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.ActivityMonitor;
|
||||||
import com.pukka.jiecheng.utils.BuglyInitManager;
|
import com.pukka.jiecheng.utils.BuglyInitManager;
|
||||||
import com.pukka.jiecheng.utils.CrashHandler;
|
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.Tls12SocketFactory;
|
||||||
import com.pukka.libcore.fresco.TrustAllManager;
|
import com.pukka.libcore.fresco.TrustAllManager;
|
||||||
import com.pukka.libcore.utils.AppLifecycleObserver;
|
import com.pukka.libcore.utils.AppLifecycleObserver;
|
||||||
@@ -31,6 +37,7 @@ import javax.net.ssl.TrustManager;
|
|||||||
*/
|
*/
|
||||||
public class MyApplication extends Application {
|
public class MyApplication extends Application {
|
||||||
private static final String PROCESS_NAME_MAIN = "tv.huashi.jetsenx.jetsenfilx.jsttv";
|
private static final String PROCESS_NAME_MAIN = "tv.huashi.jetsenx.jetsenfilx.jsttv";
|
||||||
|
private static final String VOICE_PROCESS_SUFFIX = ":voice";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
@@ -59,12 +66,67 @@ public class MyApplication extends Application {
|
|||||||
|
|
||||||
// 网络层初始化(必须在 Fresco.initialize 之前,避免首签请求受 SSL 状态影响)
|
// 网络层初始化(必须在 Fresco.initialize 之前,避免首签请求受 SSL 状态影响)
|
||||||
initNetworkLayer();
|
initNetworkLayer();
|
||||||
|
// 跨进程语音命令接收器::voice 进程 SkyworthVoiceAdapter 投递 INTERNAL_FORWARD 时由本接收器处理
|
||||||
|
registerVoiceInternalReceiver();
|
||||||
|
// 兜底拉起 :voice 进程 FGS,覆盖桌面图标 cold start / appExit 后再次进入 / force-stop 后首次启动场景
|
||||||
|
ensureVoiceKeepAliveRunning(this);
|
||||||
//preInitWebCore();
|
//preInitWebCore();
|
||||||
//LogUtils.d("onCreate: channel_id : "+ AppConfiguration.getInstance().getChannelId()+ " isNeadMy : "+ AppConfiguration.getInstance().isNeedMy() + " control(Touch): "+AppConfiguration.getInstance().isTouchMode());
|
//LogUtils.d("onCreate: channel_id : "+ AppConfiguration.getInstance().getChannelId()+ " isNeadMy : "+ AppConfiguration.getInstance().isNeedMy() + " control(Touch): "+AppConfiguration.getInstance().isTouchMode());
|
||||||
}
|
}
|
||||||
Fresco.initialize(this);
|
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)。
|
* 初始化脚手架新网络层(Retrofit + OkHttp + Moshi + Coroutines)。
|
||||||
*
|
*
|
||||||
|
|||||||
69
app/src/main/java/com/pukka/jiecheng/TestActivity.java
Executable file → Normal file
69
app/src/main/java/com/pukka/jiecheng/TestActivity.java
Executable file → Normal file
@@ -8,14 +8,28 @@ import android.widget.Button;
|
|||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
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
|
* @file TestActivity.java
|
||||||
* @description 用于测试的 Activity,包含跳转到 MainActivity 的按钮,以实现简单的联调和入口测试
|
* @description 用于测试的 Activity:进入页面即触发首签拿到 sign,启动心跳;
|
||||||
|
* 然后通过按钮跳转到 OrderActivity,验证 sign 已自动同步到后续接口
|
||||||
* @author ainetv
|
* @author ainetv
|
||||||
* @date 2026-03-20
|
* @date 2026-03-20
|
||||||
*/
|
*/
|
||||||
public class TestActivity extends AppCompatActivity {
|
public class TestActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private static final String TAG = "TestActivity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AsApiRequester 单例(Kotlin object),由 @JvmStatic 暴露给 Java 端调用。
|
||||||
|
* 与 MainActivity 保持一致:业务侧持有单例引用,避免每次现取。
|
||||||
|
*/
|
||||||
|
private final AsApiRequester asApiRequester = AsApiRequester.INSTANCE;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
@@ -25,9 +39,60 @@ public class TestActivity extends AppCompatActivity {
|
|||||||
btnGoToMain.setOnClickListener(new View.OnClickListener() {
|
btnGoToMain.setOnClickListener(new View.OnClickListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onClick(View v) {
|
public void onClick(View v) {
|
||||||
Intent intent = new Intent(TestActivity.this, MainActivity.class);
|
Intent intent = new Intent(TestActivity.this, OrderActivity.class);
|
||||||
startActivity(intent);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
0
app/src/main/java/com/pukka/jiecheng/aboutmy/AboutMyActivity.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/aboutmy/AboutMyActivity.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/aboutmy/LongPressSpanHelper.kt
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/aboutmy/LongPressSpanHelper.kt
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/base/BaseActivity.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/base/BaseActivity.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/base/BaseOnItemClickListener.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/base/BaseOnItemClickListener.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/base/BaseOnItemFocusChangeListener.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/base/BaseOnItemFocusChangeListener.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/base/BasePresenter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/base/BasePresenter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/base/BasePresenterViewHolder.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/base/BasePresenterViewHolder.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/base/BaseRecyclerAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/base/BaseRecyclerAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/base/BaseRecyclerViewHolder.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/base/BaseRecyclerViewHolder.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/base/BaseViewHolder.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/base/BaseViewHolder.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/base/SystemConfigManager.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/base/SystemConfigManager.java
Executable file → Normal file
25
app/src/main/java/com/pukka/jiecheng/config/AppConfiguration.java
Executable file → Normal file
25
app/src/main/java/com/pukka/jiecheng/config/AppConfiguration.java
Executable file → Normal file
@@ -12,6 +12,7 @@ import com.pukka.jiecheng.BuildConfig;
|
|||||||
* 3. 提供构建类型判断 (isRelease)
|
* 3. 提供构建类型判断 (isRelease)
|
||||||
* 4. 提供酒店类型获取 (getHotelType)
|
* 4. 提供酒店类型获取 (getHotelType)
|
||||||
* 5. 提供是否显示我的页面配置 (isNeedMy)
|
* 5. 提供是否显示我的页面配置 (isNeedMy)
|
||||||
|
* 6. 提供家用版判断 (isHomeEdition)
|
||||||
*
|
*
|
||||||
* - 屏蔽底层 BuildConfig 细节,解耦业务逻辑。
|
* - 屏蔽底层 BuildConfig 细节,解耦业务逻辑。
|
||||||
* - 统一入口,便于未来修改配置源。
|
* - 统一入口,便于未来修改配置源。
|
||||||
@@ -108,7 +109,7 @@ public class AppConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否需要显示"我的"页面
|
* 是否需要显示“我的”页面
|
||||||
* @return true: 显示; false: 隐藏
|
* @return true: 显示; false: 隐藏
|
||||||
*/
|
*/
|
||||||
public boolean isNeedMy() {
|
public boolean isNeedMy() {
|
||||||
@@ -116,11 +117,21 @@ public class AppConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否为第三方变体
|
* 是否为家用版(home edition)
|
||||||
* @return true: thirdparty flavor; false: 其他渠道
|
* @return true: 家用版; false: 商用版
|
||||||
*/
|
*/
|
||||||
public boolean isThirdPartyVariant() {
|
public boolean isHomeEdition() {
|
||||||
return BuildConfig.THIRDPARTY_VARIANT;
|
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 心跳请求间隔周期 (毫秒)
|
* @return 心跳请求间隔周期 (毫秒)
|
||||||
*/
|
*/
|
||||||
public long getHeartBeatInterval() {
|
public long getHeartBeatInterval() {
|
||||||
|
// Debug环境下心跳间隔10秒,其他环境使用默认5分钟
|
||||||
|
if (!isRelease()) {
|
||||||
|
return 30000L;
|
||||||
|
}
|
||||||
return HEART_BEAT_INTERVAL;
|
return HEART_BEAT_INTERVAL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
45
app/src/main/java/com/pukka/jiecheng/device/DeviceActivity.kt
Executable file → Normal file
45
app/src/main/java/com/pukka/jiecheng/device/DeviceActivity.kt
Executable file → Normal file
@@ -5,11 +5,13 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import com.pukka.jiecheng.R
|
import com.pukka.jiecheng.R
|
||||||
import com.pukka.jiecheng.base.BaseActivity
|
import com.pukka.jiecheng.base.BaseActivity
|
||||||
import com.pukka.jiecheng.config.AppConfiguration
|
import com.pukka.jiecheng.config.AppConfiguration
|
||||||
|
import com.pukka.jiecheng.utils.Surround51Manager
|
||||||
import com.pukka.jiecheng.utils.ToastUtils
|
import com.pukka.jiecheng.utils.ToastUtils
|
||||||
import com.pukka.libcore.base.BaseConst
|
import com.pukka.libcore.base.BaseConst
|
||||||
import com.pukka.libcore.boxShared.BoxSharedParamsUtils
|
import com.pukka.libcore.boxShared.BoxSharedParamsUtils
|
||||||
@@ -37,7 +39,10 @@ class DeviceActivity : BaseActivity() {
|
|||||||
var mTvMac: TextView? = null
|
var mTvMac: TextView? = null
|
||||||
var mTvSn: TextView? = null
|
var mTvSn: TextView? = null
|
||||||
var mTvExpireTime: 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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_device)
|
setContentView(R.layout.activity_device)
|
||||||
@@ -68,20 +73,40 @@ class DeviceActivity : BaseActivity() {
|
|||||||
mTvExpireTime!!.text = "暂无数据"
|
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()
|
initGamePad()
|
||||||
enableBackButton()
|
enableBackButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateSurround51Value() {
|
||||||
|
mTvSurround51Value?.text = if (Surround51Manager.is51Enabled()) "开" else "关"
|
||||||
|
}
|
||||||
|
|
||||||
private fun initListener() {
|
private fun initListener() {
|
||||||
// mClCheckUpdate!!.setOnClickListener(View.OnClickListener { v: View? ->
|
mClConfirm?.setOnClickListener(View.OnClickListener { v: View? ->
|
||||||
// // TODO: 2025/6/10 APK更新
|
finish()
|
||||||
//// DownApkDialog(this).initDown2()
|
// TODO: 2025/6/10 APK更新
|
||||||
//
|
// DownApkDialog(this).initDown2()
|
||||||
// //检测更新
|
|
||||||
// // TODO: 2025/6/10 APK更新
|
//检测更新
|
||||||
//// new DownApkDialog(getActivity()).initDown2();
|
// TODO: 2025/6/10 APK更新
|
||||||
|
// new DownApkDialog(getActivity()).initDown2();
|
||||||
// ToastUtils.showLongBottomToast("暂无新版本")
|
// ToastUtils.showLongBottomToast("暂无新版本")
|
||||||
// })
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
0
app/src/main/java/com/pukka/jiecheng/device/DeviceIdentityInitManager.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/device/DeviceIdentityInitManager.java
Executable file → Normal file
6
app/src/main/java/com/pukka/jiecheng/device/DeviceIdentityProvider.java
Executable file → Normal file
6
app/src/main/java/com/pukka/jiecheng/device/DeviceIdentityProvider.java
Executable file → Normal file
@@ -9,7 +9,7 @@ import com.pukka.jiecheng.config.AppConfiguration;
|
|||||||
import com.pukka.libcore.boxShared.BoxSharedParamsUtils;
|
import com.pukka.libcore.boxShared.BoxSharedParamsUtils;
|
||||||
import com.pukka.libcore.utils.LogUtils;
|
import com.pukka.libcore.utils.LogUtils;
|
||||||
|
|
||||||
import java.nio.charset.Charset;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ public class DeviceIdentityProvider {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
MessageDigest digest = MessageDigest.getInstance("MD5");
|
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);
|
int usedBytes = Math.min(MD5_TRUNCATED_BYTES, hashBytes.length);
|
||||||
if (usedBytes <= 0) {
|
if (usedBytes <= 0) {
|
||||||
return "";
|
return "";
|
||||||
@@ -112,7 +112,7 @@ public class DeviceIdentityProvider {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
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);
|
StringBuilder hexBuilder = new StringBuilder(hashBytes.length * 2);
|
||||||
for (byte hashByte : hashBytes) {
|
for (byte hashByte : hashBytes) {
|
||||||
hexBuilder.append(String.format(Locale.ROOT, "%02X", hashByte));
|
hexBuilder.append(String.format(Locale.ROOT, "%02X", hashByte));
|
||||||
|
|||||||
0
app/src/main/java/com/pukka/jiecheng/device/DeviceIdentityStore.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/device/DeviceIdentityStore.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/device/OaidProvider.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/device/OaidProvider.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/downapk/DownApkDialog.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/downapk/DownApkDialog.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/downapk/DownloadApkManager.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/downapk/DownloadApkManager.java
Executable file → Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
0
app/src/main/java/com/pukka/jiecheng/downapk/UpgradeHelper.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/downapk/UpgradeHelper.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/downapk/amber/DownApkAmberManager.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/downapk/amber/DownApkAmberManager.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/filter/FilterActivity.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/filter/FilterActivity.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/filter/FilterFragment.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/filter/FilterFragment.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/filter/HeaderVisibilityHelper.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/filter/HeaderVisibilityHelper.java
Executable file → Normal file
2
app/src/main/java/com/pukka/jiecheng/filter/adapter/ContentAdapter.java
Executable file → Normal file
2
app/src/main/java/com/pukka/jiecheng/filter/adapter/ContentAdapter.java
Executable file → Normal file
@@ -90,7 +90,7 @@ public class ContentAdapter extends RecyclerView.Adapter<ContentAdapter.ItemHold
|
|||||||
// itemHolder.id_tv_playtime.setText("");
|
// itemHolder.id_tv_playtime.setText("");
|
||||||
// }
|
// }
|
||||||
String info = ContentDetailUtils.getProgramInformation(contentInfoChooseList.get(position).getContenttype(),
|
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.mask.setVisibility(TextUtils.isEmpty(info) ? View.GONE : View.VISIBLE);
|
||||||
itemHolder.id_tv_playtime.setText(info);
|
itemHolder.id_tv_playtime.setText(info);
|
||||||
|
|
||||||
|
|||||||
0
app/src/main/java/com/pukka/jiecheng/filter/adapter/HeaderAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/filter/adapter/HeaderAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/filter/adapter/TopAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/filter/adapter/TopAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/filter/adapter/TopAdapter2.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/filter/adapter/TopAdapter2.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/filter/bean/TopBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/filter/bean/TopBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/filter/bean/TopListBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/filter/bean/TopListBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/history/CenterLinearLayoutManager.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/history/CenterLinearLayoutManager.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/history/HistoryActivity.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/history/HistoryActivity.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/history/HistoryItemAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/history/HistoryItemAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/history/HistoryTextBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/history/HistoryTextBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/history/HistoryTextPresenter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/history/HistoryTextPresenter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/HotelMyActivity.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/HotelMyActivity.java
Executable file → Normal file
271
app/src/main/java/com/pukka/jiecheng/index/IndexActivity.java
Executable file → Normal file
271
app/src/main/java/com/pukka/jiecheng/index/IndexActivity.java
Executable file → Normal file
@@ -1,6 +1,7 @@
|
|||||||
|
|
||||||
package com.pukka.jiecheng.index;
|
package com.pukka.jiecheng.index;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Handler;
|
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.BackImgDialog;
|
||||||
import com.pukka.jiecheng.index.dialog.FullimgDialog;
|
import com.pukka.jiecheng.index.dialog.FullimgDialog;
|
||||||
import com.pukka.jiecheng.index.fragment.ContentFragment;
|
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.HotelMyFragment;
|
||||||
import com.pukka.jiecheng.index.fragment.MyFragment;
|
import com.pukka.jiecheng.index.fragment.MyFragment;
|
||||||
import com.pukka.jiecheng.index.fragment.ShortVideoFragment;
|
import com.pukka.jiecheng.index.fragment.ShortVideoFragment;
|
||||||
import com.pukka.jiecheng.index.viewmodel.IndexViewModel;
|
import com.pukka.jiecheng.index.viewmodel.IndexViewModel;
|
||||||
import com.pukka.jiecheng.index.viewmodel.NavViewModel;
|
import com.pukka.jiecheng.index.viewmodel.NavViewModel;
|
||||||
import com.pukka.jiecheng.index.viewmodel.TopViewModel;
|
import com.pukka.jiecheng.index.viewmodel.TopViewModel;
|
||||||
|
import com.pukka.jiecheng.login.WxMpLoginManager;
|
||||||
import com.pukka.jiecheng.login.viewmodel.IsLoginViewModel;
|
import com.pukka.jiecheng.login.viewmodel.IsLoginViewModel;
|
||||||
import com.pukka.jiecheng.newtv.NewTvListner;
|
import com.pukka.jiecheng.newtv.NewTvListner;
|
||||||
import com.pukka.jiecheng.newtv.NewTvUtils;
|
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.TopView;
|
||||||
import com.pukka.jiecheng.widget.VideoContainer;
|
import com.pukka.jiecheng.widget.VideoContainer;
|
||||||
import com.pukka.jiecheng.widget.banner.Banner;
|
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.BoxSharedParameters;
|
||||||
import com.pukka.libcore.boxShared.BoxSharedParamsUtils;
|
import com.pukka.libcore.boxShared.BoxSharedParamsUtils;
|
||||||
import com.pukka.libcore.threading.DispatchQueue;
|
import com.pukka.libcore.threading.DispatchQueue;
|
||||||
@@ -93,6 +96,8 @@ public class IndexActivity extends BaseActivity {
|
|||||||
|
|
||||||
private MyFragment mMyFragment;
|
private MyFragment mMyFragment;
|
||||||
private HotelMyFragment mHotelMyFragment;
|
private HotelMyFragment mHotelMyFragment;
|
||||||
|
// 家用版(home flavor)专用"我的"Fragment:未登录态、隐藏 MAC
|
||||||
|
private HomeMyFragment mHomeMyFragment;
|
||||||
private ShortVideoFragment mShortVideoFragment;
|
private ShortVideoFragment mShortVideoFragment;
|
||||||
|
|
||||||
private FilterFragment filterFragment;
|
private FilterFragment filterFragment;
|
||||||
@@ -310,20 +315,80 @@ public class IndexActivity extends BaseActivity {
|
|||||||
return isContentLayoutReady;
|
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() {
|
private boolean applyInitialNavFocus() {
|
||||||
if (mRvNav.getChildCount() <= 0 || mRvNav.getLayoutManager() == null) {
|
if (mRvNav.getChildCount() <= 0 || mRvNav.getLayoutManager() == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
NavFocusBean firstFocusBean = mIndexNavAdapter.getFistFocusIndex(mediaId);
|
// 默认落焦位置由 registerTopNavDataObserver 按渠道设置:扫码版→「电影」,行业版→「推荐」
|
||||||
View focusView = mRvNav.getLayoutManager().findViewByPosition(firstFocusBean.getIndex());
|
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) {
|
if (focusView == null) {
|
||||||
mRvNav.getLayoutManager().scrollToPosition(firstFocusBean.getIndex());
|
mRvNav.getLayoutManager().scrollToPosition(focusIndex);
|
||||||
mRvNav.post(mFocusDispatcher::attemptFocus);
|
mRvNav.post(mFocusDispatcher::attemptFocus);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
RecyclerView recyclerView = focusView.findViewById(R.id.id_rv_sub_nav);
|
RecyclerView recyclerView = focusView.findViewById(R.id.id_rv_sub_nav);
|
||||||
if (recyclerView != null && recyclerView.getLayoutManager() != null) {
|
if (recyclerView != null && recyclerView.getLayoutManager() != null && focusIndexSub >= 0) {
|
||||||
View focusViewSub = recyclerView.getLayoutManager().findViewByPosition(firstFocusBean.getIndexSub());
|
View focusViewSub = recyclerView.getLayoutManager().findViewByPosition(focusIndexSub);
|
||||||
if (focusViewSub != null) {
|
if (focusViewSub != null) {
|
||||||
focusViewSub.requestFocus();
|
focusViewSub.requestFocus();
|
||||||
mTopView.mIvLogo.setFocusable(false);
|
mTopView.mIvLogo.setFocusable(false);
|
||||||
@@ -437,6 +502,43 @@ public class IndexActivity extends BaseActivity {
|
|||||||
return false;
|
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
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
@@ -571,28 +673,62 @@ public class IndexActivity extends BaseActivity {
|
|||||||
LogUtils.d("mNavList.size() == " + mNavList.size());
|
LogUtils.d("mNavList.size() == " + mNavList.size());
|
||||||
mIndexNavAdapter.setLayoutNavList(mNavList);
|
mIndexNavAdapter.setLayoutNavList(mNavList);
|
||||||
isNavDataReady = true;
|
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();
|
DefaultNavFocusBean defaultBean = mNavViewModel.defaultFocusIndex.getValue();
|
||||||
if (defaultBean != null && defaultBean.getDefaultIndex() < 0) {
|
if (defaultBean != null && defaultBean.getDefaultIndex() < 0) {
|
||||||
defaultBean.setDefaultIndex(firstFocusBean.getIndex());
|
defaultBean.setDefaultIndex(defaultFocus.getIndex());
|
||||||
defaultBean.setDefaultIndexSub(firstFocusBean.getIndexSub());
|
defaultBean.setDefaultIndexSub(defaultFocus.getIndexSub());
|
||||||
}
|
}
|
||||||
if (mNavList.size() == 1 && mNavList.get(0).getNavList().size() == 1) {
|
if (mNavList.size() == 1 && mNavList.get(0).getNavList().size() == 1) {
|
||||||
dealOnlyOneTabUi(mNavList);
|
dealOnlyOneTabUi(mNavList);
|
||||||
} else {
|
} else {
|
||||||
mRvNav.setVisibility(View.VISIBLE);
|
mRvNav.setVisibility(View.VISIBLE);
|
||||||
// 根据默认 Tab 的 navType 和 navId 切换到对应 Fragment 并加载内容
|
if (isScanVariant()) {
|
||||||
// (优先「电影」tab,找不到回退到第 0 个 tab)
|
// 扫码版:按 defaultFocus(指向「电影」)切到对应 Fragment
|
||||||
switchToDefaultFragmentByNavList(mNavList, firstFocusBean);
|
switchToDefaultFragmentByNavList(mNavList, defaultFocus);
|
||||||
|
} else {
|
||||||
|
// 行业版:按 defaultFocus(指向「推荐」)切到对应 Fragment
|
||||||
|
switchToDefaultFragmentByNavList(mNavList, defaultFocus);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
mRvNav.postDelayed(() -> mFocusDispatcher.request(FOCUS_REQUEST_INITIAL), 500);
|
mRvNav.postDelayed(() -> mFocusDispatcher.request(FOCUS_REQUEST_INITIAL), 500);
|
||||||
if (isFirstLoad == 1) {
|
if (isFirstLoad == 1) {
|
||||||
isFirstLoad = 0;
|
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) {
|
private void dealOnlyOneTabUi(List<GetLayoutNavBean.DataBean> mNavList) {
|
||||||
mRvNav.setVisibility(View.GONE);
|
mRvNav.setVisibility(View.GONE);
|
||||||
GetLayoutNavBean.DataBean.SubNavDataBean tempNavObj = mNavList.get(0).getNavList().get(0);
|
GetLayoutNavBean.DataBean.SubNavDataBean tempNavObj = mNavList.get(0).getNavList().get(0);
|
||||||
@@ -653,9 +789,21 @@ public class IndexActivity extends BaseActivity {
|
|||||||
IndexAmberManager.navName = navName;
|
IndexAmberManager.navName = navName;
|
||||||
LogUtils.d("default nav: name=" + navName + " navId=" + navId + " navType=" + navType);
|
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 (navType == 2) {
|
||||||
// 我的页面(电影不可能是该类型,保留以兼容后端特殊配置)
|
// 我的页面(电影不可能是该类型,保留以兼容后端特殊配置)
|
||||||
if (BoxSharedParamsUtils.isJcHotel()) {
|
// 家用版优先:home flavor 走专用 HomeMyFragment
|
||||||
|
if (AppConfiguration.getInstance().isHomeEdition()) {
|
||||||
|
if (null != mHomeMyFragment) {
|
||||||
|
switchFragment(mHomeMyFragment);
|
||||||
|
}
|
||||||
|
} else if (BoxSharedParamsUtils.isJcHotel()) {
|
||||||
switchFragment(mHotelMyFragment);
|
switchFragment(mHotelMyFragment);
|
||||||
} else if (mMyFragment != null) {
|
} else if (mMyFragment != null) {
|
||||||
mMyFragment.navId = mCurrentNavId;
|
mMyFragment.navId = mCurrentNavId;
|
||||||
@@ -663,7 +811,6 @@ public class IndexActivity extends BaseActivity {
|
|||||||
}
|
}
|
||||||
} else if (navType == 6) {
|
} else if (navType == 6) {
|
||||||
// 筛选页
|
// 筛选页
|
||||||
onNavTypeChanged(navType, i);
|
|
||||||
switchFragment(filterFragment);
|
switchFragment(filterFragment);
|
||||||
filterFragment.initPos();
|
filterFragment.initPos();
|
||||||
} else if (navType == 5 || navType == 7) {
|
} else if (navType == 5 || navType == 7) {
|
||||||
@@ -723,11 +870,22 @@ public class IndexActivity extends BaseActivity {
|
|||||||
ft.commit();
|
ft.commit();
|
||||||
filterFragment = FilterFragment.newInstance(true);
|
filterFragment = FilterFragment.newInstance(true);
|
||||||
currentFragment = mContentFragment;
|
currentFragment = mContentFragment;
|
||||||
if (BoxSharedParamsUtils.isJcHotel()) {
|
// 家用版优先:home flavor 走专用 HomeMyFragment(未登录态、隐藏 MAC)
|
||||||
|
if (AppConfiguration.getInstance().isHomeEdition()) {
|
||||||
|
mHomeMyFragment = HomeMyFragment.newInstance();
|
||||||
|
} else if (BoxSharedParamsUtils.isJcHotel()) {
|
||||||
mHotelMyFragment = HotelMyFragment.newInstance();
|
mHotelMyFragment = HotelMyFragment.newInstance();
|
||||||
} else {
|
} else {
|
||||||
mMyFragment = MyFragment.newInstance();
|
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();
|
mShortVideoFragment = ShortVideoFragment.newInstance();
|
||||||
mBrowseFrameLayout = findViewById(R.id.id_nav_bfl);
|
mBrowseFrameLayout = findViewById(R.id.id_nav_bfl);
|
||||||
@@ -854,7 +1012,12 @@ public class IndexActivity extends BaseActivity {
|
|||||||
mediaId = mCurrentNavId;
|
mediaId = mCurrentNavId;
|
||||||
// 如果是我的页面,则切换到我的fragment上
|
// 如果是我的页面,则切换到我的fragment上
|
||||||
if (navType == 2) {
|
if (navType == 2) {
|
||||||
if (BoxSharedParamsUtils.isJcHotel()) {
|
// 家用版优先:home flavor 走专用 HomeMyFragment
|
||||||
|
if (AppConfiguration.getInstance().isHomeEdition()) {
|
||||||
|
if (null != mHomeMyFragment) {
|
||||||
|
switchFragment(mHomeMyFragment);
|
||||||
|
}
|
||||||
|
} else if (BoxSharedParamsUtils.isJcHotel()) {
|
||||||
switchFragment(mHotelMyFragment);
|
switchFragment(mHotelMyFragment);
|
||||||
} else {
|
} else {
|
||||||
if (null != mMyFragment) {
|
if (null != mMyFragment) {
|
||||||
@@ -1038,25 +1201,35 @@ public class IndexActivity extends BaseActivity {
|
|||||||
mShortVideoFragment.setAdapterDirection(4);
|
mShortVideoFragment.setAdapterDirection(4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// BOTTOM 模板列表失去焦点,导航列表获取焦点
|
if (mRvNav.getFocusedChild() == null) {
|
||||||
// 模板的RecyclerView
|
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();
|
RecyclerView recyclerView = mContentFragment.getRvContent();
|
||||||
if (recyclerView.getFocusedChild() != null && mRvNav.getFocusedChild() == null
|
if (recyclerView.getFocusedChild() != null && mRvNav.getFocusedChild() == null) {
|
||||||
&& null != mRvNav.getLayoutManager() && null != mNavViewModel.resideIndex.getValue()) {
|
View navReside = findNavResideView();
|
||||||
if (mNavViewModel.resideIndex.getValue().getResideIndexSub() != -1) {
|
if (navReside != null) {
|
||||||
View focusView = mRvNav.getLayoutManager()
|
return navReside;
|
||||||
.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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return mRvNav.getLayoutManager()
|
|
||||||
.findViewByPosition(mNavViewModel.resideIndex.getValue().getResideIndex());
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((null != mNavViewModel.resideIndex.getValue() && null != mNavViewModel.myIndex.getValue())
|
if ((null != mNavViewModel.resideIndex.getValue() && null != mNavViewModel.myIndex.getValue())
|
||||||
&& (mNavViewModel.resideIndex.getValue().getResideIndex() == mNavViewModel.myIndex.getValue()
|
&& (mNavViewModel.resideIndex.getValue().getResideIndex() == mNavViewModel.myIndex.getValue()
|
||||||
@@ -1067,22 +1240,13 @@ public class IndexActivity extends BaseActivity {
|
|||||||
.getMyIndexSub()
|
.getMyIndexSub()
|
||||||
|| mNavViewModel.resideIndex.getValue().getResideIndexSub() == mNavViewModel.myIndex.getValue()
|
|| mNavViewModel.resideIndex.getValue().getResideIndexSub() == mNavViewModel.myIndex.getValue()
|
||||||
.getMyIndex())
|
.getMyIndex())
|
||||||
&& mRvNav.getFocusedChild() == null && null != mRvNav.getLayoutManager()) {
|
&& mRvNav.getFocusedChild() == null) {
|
||||||
if (mNavViewModel.resideIndex.getValue().getResideIndexSub() != -1) {
|
View navReside = findNavResideView();
|
||||||
View focusView = mRvNav.getLayoutManager()
|
if (navReside != null) {
|
||||||
.findViewByPosition(mNavViewModel.resideIndex.getValue().getResideIndex());
|
return navReside;
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return mRvNav.getLayoutManager()
|
|
||||||
.findViewByPosition(mNavViewModel.resideIndex.getValue().getResideIndex());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1105,6 +1269,14 @@ public class IndexActivity extends BaseActivity {
|
|||||||
transaction.hide(currentFragment).show(targetFragment).commit();
|
transaction.hide(currentFragment).show(targetFragment).commit();
|
||||||
}
|
}
|
||||||
currentFragment = targetFragment;
|
currentFragment = targetFragment;
|
||||||
|
// 家用版:首页 Tab 切换走 hide/show,Fragment.onResume 不会被触发。
|
||||||
|
// 切到"我的"时主动调一次 refreshUserState(),确保扫码登录/退出登录等异步态变更
|
||||||
|
// 落到 UI 上。
|
||||||
|
if (AppConfiguration.getInstance().isHomeEdition()
|
||||||
|
&& targetFragment == mHomeMyFragment
|
||||||
|
&& mHomeMyFragment != null) {
|
||||||
|
mHomeMyFragment.refreshUserState();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void releasePlayer() {
|
private static void releasePlayer() {
|
||||||
@@ -1149,6 +1321,15 @@ public class IndexActivity extends BaseActivity {
|
|||||||
if (currentFragment == filterFragment) {
|
if (currentFragment == filterFragment) {
|
||||||
filterFragment.requestFocusFirst();
|
filterFragment.requestFocusFirst();
|
||||||
return super.onKeyDown(keyCode, event);
|
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) {
|
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == android.view.KeyEvent.KEYCODE_ENTER) {
|
||||||
|
|||||||
0
app/src/main/java/com/pukka/jiecheng/index/IndexAmberManager.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/IndexAmberManager.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/IndexConstant.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/IndexConstant.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/D1ContentAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/D1ContentAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/D3ContentAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/D3ContentAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/D3NavAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/D3NavAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/D4ContentAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/D4ContentAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/IndexContentAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/IndexContentAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/IndexGroupNavAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/IndexGroupNavAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/IndexMyCollectAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/IndexMyCollectAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/IndexMyHistoryAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/IndexMyHistoryAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/IndexMySetMoreAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/IndexMySetMoreAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/IndexNavAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/IndexNavAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/MatchListAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/MatchListAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/MatchListHorAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/MatchListHorAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/ShortVideoAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/ShortVideoAdapter.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/interfaces/OnNavidListener.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/interfaces/OnNavidListener.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/viewholder/GroupNavViewHolder.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/viewholder/GroupNavViewHolder.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/viewholder/NavViewHolder.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/adapter/viewholder/NavViewHolder.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/bean/CornerMarkBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/bean/CornerMarkBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/bean/D1TypeBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/bean/D1TypeBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/bean/DefaultNavFocusBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/bean/DefaultNavFocusBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/bean/ItemTypeBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/bean/ItemTypeBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/bean/LogOutBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/bean/LogOutBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/bean/MyIndexBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/bean/MyIndexBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/bean/NavBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/bean/NavBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/bean/NavFocusBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/bean/NavFocusBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/bean/NavResideBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/bean/NavResideBean.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/dialog/BackImgDialog.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/dialog/BackImgDialog.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/dialog/BackRetentionDialog.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/dialog/BackRetentionDialog.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/dialog/FullimgDialog.java
Executable file → Normal file
0
app/src/main/java/com/pukka/jiecheng/index/dialog/FullimgDialog.java
Executable file → Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user