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/*
|
||||
.agents/*
|
||||
.trae/*
|
||||
.worktrees/
|
||||
skills-lock.json
|
||||
*.log
|
||||
/CLAUDE.md
|
||||
.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 {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
@@ -16,13 +14,21 @@ android {
|
||||
checkReleaseBuilds false
|
||||
abortOnError false
|
||||
}
|
||||
// 单元测试跑在 JVM 上,org.json 等 Android SDK 类默认抛 "not mocked";
|
||||
// voice/parser(SkyworthVodParser 等)依赖 org.json 解析 payload,
|
||||
// 需要真实实现。这里引入官方 json.org 的 JVM 版本(仅 test classpath)。
|
||||
testOptions {
|
||||
unitTests {
|
||||
includeAndroidResources = true
|
||||
}
|
||||
}
|
||||
def versionSuffix = "0" // 定义版本后缀变量,必须小于或等于9大于0
|
||||
defaultConfig {
|
||||
applicationId "tv.huashi.jetsenx.jetsenfilx.jsttv"
|
||||
minSdk app_android.minSdkVersion
|
||||
targetSdk app_android.targetSdkVersion
|
||||
versionCode Integer.parseInt(new Date().format("yyMMddHH") + versionSuffix)
|
||||
versionName "v1.3.28"
|
||||
versionName "v1.3.35"
|
||||
multiDexEnabled true
|
||||
//压缩语言种类,目前只支持中文语言
|
||||
resConfigs('zh-rCN')
|
||||
@@ -30,6 +36,10 @@ android {
|
||||
// Define flavor dimensions
|
||||
flavorDimensions "channel", "control"
|
||||
|
||||
// ===== 语音协议占位符默认值(文档 §6.2 signature-level permission) =====
|
||||
// debug 通过 flavor 覆盖为 normal 方便联调,release 维持 signature。
|
||||
manifestPlaceholders = [voiceProtectionLevel: "signature"]
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
//酒店 ANNING 安柠 AISHI 爱视(不要手柄)
|
||||
buildConfigField "String", "HOTEL_TYPE", "\"AISHI\""
|
||||
@@ -39,9 +49,11 @@ android {
|
||||
buildConfigField "boolean", "NEED_ACTIVATION_CODE", "true"
|
||||
//是否为扫码变体(as8Scan 覆盖为 true;asAScan 同样覆盖为 true)
|
||||
buildConfigField "boolean", "SCAN_VARIANT", "false"
|
||||
//是否为家用版(home flavor 覆盖为 true)
|
||||
buildConfigField "boolean", "IS_HOME_EDITION", "false"
|
||||
//默认激活码:扫码变体在 NEED_ACTIVATION_CODE=false 时使用,普通渠道为空字符串
|
||||
buildConfigField "String", "DEFAULT_ACTIVATION_CODE", "\"\""
|
||||
buildConfigField "String", "BUGLY_APP_ID", "\"01f1b10fca\""
|
||||
//是否为第三方变体(thirdparty flavor 覆盖为 true)
|
||||
buildConfigField "boolean", "THIRDPARTY_VARIANT", "false"
|
||||
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "arm64-v8a"
|
||||
@@ -71,6 +83,8 @@ android {
|
||||
zipAlignEnabled true // 是否开启zip压缩
|
||||
signingConfig signingConfigs.release
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
// release 严格按文档 §6.2 使用 signature-level permission
|
||||
manifestPlaceholders = [voiceProtectionLevel: "signature"]
|
||||
}
|
||||
|
||||
// preview {
|
||||
@@ -86,12 +100,14 @@ android {
|
||||
|
||||
debug {
|
||||
// AS 域 baseUrl:debug 走测试/内网域名
|
||||
buildConfigField "String", "AS_BASE_URL", "\"https://jst-test.ainetv.cn/api/\"" //http://as-partner-end.172.16.100.110.sslip.io/
|
||||
buildConfigField "String", "AS_BASE_URL", "\"http://as-partner-end.172.16.100.105.sslip.io/\"" //http://as-partner-end.172.16.100.105.sslip.io/
|
||||
minifyEnabled false
|
||||
shrinkResources false
|
||||
zipAlignEnabled true // 是否开启zip压缩
|
||||
signingConfig signingConfigs.release
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
// 联调期间把 voice 协议自定义 permission 降级为 normal,让 adb shell 可投递(release 仍走 signature)。
|
||||
manifestPlaceholders = [voiceProtectionLevel: "normal"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,16 +154,29 @@ android {
|
||||
buildConfigField "String", "CHANNEL_ID", "\"TS\""
|
||||
}
|
||||
|
||||
// --- aishi 子方向:扫码版(无激活码 默认 8888 的激活码) ---
|
||||
// --- aishi 子方向:扫码版(无激活码 默认 88888 的激活码) ---
|
||||
// 共享 aishi 渠道路由(X-Channel-Id="AS"),仅客户端激活流程差异
|
||||
as8Scan {
|
||||
dimension "channel"
|
||||
manifestPlaceholders = [
|
||||
APP_CHANNEL_ID : "aishi",
|
||||
APP_CHANNEL_ID : "as8Scan",
|
||||
splash_theme : "@style/SplashTheme"]
|
||||
buildConfigField "String", "CHANNEL_ID", "\"AS\""
|
||||
buildConfigField "boolean", "NEED_ACTIVATION_CODE", "false"
|
||||
buildConfigField "boolean", "SCAN_VARIANT", "true"
|
||||
buildConfigField "String", "DEFAULT_ACTIVATION_CODE", "\"88888\""
|
||||
}
|
||||
|
||||
// --- 声立方:扫码版(无激活码 默认 UK4KQ 的激活码) ---
|
||||
fxScan {
|
||||
dimension "channel"
|
||||
manifestPlaceholders = [
|
||||
APP_CHANNEL_ID : "fxScan",
|
||||
splash_theme : "@style/SplashTheme"]
|
||||
buildConfigField "String", "CHANNEL_ID", "\"FX\""
|
||||
buildConfigField "boolean", "NEED_ACTIVATION_CODE", "false"
|
||||
buildConfigField "boolean", "SCAN_VARIANT", "true"
|
||||
buildConfigField "String", "DEFAULT_ACTIVATION_CODE", "\"UK4KQ\""
|
||||
}
|
||||
|
||||
// --- aishi 子方向:激活扫码版(有激活码) ---
|
||||
@@ -157,23 +186,58 @@ android {
|
||||
asAScan {
|
||||
dimension "channel"
|
||||
manifestPlaceholders = [
|
||||
APP_CHANNEL_ID : "aishi",
|
||||
APP_CHANNEL_ID : "asAScan",
|
||||
splash_theme : "@style/SplashTheme"]
|
||||
buildConfigField "String", "CHANNEL_ID", "\"AS\""
|
||||
buildConfigField "boolean", "SCAN_VARIANT", "true"
|
||||
}
|
||||
|
||||
// --- 第三方渠道(thirdparty) ---
|
||||
thirdparty {
|
||||
// 测试渠道分销
|
||||
qnAScan {
|
||||
dimension "channel"
|
||||
applicationIdSuffix ".thirdparty"
|
||||
manifestPlaceholders = [
|
||||
APP_CHANNEL_ID : "thirdparty",
|
||||
APP_CHANNEL_ID : "qnAScan",
|
||||
splash_theme : "@style/SplashTheme"]
|
||||
buildConfigField "String", "CHANNEL_ID", "\"TP\""
|
||||
buildConfigField "boolean", "THIRDPARTY_VARIANT", "true"
|
||||
// 注意:AS_BASE_URL 不能在此处覆盖,buildType 的 buildConfigField 优先级更高会将其覆盖。
|
||||
// 第三方域名通过下方 androidComponents.onVariants 设置(Variant API 优先级最高)。
|
||||
buildConfigField "String", "CHANNEL_ID", "\"QN\""
|
||||
buildConfigField "boolean", "SCAN_VARIANT", "true"
|
||||
}
|
||||
|
||||
|
||||
// --- 家用版:独立 applicationId,与商用渠道并行发行 ---
|
||||
// 暂与商用版共用 BUGLY_APP_ID / versionCode(家用版需独立时再覆盖)。
|
||||
// 未来若需按地区/子渠道拆分,加为 sibling flavor 并各自挂 applicationIdSuffix 即可。
|
||||
//
|
||||
// CHANNEL_ID="HM":与 CLAUDE.md 渠道表一致,且避免与 fxScan("FX") 在 X-Channel-ID 头冲突
|
||||
// (OkHttpClientUtil + HttpRequestInterceptor 注入该头,相同 CHANNEL_ID 会让后端把家用版路由为声立方扫码版)。
|
||||
// SCAN_VARIANT=true + DEFAULT_ACTIVATION_CODE="UK4KQ":保持与 as8Scan/fxScan 同款的"扫码 + 静默激活"语义,
|
||||
// 配合 tryAutoActivateWithDefaultCode 在 MainActivity 静默激活家用版设备。
|
||||
// 若后续家用版切换为需要激活码输入面板,可将 NEED_ACTIVATION_CODE 改回 true 并清空 DEFAULT_ACTIVATION_CODE。
|
||||
home {
|
||||
dimension "channel"
|
||||
applicationIdSuffix ".home"
|
||||
manifestPlaceholders = [
|
||||
APP_CHANNEL_ID : "home",
|
||||
splash_theme : "@style/SplashTheme"]
|
||||
buildConfigField "String", "CHANNEL_ID", "\"FX\""
|
||||
buildConfigField "String", "HOTEL_TYPE", "\"HOME\""
|
||||
buildConfigField "boolean", "IS_HOME_EDITION", "true"
|
||||
buildConfigField "boolean", "NEED_ACTIVATION_CODE", "false"
|
||||
buildConfigField "boolean", "SCAN_VARIANT", "false"
|
||||
buildConfigField "String", "DEFAULT_ACTIVATION_CODE", "\"UK4KQ\""
|
||||
}
|
||||
|
||||
ashome {
|
||||
dimension "channel"
|
||||
applicationIdSuffix ".home"
|
||||
manifestPlaceholders = [
|
||||
APP_CHANNEL_ID : "home",
|
||||
splash_theme : "@style/SplashTheme"]
|
||||
buildConfigField "String", "CHANNEL_ID", "\"AS\""
|
||||
buildConfigField "String", "HOTEL_TYPE", "\"HOME\""
|
||||
buildConfigField "boolean", "IS_HOME_EDITION", "true"
|
||||
buildConfigField "boolean", "NEED_ACTIVATION_CODE", "false"
|
||||
buildConfigField "boolean", "SCAN_VARIANT", "false"
|
||||
buildConfigField "String", "DEFAULT_ACTIVATION_CODE", "\"88888\""
|
||||
}
|
||||
|
||||
// Add more channels to this list as needed
|
||||
@@ -222,18 +286,6 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// 第三方变体 AS_BASE_URL 覆盖:
|
||||
// buildType 的 buildConfigField 优先级高于 flavor,因此 thirdparty flavor 内直接设置会被
|
||||
// debug/release buildType 覆盖为 jst-test/jst.ainetv.cn。Variant API 的优先级最高,在此统一覆盖。
|
||||
androidComponents {
|
||||
onVariants(selector().withFlavor("channel", "thirdparty")) { variant ->
|
||||
variant.buildConfigFields.put(
|
||||
"AS_BASE_URL",
|
||||
new BuildConfigField("String", "\"http://58.220.83.58:8084/\"", null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
dependencies {
|
||||
|
||||
@@ -260,12 +312,13 @@ dependencies {
|
||||
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
||||
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.19.1'
|
||||
//implementation(name: 'exoplayer-ffmpeg-extension-2.19.1', ext: 'aar')
|
||||
// 5.1声道兜底(AC3/EAC3/DTS/TrueHD),配合 PkExoMediaPlayer 的 EXTENSION_RENDERER_MODE_ON:
|
||||
// 平台渲染器优先(支持AC3直通/HW解码的设备走平台),FFmpeg仅在平台不支持时兜底软解为PCM
|
||||
implementation files('libs/exoplayer-ffmpeg-extension-2.19.1.aar')
|
||||
|
||||
// implementation 'com.sensorsdata.analytics.android:SensorsAnalyticsSDK:6.8.3'
|
||||
implementation 'commons-codec:commons-codec:1.15'
|
||||
// 3.12.0:3.12.1+(含 3.12.13)引入 Android10Platform 触发 Dalvik VerifyError,必须精确锁 3.12.0
|
||||
implementation 'com.squareup.okhttp3:okhttp:3.12.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp:3.12.13'
|
||||
implementation 'com.google.code.gson:gson:2.8.9'
|
||||
implementation 'com.github.gzu-liyujiang:Android_CN_OAID:4.1.3'
|
||||
implementation('io.github.volcengine-tls:tls-android-producer:2.0.2-api16') {
|
||||
@@ -279,32 +332,30 @@ dependencies {
|
||||
implementation 'com.google.guava:guava:31.1-android'
|
||||
|
||||
// ===== 网络层(脚手架新引入,与既有 HttpUtils 双栈共存) =====
|
||||
// ===== Android 4.2 兼容(对齐 mediaapp 已验证组合,见 Android4.2兼容性改动文档)=====
|
||||
// - OkHttp 4.x 官方要求 API 21+;3.13+ 含 Android10Platform(API 10 SSL API)触发 Dalvik VerifyError。
|
||||
// 3.12.0 是支持 API 9-20 的长期分支(与 libcore/libdata 老栈版本一致),配合下方 force 全局锁定。
|
||||
// - Retrofit 2.6.0:suspend 函数 2.6.0 起支持;2.9+ 传递依赖 OkHttp 3.14.9(含 Android10Platform)。
|
||||
// - Coroutines 1.6.4:1.7+ 要求 minSdk 21,1.6.4 兼容 API 16+。
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.6.0'
|
||||
implementation 'com.squareup.retrofit2:converter-moshi:2.6.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp:3.12.0'
|
||||
implementation 'com.squareup.okhttp3:logging-interceptor:3.12.0'
|
||||
// 为什么显式指定版本:app 模块此前未声明 retrofit/okhttp/moshi/coroutines,避免被根 config.gradle 隐式拉入不同版本。
|
||||
// Kotlin 1.9.22 -> KSP 1.9.22-1.0.17 已对齐。
|
||||
// 为什么用 `platform(enforcedPlatform)` 而非 `implementation`:okhttp-bom 是 Bill-of-Materials,本身仅提供版本对齐,
|
||||
// 不能被当作运行时产物拉到 runtime classpath(这正是上一个编译报错的根因)。
|
||||
implementation platform('com.squareup.okhttp3:okhttp-bom:4.12.0')
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.11.0'
|
||||
implementation 'com.squareup.retrofit2:converter-moshi:2.11.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp'
|
||||
implementation 'com.squareup.okhttp3:logging-interceptor'
|
||||
implementation 'com.squareup.moshi:moshi:1.15.1'
|
||||
implementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
|
||||
ksp 'com.squareup.moshi:moshi-kotlin-codegen:1.15.1'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
|
||||
|
||||
// 防止其他依赖(tls-producer/libdata 等)传递引入更高版本 OkHttp/Retrofit 破坏 4.2 兼容
|
||||
configurations.all {
|
||||
resolutionStrategy.force 'com.squareup.okhttp3:okhttp:3.12.0'
|
||||
resolutionStrategy.force 'com.squareup.retrofit2:retrofit:2.6.0'
|
||||
}
|
||||
|
||||
implementation 'net.jpountz.lz4:lz4:1.3.0'
|
||||
implementation 'org.slf4j:slf4j-android:1.7.36'
|
||||
//腾讯buggly
|
||||
implementation 'com.tencent.bugly:crashreport:latest.release'
|
||||
//爱奇艺本地奔溃日志
|
||||
implementation 'com.iqiyi.xcrash:xcrash-android-lib:3.1.0'
|
||||
|
||||
// org.json 的 JVM 实现:Android SDK 自带的 org.json 在本地单测里是空壳(not mocked),
|
||||
// 供 voice/parser 下基于 JSONObject 的解析逻辑做真实单测。
|
||||
testImplementation 'org.json:json:20180813'
|
||||
}
|
||||
|
||||
0
app/key/jiecheng.jks
Executable file → Normal file
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"
|
||||
tools:ignore="MissingLeanbackLauncher">
|
||||
|
||||
<!-- Android 4.2 兼容(minSdk 17):覆盖 minSdk>17 的依赖库声明,运行时兼容性靠
|
||||
降级依赖版本 + 不触碰高版本 API 路径保证。清单随探测编译逐步补充。 -->
|
||||
<uses-sdk tools:overrideLibrary="com.github.gzuliyujiang.oaid" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.telephony"
|
||||
android:required="false" />
|
||||
@@ -41,6 +37,23 @@
|
||||
<uses-permission android:name="android.permission.REPLACE_EXISTING_PACKAGE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<!-- ===== 语音协议(文档 §6.2):自定义 signature-level permission ===== -->
|
||||
<!-- debug flavor 通过 voiceProtectionLevel=normal 临时放开(联调期 adb shell 可投递);
|
||||
release 走 signature 严格白名单。 -->
|
||||
<permission
|
||||
android:name="com.pukka.jiecheng.permission.VOICE_COMMAND"
|
||||
android:protectionLevel="${voiceProtectionLevel}" />
|
||||
<uses-permission android:name="com.pukka.jiecheng.permission.VOICE_COMMAND" />
|
||||
|
||||
<!-- ===== 语音冷启动保活(文档 2026-08-28-语音冷启动链路设计方案) ===== -->
|
||||
<!-- BOOT_COMPLETED 启 FGS 必须先声明权限;FOREGROUND_SERVICE 是 Android 9+ 启前台服务的前置 -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<!-- 语音冷启动绕过 Background Activity Start 限制::voice 进程挂 1px 悬浮窗,
|
||||
AMS 视为有 visible window,后续 startActivity 走豁免路径。
|
||||
标准权限,部分 OEM ROM 自动授予;无需运行时申请。 -->
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
|
||||
<application
|
||||
android:name=".MyApplication"
|
||||
android:allowBackup="false"
|
||||
@@ -71,6 +84,10 @@
|
||||
android:name=".device.DeviceActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="landscape" />
|
||||
<activity
|
||||
android:name=".setting.SettingActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="landscape" />
|
||||
<activity
|
||||
android:name=".order.OrderActivity"
|
||||
android:exported="false"
|
||||
@@ -85,6 +102,11 @@
|
||||
android:exported="false"
|
||||
android:screenOrientation="landscape"
|
||||
android:windowSoftInputMode="stateHidden|adjustResize" />
|
||||
<!-- 家用版微信扫码登录 Activity(第一阶段占位),仅应用内启动 -->
|
||||
<activity
|
||||
android:name=".login.WxMpLoginActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="landscape" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -98,17 +120,6 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ThirdPartyEntryActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="landscape"
|
||||
android:theme="@style/whiteTheme">
|
||||
<intent-filter>
|
||||
<action android:name="com.pukka.jiecheng.action.THIRD_PARTY_PLAY" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".TestActivity"
|
||||
android:exported="true"
|
||||
@@ -156,6 +167,61 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- 语音命令接收器(文档 §5.2) -->
|
||||
<receiver
|
||||
android:name=".voice.receiver.VoiceCommandReceiver"
|
||||
android:exported="true"
|
||||
android:permission="com.pukka.jiecheng.permission.VOICE_COMMAND">
|
||||
<intent-filter>
|
||||
<action android:name="com.pukka.jiecheng.action.VOICE_COMMAND" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- 语音冷启动链路:开机拉起 :voice 进程 FGS(文档 2026-08-28-语音冷启动链路设计方案) -->
|
||||
<receiver
|
||||
android:name=".voice.receiver.BootCompletedReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:directBootAware="true"
|
||||
android:process=":voice"
|
||||
android:permission="android.permission.RECEIVE_BOOT_COMPLETED">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- 自广播兜底接收器:处理 KEEPALIVE_BOOT 自定义 action。
|
||||
解决 BOOT_COMPLETED 因 STOPPED / OEM 拦截失败时 :voice 进程无法自启的问题。
|
||||
同包 explicit broadcast(Intent.setPackage)不受 STOPPED 状态限制。
|
||||
exported=false 不暴露给外部,仅接收应用自身 AlarmManager 派发的 PendingIntent。 -->
|
||||
<receiver
|
||||
android:name=".voice.receiver.KeepAlivePingReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:process=":voice">
|
||||
<intent-filter>
|
||||
<action android:name="com.pukka.jiecheng.action.KEEPALIVE_BOOT" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- 语音冷启动保活服务:保持 :voice 进程隔离。
|
||||
设计意图:主进程被 LMK 杀掉后 receiver 仍能存活,持续监听 OEM 创维广播。
|
||||
cold-launch 受 Android 12+ Background Activity Start 限制的问题,由
|
||||
VoiceOverlayHelper 在 :voice 进程挂 1px TYPE_APPLICATION_OVERLAY 悬浮窗
|
||||
提供 visible window 豁免(AMS 视为前台可见窗口),配合
|
||||
SkyworthVoiceAdapter.coldLaunchMainActivity 优先路径
|
||||
showOverlay → ctx.startActivity(cold) → hideOverlay 绕过。
|
||||
FullScreenIntent 通知作为 OEM ROM 拦截悬浮窗场景下的次选兜底保留。
|
||||
当前设备 Android 11 不需要 USE_FULL_SCREEN_INTENT 权限;如后续适配
|
||||
Android 14 设备需在 manifest 声明该权限。 -->
|
||||
<service
|
||||
android:name=".voice.service.VoiceKeepAliveService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:process=":voice" />
|
||||
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
0
app/src/main/java/com/pukka/jiecheng/JcConfig.java
Executable file → Normal file
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.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
@@ -33,8 +35,10 @@ import com.pukka.jiecheng.downapk.UpgradeHelper;
|
||||
import com.pukka.jiecheng.BuildConfig;
|
||||
import com.pukka.jiecheng.index.IndexActivity;
|
||||
import com.pukka.jiecheng.login.bean.LoginLogonBean;
|
||||
import com.pukka.jiecheng.monitor.NetworkMonitor;
|
||||
import com.pukka.jiecheng.newtv.NewTvView;
|
||||
import com.pukka.jiecheng.permission.AgreementStoragePermissionGateway;
|
||||
import com.pukka.jiecheng.request.AsApiErrorCode;
|
||||
import com.pukka.jiecheng.request.AsApiRequester;
|
||||
import com.pukka.jiecheng.request.AsHeartBeatManager;
|
||||
import com.pukka.jiecheng.utils.ActivitedDialog;
|
||||
@@ -63,6 +67,11 @@ import com.pukka.libcore.utils.SpTimeUtils;
|
||||
import com.pukka.libcore.utils.StringUtils;
|
||||
import com.tencent.bugly.crashreport.BuglyLog;
|
||||
|
||||
import com.pukka.jiecheng.voice.parser.SkyworthVodParser;
|
||||
import com.pukka.jiecheng.voice.protocol.VoiceCommand;
|
||||
import com.pukka.jiecheng.voice.protocol.VoiceExtras;
|
||||
import com.pukka.jiecheng.voice.service.VoiceCommandService;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.HashMap;
|
||||
@@ -76,7 +85,8 @@ import io.reactivex.rxjava3.core.Single;
|
||||
* @Date:2025-05-14-15
|
||||
* @Description:启动页 路由控制
|
||||
*/
|
||||
public class MainActivity extends AppCompatActivity implements AsApiRequester.CheckStatusCallback {
|
||||
public class MainActivity extends AppCompatActivity
|
||||
implements AsApiRequester.CheckStatusCallback, NetworkMonitor.NetworkReceiver {
|
||||
private static final String TAG = "MainActivity";
|
||||
private final ViewTreeObserver.OnGlobalFocusChangeListener globalFocusChangeListener = (oldFocus, newFocus) ->
|
||||
LogUtils.d(TAG, "focus changed old=" + formatFocusView(oldFocus) + ", new=" + formatFocusView(newFocus));
|
||||
@@ -84,6 +94,8 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
* 用户协议中的同意按钮点击后发送通知
|
||||
*/
|
||||
private boolean isShowToLogin = true;
|
||||
// 家用版标志:激活界面全程不可见,未激活时静默调用家用版激活码激活
|
||||
private final boolean isHomeEdition = AppConfiguration.getInstance().isHomeEdition();
|
||||
private final AsApiRequester asApiRequester = AsApiRequester.INSTANCE;
|
||||
private Context context;
|
||||
private UserAgreementView userAgreementView;
|
||||
@@ -108,38 +120,64 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
private String intentRoomNo;
|
||||
// 防重入:checkBenefits 多次回调(如 onPreset/onUnbound 等)时只触发一次自动激活
|
||||
private boolean autoActivateAttempted = false;
|
||||
// 家用版静默激活进行中:避免并发回调在第一次静默激活 in-flight 期间误拉网络错误全屏。
|
||||
// 仅由 onActivateFailed(家用版静默激活的真正失败回调)清掉。
|
||||
private boolean isSilentActivationInFlight = false;
|
||||
|
||||
// 无激活码版(as8Scan,NEED_ACTIVATION_CODE=false)默认激活码:
|
||||
// 该变体不展示激活码输入面板,由后端按固定激活码 "8888" 完成激活,
|
||||
// 网络恢复自动重试:true 时收到网络恢复事件直接 return,防并发重试
|
||||
private boolean isRetryingNetworkError = false;
|
||||
// NetworkMonitor 实例:与 BroadcastReceiver 同生命周期
|
||||
private NetworkMonitor networkMonitor;
|
||||
|
||||
// 无激活码版(扫码变体,NEED_ACTIVATION_CODE=false)默认激活码:
|
||||
// 该变体不展示激活码输入面板,由后端按 BuildConfig.DEFAULT_ACTIVATION_CODE 配置的激活码完成激活,
|
||||
// 房间号为空字符串。由 tryAutoActivateWithDefaultCode 直接提交。
|
||||
private static final String DEFAULT_ACTIVATION_CODE = "88888";
|
||||
// 各渠道默认值由 build.gradle 的 productFlavors 定义:as8Scan="88888",fxScan="UK4KQ",普通渠道=""
|
||||
private static final String DEFAULT_ACTIVATION_CODE = BuildConfig.DEFAULT_ACTIVATION_CODE;
|
||||
|
||||
private final AgreementStoragePermissionGateway agreementStoragePermissionGateway = new AgreementStoragePermissionGateway();
|
||||
|
||||
// 语音冷启动待入队命令:见 docs/2026-08-28-语音冷启动链路设计方案.md §4.1
|
||||
// extras / SP 键统一定义在 VoiceExtras,本处只持有内存态。
|
||||
private Bundle pendingVoiceExtras;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
context = this;
|
||||
LogUtils.d("appStart --- activity.onCreate:start");
|
||||
setContentView(R.layout.activity_main);
|
||||
// 第三方变体:直接跳转 ThirdPartyEntryActivity
|
||||
if (AppConfiguration.getInstance().isThirdPartyVariant()) {
|
||||
Intent intent = new Intent(MainActivity.this, ThirdPartyEntryActivity.class);
|
||||
// 透传当前 Intent 的 extras(包含 EPG 传入的参数)
|
||||
intent.putExtras(getIntent());
|
||||
startActivity(intent);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
//registerGlobalFocusListener();
|
||||
AppManager.getAppManager().addActivity(this);
|
||||
BuildConfigUtils.initBuildConfig(ContextUtils.getApplicationContext());
|
||||
ChannelManger.initChannel();
|
||||
// 解析启动 Intent 中的激活参数(用于自动激活场景)
|
||||
parseAutoActivateIntent();
|
||||
// 捕获语音冷启动待入队 extras(见 docs/2026-08-28-语音冷启动链路设计方案.md §4.1)
|
||||
capturePendingVoiceExtras();
|
||||
initView();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold-launch 经 FLAG_ACTIVITY_CLEAR_TOP 重用现有 MainActivity 实例时(launchMode=standard),
|
||||
* Android 不会回调 onCreate,新 Intent 通过本方法进入。{@code getIntent()} 默认仍返回原始
|
||||
* Intent,必须 setIntent(intent) 才能让 capturePendingVoiceExtras 看到新 Intent 上的
|
||||
* {@code __voice_pending} extras,否则主进程未激活路径下入队会静默丢失。
|
||||
*
|
||||
* <p>详见 docs/2026-08-28-语音冷启动链路设计方案.md §4.1。
|
||||
*/
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
if (intent == null) return;
|
||||
LogUtils.d(TAG, "onNewIntent, hasVoicePending="
|
||||
+ intent.getBooleanExtra(VoiceExtras.EXTRA_VOICE_PENDING, false)
|
||||
+ ", hasActivateCode=" + !TextUtils.isEmpty(intent.getStringExtra("activate_code")));
|
||||
setIntent(intent);
|
||||
// 入队仍走 capturePendingVoiceExtras → goToActivity → enqueuePendingVoiceCommand 单点。
|
||||
capturePendingVoiceExtras();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析启动 Intent 中的 activate_code / room_no 参数。
|
||||
* 仅当两个参数同时存在时,启用后续的 checkBenefits 自动激活流程。
|
||||
@@ -238,6 +276,8 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
submitDeviceActivateCode(activateCode, roomNo);
|
||||
}
|
||||
});
|
||||
// 注册网络变化广播:用于 init 网络失败全屏页展示期间,网络恢复时自动重试
|
||||
registerNetworkMonitor();
|
||||
nextInit();
|
||||
if (userAgreementView.isShow()) {
|
||||
userAgreementView.setVisibility(View.VISIBLE);
|
||||
@@ -402,6 +442,11 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
() -> {
|
||||
// 家用版:静默激活成功不触碰任何激活 UI;真正的进首页由 onActivated 回调驱动
|
||||
if (isHomeEdition) {
|
||||
LogUtils.d(TAG, "home edition activate submitted, wait onActivated to route");
|
||||
return;
|
||||
}
|
||||
deviceNotActivatedView.setSubmitting(false);
|
||||
deviceNotActivatedView.showActivateMessage("激活成功,正在刷新服务状态...");
|
||||
ToastUtils.showShortToastSafe("激活成功");
|
||||
@@ -473,7 +518,45 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 家用版非激活终态统一入口:
|
||||
* - 尚未尝试过静默激活 → 用家用版默认激活码静默激活一次(tryAutoActivateFromIntent 内部短路到默认码)
|
||||
* - 已尝试过仍未激活 → 降级到网络错误全屏(含退出按钮),绝不展示激活输入面板
|
||||
*
|
||||
* 竞态防护:AsApiRequester 异步派发 onPreset/onUnbound/onActivateFailed 等多个非激活回调时,
|
||||
* 仅有第一个回调触发 tryAutoActivateFromIntent;后续回调在 autoActivateAttempted=true 短路到
|
||||
* showHomeActivationFailure。但若静默激活仍在 in-flight 期间被多个回调排队,它们会全部跳到
|
||||
* showNetworkErrorFullscreen 形成"网络错误"全屏闪现。解决:用 isSilentActivationInFlight
|
||||
* 标记静默激活进行中,期间到达的非激活回调静默忽略(不拉全屏),让真正的失败回调(onActivateFailed
|
||||
* 或 onUnbound 终态)统一收口。
|
||||
*/
|
||||
private void onHomeNonActivatedState(AsApiRequester.CheckBenefitsResult result) {
|
||||
if (autoActivateAttempted) {
|
||||
// 静默激活进行中:忽略并发回调,避免拉网络错误全屏闪现。
|
||||
if (isSilentActivationInFlight) {
|
||||
LogUtils.d(TAG, "home edition: silent activation in-flight, ignore duplicate non-activated callback");
|
||||
return;
|
||||
}
|
||||
showHomeActivationFailure(result == null ? null : result.msg);
|
||||
return;
|
||||
}
|
||||
tryAutoActivateFromIntent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 家用版静默激活失败降级:不弹激活输入面板,改用网络错误全屏(含退出按钮)兜底。
|
||||
* @param message 后端返回的错误信息,可为空(空则使用通用兜底文案)
|
||||
*/
|
||||
private void showHomeActivationFailure(String message) {
|
||||
LogUtils.e(TAG, "home edition silent activation failed: " + message);
|
||||
showNetworkErrorFullscreen(message);
|
||||
}
|
||||
|
||||
private void showActivateFail(String message) {
|
||||
if (isHomeEdition) {
|
||||
showHomeActivationFailure(message);
|
||||
return;
|
||||
}
|
||||
String safeMessage = StringUtils.isEmpty(message) ? "激活失败,请稍后重试" : message;
|
||||
runOnUiThread(() -> {
|
||||
deviceNotActivatedView.setSubmitting(false);
|
||||
@@ -566,12 +649,97 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
return;
|
||||
}
|
||||
LogUtils.d(TAG,"goToActivity --- start: ");
|
||||
// 语音冷启动待入队命令:捕获到的 extras 入队到 VoiceCommandService.coldStartQueue,
|
||||
// IndexActivity 首帧渲染完成后由既有 replayColdStartVoiceCommands 钩子回放。
|
||||
if (pendingVoiceExtras != null) {
|
||||
enqueuePendingVoiceCommand();
|
||||
}
|
||||
newIntent = new Intent();
|
||||
newIntent.setClass(MainActivity.this, IndexActivity.class);
|
||||
startActivity(newIntent);
|
||||
finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从启动 Intent 或 SP 复原语音冷启动 extras。
|
||||
*
|
||||
* <p>两路来源:
|
||||
* <ol>
|
||||
* <li>当前 Intent 带 {@code __voice_pending=true}(典型场景::voice 进程 cold launch MainActivity,
|
||||
* 或主进程已存活但未激活时 VoiceInternalReceiver 冷启 MainActivity)</li>
|
||||
* <li>Intent 不带 extras 但 SP 标记为 true(典型场景:强制升级后新版 App 启动 / appExit 后再次启动)</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>复原后写入 SP 持久化标志,覆盖"激活完成后才走 goToActivity"或"升级后 MainActivity 重启"
|
||||
* 两条路径——只要没消费就一直保留直到 enqueuePendingVoiceCommand 显式清掉。
|
||||
*/
|
||||
private void capturePendingVoiceExtras() {
|
||||
Intent it = getIntent();
|
||||
if (it != null && it.getBooleanExtra(VoiceExtras.EXTRA_VOICE_PENDING, false)) {
|
||||
pendingVoiceExtras = cloneVoiceExtras(it);
|
||||
persistPendingVoice(pendingVoiceExtras);
|
||||
} else if (SharedPreferenceUtils.getBoolean(VoiceExtras.SP_PENDING_VOICE_FLAG, false)) {
|
||||
pendingVoiceExtras = restorePendingVoice();
|
||||
}
|
||||
}
|
||||
|
||||
private static Bundle cloneVoiceExtras(Intent src) {
|
||||
Bundle bundle = new Bundle();
|
||||
for (String key : new String[]{VoiceExtras.EXTRA_OEM_FILM_NAME, VoiceExtras.EXTRA_OEM_AUTOPLAY,
|
||||
VoiceExtras.EXTRA_OEM_REQUEST_ID, VoiceExtras.EXTRA_OEM_SOURCE_APP}) {
|
||||
String value = src.getStringExtra(key);
|
||||
if (value != null) bundle.putString(key, value);
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
private static void persistPendingVoice(Bundle bundle) {
|
||||
if (bundle == null) return;
|
||||
SharedPreferenceUtils.put(VoiceExtras.SP_KEY_PENDING_FILM_NAME, bundle.getString(VoiceExtras.EXTRA_OEM_FILM_NAME, ""));
|
||||
SharedPreferenceUtils.put(VoiceExtras.SP_KEY_PENDING_AUTOPLAY, bundle.getString(VoiceExtras.EXTRA_OEM_AUTOPLAY, ""));
|
||||
SharedPreferenceUtils.put(VoiceExtras.SP_KEY_PENDING_REQUEST_ID, bundle.getString(VoiceExtras.EXTRA_OEM_REQUEST_ID, ""));
|
||||
SharedPreferenceUtils.put(VoiceExtras.SP_KEY_PENDING_SENDER, bundle.getString(VoiceExtras.EXTRA_OEM_SOURCE_APP, ""));
|
||||
SharedPreferenceUtils.put(VoiceExtras.SP_PENDING_VOICE_FLAG, true);
|
||||
}
|
||||
|
||||
private static Bundle restorePendingVoice() {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putString(VoiceExtras.EXTRA_OEM_FILM_NAME, SharedPreferenceUtils.getString(VoiceExtras.SP_KEY_PENDING_FILM_NAME, ""));
|
||||
bundle.putString(VoiceExtras.EXTRA_OEM_AUTOPLAY, SharedPreferenceUtils.getString(VoiceExtras.SP_KEY_PENDING_AUTOPLAY, ""));
|
||||
bundle.putString(VoiceExtras.EXTRA_OEM_REQUEST_ID, SharedPreferenceUtils.getString(VoiceExtras.SP_KEY_PENDING_REQUEST_ID, ""));
|
||||
bundle.putString(VoiceExtras.EXTRA_OEM_SOURCE_APP, SharedPreferenceUtils.getString(VoiceExtras.SP_KEY_PENDING_SENDER, ""));
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 pendingVoiceExtras 解析成 VoiceCommand 并入队 coldStartQueue。
|
||||
*
|
||||
* <p>状态清理规则(修复 code-review finding #8):
|
||||
* 仅当 parseJump 成功入队后才清 pendingVoiceExtras + SP 标志。
|
||||
* parseJump 失败时保留状态,下一次 MainActivity.onCreate 重试;
|
||||
* 避免 parser reject 时命令被静默丢失。
|
||||
*/
|
||||
private void enqueuePendingVoiceCommand() {
|
||||
try {
|
||||
String filmName = pendingVoiceExtras.getString(VoiceExtras.EXTRA_OEM_FILM_NAME);
|
||||
String autoPlay = pendingVoiceExtras.getString(VoiceExtras.EXTRA_OEM_AUTOPLAY);
|
||||
String requestId = pendingVoiceExtras.getString(VoiceExtras.EXTRA_OEM_REQUEST_ID);
|
||||
String sender = pendingVoiceExtras.getString(VoiceExtras.EXTRA_OEM_SOURCE_APP);
|
||||
VoiceCommandService service = VoiceCommandService.getInstance(getApplicationContext());
|
||||
VoiceCommand cmd = SkyworthVodParser.parseJump(service, filmName, autoPlay, requestId, sender);
|
||||
if (cmd != null) {
|
||||
service.enqueueForColdStart(cmd);
|
||||
pendingVoiceExtras = null;
|
||||
SharedPreferenceUtils.put(VoiceExtras.SP_PENDING_VOICE_FLAG, false);
|
||||
LogUtils.d(TAG, "voice command enqueued for cold start: " + requestId);
|
||||
} else {
|
||||
LogUtils.w(TAG, "voice command parse returned null, keep pending for retry. requestId=" + requestId);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
LogUtils.e(TAG, "enqueuePendingVoiceCommand failed: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息接口
|
||||
*/
|
||||
@@ -592,6 +760,8 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
asApiRequester.requestAsInitDeviceApi(this, new AsApiRequester.InitCallback() {
|
||||
@Override
|
||||
public void onSuccess(AsApiRequester.InitResult result) {
|
||||
// 无论初次调用还是重试,结束即允许下一次网络事件再触发
|
||||
isRetryingNetworkError = false;
|
||||
// 初始化成功,启动心跳调度(应用生命周期内保持活跃)
|
||||
AsHeartBeatManager.getInstance().start(getApplicationContext());
|
||||
|
||||
@@ -619,6 +789,8 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
|
||||
@Override
|
||||
public void onFail(int errorCode, String message) {
|
||||
// 重试回调与初次回调共用同一入口,统一清标志
|
||||
isRetryingNetworkError = false;
|
||||
MainActivity.this.onFail(errorCode, message);
|
||||
// runOnUiThread(()->{
|
||||
// deviceNotActivatedView.setErrorInfo(String.valueOf(errorCode));
|
||||
@@ -704,6 +876,8 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
// }
|
||||
// }
|
||||
AppManager.getAppManager().finishActivity(this);
|
||||
// 反注册网络变化广播
|
||||
unregisterNetworkMonitor();
|
||||
if(asApiRequester != null){
|
||||
asApiRequester.onDestroy();
|
||||
}
|
||||
@@ -796,7 +970,7 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFail(String message) {
|
||||
public void onFail(int errorCode, String message) {
|
||||
handleSdkInitFailure(thirdCardNo, retryCount, "init_fail", message);
|
||||
}
|
||||
}
|
||||
@@ -884,6 +1058,20 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
public void onActivated(AsApiRequester.CheckBenefitsResult result) {
|
||||
hideNetworkErrorFullscreen();
|
||||
dismissLoadingDialog();
|
||||
// 家用版:激活成功后直接进首页,不弹激活成功 dialog、不展示激活界面。
|
||||
// thirdCardNo 为空是合法终态(与 as8Scan/asAScan line 1069–1083 一致,
|
||||
// 此处不展示"缺少设备码"提示且不调用 initJetsenxSdk,否则下游依赖
|
||||
// JetsenxSdkUtil.cachedUid 的代码(如 IndexActivity 视频初始化)会拿到空 uid)。
|
||||
if (isHomeEdition) {
|
||||
if (result != null && !TextUtils.isEmpty(result.thirdCardNo)) {
|
||||
AppConfiguration.getInstance().setExpireTime(result.expireTime);
|
||||
JetsenxSdkUtil.cacheReusableUid(result.thirdCardNo);
|
||||
initJetsenxSdkWithRetry(result.thirdCardNo, 0);
|
||||
}
|
||||
LogUtils.d(TAG, "home edition onActivated, route to home");
|
||||
goToActivity();
|
||||
return;
|
||||
}
|
||||
if(result != null && !TextUtils.isEmpty(result.thirdCardNo)) {
|
||||
|
||||
AppConfiguration.getInstance().setExpireTime(result.expireTime);
|
||||
@@ -939,8 +1127,20 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCodeActivated(AsApiRequester.CheckBenefitsResult result) {
|
||||
// 10900(已通过激活码激活):onActivated 内已按 result.code 特判 10900 分支,委托复用
|
||||
onActivated(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPreset(AsApiRequester.CheckBenefitsResult result) {
|
||||
// 家用版:未激活(预置)状态 → 静默调用家用版激活码激活,不展示激活界面
|
||||
if (isHomeEdition) {
|
||||
LogUtils.d(TAG, "home edition onPreset: trigger silent activation");
|
||||
onHomeNonActivatedState(result);
|
||||
return;
|
||||
}
|
||||
showActivationLoadingDialog(getString(R.string.common_loading_default_text));
|
||||
// 设备处于预置状态(未激活):仅展示 loading,由后续异常回调(onActivateFailed/onUnbound 等)
|
||||
// 或用户主动提交激活码触发真正的激活流程;此处不再自动调用 tryAutoActivateFromIntent
|
||||
@@ -948,6 +1148,11 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
|
||||
@Override
|
||||
public void onActivating(AsApiRequester.CheckBenefitsResult result) {
|
||||
// 家用版:激活中 → 静默,不展示激活 loading
|
||||
if (isHomeEdition) {
|
||||
LogUtils.d(TAG, "home edition onActivating: silent");
|
||||
return;
|
||||
}
|
||||
String loadingMessage = result == null || StringUtils.isEmpty(result.msg)
|
||||
? getString(R.string.common_loading_default_text)
|
||||
: result.msg;
|
||||
@@ -958,6 +1163,14 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
@Override
|
||||
public void onActivateFailed(AsApiRequester.CheckBenefitsResult result) {
|
||||
hideNetworkErrorFullscreen();
|
||||
// 家用版:激活失败 → 静默激活一次或降级网络错误屏,不展示激活界面。
|
||||
// onActivateFailed 是家用版静默激活的"终态失败"回调,清掉 in-flight 标记
|
||||
// 让 onHomeNonActivatedState 把降级全屏的决策接管过去。
|
||||
if (isHomeEdition) {
|
||||
isSilentActivationInFlight = false;
|
||||
onHomeNonActivatedState(result);
|
||||
return;
|
||||
}
|
||||
runOnUiThread(()->{
|
||||
dismissLoadingDialog();
|
||||
deviceNotActivatedView.setErrorInfo(String.valueOf(result.code));
|
||||
@@ -978,6 +1191,11 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
@Override
|
||||
public void onUnbound(AsApiRequester.CheckBenefitsResult result) {
|
||||
hideNetworkErrorFullscreen();
|
||||
// 家用版:未绑定 → 静默激活一次或降级网络错误屏,不展示激活界面
|
||||
if (isHomeEdition) {
|
||||
onHomeNonActivatedState(result);
|
||||
return;
|
||||
}
|
||||
runOnUiThread(()->{
|
||||
dismissLoadingDialog();
|
||||
deviceNotActivatedView.setErrorInfo(String.valueOf(result.code));
|
||||
@@ -997,6 +1215,11 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
LogUtils.d(TAG, "onExpired checkBenefits code=" + (result == null ? "null" : result.code)
|
||||
+ ", cardNos already cleared by AsApiRequester dispatcher");
|
||||
hideNetworkErrorFullscreen();
|
||||
// 家用版:过期 → 静默激活一次或降级网络错误屏,不展示激活界面
|
||||
if (isHomeEdition) {
|
||||
onHomeNonActivatedState(result);
|
||||
return;
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
dismissLoadingDialog();
|
||||
deviceNotActivatedView.setErrorInfo(String.valueOf(result.code));
|
||||
@@ -1018,6 +1241,11 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
LogUtils.d(TAG, "onOccupied checkBenefits code=" + (result == null ? "null" : result.code)
|
||||
+ ", cardNos already cleared by AsApiRequester dispatcher");
|
||||
hideNetworkErrorFullscreen();
|
||||
// 家用版:占用 → 静默激活一次或降级网络错误屏,不展示激活界面
|
||||
if (isHomeEdition) {
|
||||
onHomeNonActivatedState(result);
|
||||
return;
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
dismissLoadingDialog();
|
||||
deviceNotActivatedView.setErrorInfo(String.valueOf(result.code));
|
||||
@@ -1039,6 +1267,11 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
LogUtils.d(TAG, "onUnknown checkBenefits code=" + (result == null ? "null" : result.code)
|
||||
+ ", invalid card status (expired/occupied/code-activated) cardNos already cleared by dispatcher");
|
||||
hideNetworkErrorFullscreen();
|
||||
// 家用版:未知状态 → 静默激活一次或降级网络错误屏,不展示激活界面
|
||||
if (isHomeEdition) {
|
||||
onHomeNonActivatedState(result);
|
||||
return;
|
||||
}
|
||||
|
||||
runOnUiThread(() -> {
|
||||
dismissLoadingDialog();
|
||||
@@ -1053,11 +1286,18 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
|
||||
@Override
|
||||
public void onFail(int errorCode, String message) {
|
||||
if (errorCode == 0) {
|
||||
// 1007 = 网络错误(无网络/无法访问服务器/超时,message 已是友好文案):
|
||||
// 拉起全屏网络错误页(含退出按钮),避免网络问题被误提示为"激活失败"
|
||||
if (errorCode == 0 || errorCode == AsApiErrorCode.ERROR_NETWORK.getCode()) {
|
||||
showNetworkErrorFullscreen(message);
|
||||
return;
|
||||
}
|
||||
hideNetworkErrorFullscreen();
|
||||
// 家用版:激活相关失败不弹激活 UI,统一降级到网络错误屏
|
||||
if (isHomeEdition) {
|
||||
showHomeActivationFailure(message);
|
||||
return;
|
||||
}
|
||||
runOnUiThread(()->{
|
||||
dismissLoadingDialog();
|
||||
deviceNotActivatedView.setErrorInfo(String.valueOf(errorCode));
|
||||
@@ -1118,9 +1358,72 @@ public class MainActivity extends AppCompatActivity implements AsApiRequester.Ch
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册网络变化广播:监听 CONNECTIVITY_CHANGE / WIFI_STATE_CHANGED,
|
||||
* 用于 init 网络错误全屏页期间的网络恢复自动重试。
|
||||
*
|
||||
* 与 BaseActivity 的注册方式保持一致(含 SDK 33+ 的 RECEIVER_NOT_EXPORTED 标记)。
|
||||
*/
|
||||
private void registerNetworkMonitor() {
|
||||
networkMonitor = new NetworkMonitor();
|
||||
networkMonitor.setNetworkNativeReceiver(this);
|
||||
IntentFilter filter = new IntentFilter();
|
||||
filter.addAction("android.net.conn.CONNECTIVITY_CHANGE");
|
||||
filter.addAction("android.net.wifi.WIFI_STATE_CHANGED");
|
||||
filter.addAction("android.net.wifi.STATE_CHANGE");
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(networkMonitor, filter, Context.RECEIVER_NOT_EXPORTED);
|
||||
} else {
|
||||
registerReceiver(networkMonitor, filter);
|
||||
}
|
||||
}
|
||||
|
||||
private void unregisterNetworkMonitor() {
|
||||
if (networkMonitor != null) {
|
||||
try {
|
||||
unregisterReceiver(networkMonitor);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// 极少数情况:onDestroy 前已 unregister,忽略避免崩溃
|
||||
LogUtils.d(TAG, "networkMonitor already unregistered");
|
||||
}
|
||||
networkMonitor = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 网络状态回调(CONNECTIVITY_CHANGE 广播触发,主线程)。
|
||||
*
|
||||
* 行为:
|
||||
* - 网络可用且全屏网络错误页正显示中(说明 init 刚因网络失败)→ 自动触发重试
|
||||
* - 正在重试 in-flight → 忽略本次事件(防重入)
|
||||
* - 非"unknown/none"视为可用(覆盖 wifi/mobile/ethernet/vpn/other 五种)
|
||||
*/
|
||||
@Override
|
||||
public void currentNetType(String netType) {
|
||||
LogUtils.d(TAG, "currentNetType:" + netType);
|
||||
boolean connected = !"unknown".equals(netType) && !"none".equals(netType);
|
||||
if (connected && isNetworkErrorFullscreen && !isRetryingNetworkError) {
|
||||
// 全屏错误页展示期间收到网络恢复 → 自动重试 init
|
||||
isRetryingNetworkError = true;
|
||||
hideNetworkErrorFullscreen();
|
||||
showActivationLoadingDialog("正在重新连接…");
|
||||
LogUtils.d(TAG, "network restored, auto-retry checkUpgradeThenMembership");
|
||||
checkUpgradeThenMembership();
|
||||
}
|
||||
}
|
||||
|
||||
private void exitApp() {
|
||||
AppManager.getAppManager().setKeep(false);
|
||||
// setKeep(true) 让 AppManager.appExit() 走"仅 finishAllActivity"分支,
|
||||
// 不延迟 1s、不 killProcess;主进程降为无 UI 后台但保留内存态,
|
||||
// :voice 进程与 FGS 不受影响,用户退出后仍能接收 OEM 语音广播(见 §4.4)。
|
||||
// 脆弱点守卫:setKeep(true) 与 appExit() 之间禁止插入任何异步。
|
||||
// isKeep 是 AppManager 单例状态;本次执行完后立即 reset 回 false,避免污染
|
||||
// 后续其它 appExit() 调用方(BaseActivity / IndexActivity / DownApkDialog 等)
|
||||
// 的进程语义。env 切换(changeToPreview/changeToDebug)与 BenefitRevokedDialog.exitApp
|
||||
// 仍走旧路径——见设计文档 §4.4。
|
||||
AppManager.getAppManager().setKeep(true);
|
||||
AppManager.getAppManager().appExit();
|
||||
AppManager.getAppManager().setKeep(false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
64
app/src/main/java/com/pukka/jiecheng/MyApplication.java
Executable file → Normal file
64
app/src/main/java/com/pukka/jiecheng/MyApplication.java
Executable file → Normal file
@@ -1,10 +1,13 @@
|
||||
|
||||
package com.pukka.jiecheng;
|
||||
|
||||
import android.app.ActivityManager;
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
|
||||
import androidx.lifecycle.ProcessLifecycleOwner;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.bytedance.boost_multidex.BoostMultiDex;
|
||||
import com.facebook.drawee.backends.pipeline.Fresco;
|
||||
@@ -14,6 +17,9 @@ import com.pukka.jiecheng.device.DeviceIdentityInitManager;
|
||||
import com.pukka.jiecheng.utils.ActivityMonitor;
|
||||
import com.pukka.jiecheng.utils.BuglyInitManager;
|
||||
import com.pukka.jiecheng.utils.CrashHandler;
|
||||
import com.pukka.jiecheng.voice.receiver.KeepAlivePingReceiver;
|
||||
import com.pukka.jiecheng.voice.receiver.VoiceInternalReceiver;
|
||||
import com.pukka.jiecheng.voice.service.VoiceKeepAliveService;
|
||||
import com.pukka.libcore.fresco.Tls12SocketFactory;
|
||||
import com.pukka.libcore.fresco.TrustAllManager;
|
||||
import com.pukka.libcore.utils.AppLifecycleObserver;
|
||||
@@ -31,6 +37,7 @@ import javax.net.ssl.TrustManager;
|
||||
*/
|
||||
public class MyApplication extends Application {
|
||||
private static final String PROCESS_NAME_MAIN = "tv.huashi.jetsenx.jetsenfilx.jsttv";
|
||||
private static final String VOICE_PROCESS_SUFFIX = ":voice";
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
@@ -59,12 +66,67 @@ public class MyApplication extends Application {
|
||||
|
||||
// 网络层初始化(必须在 Fresco.initialize 之前,避免首签请求受 SSL 状态影响)
|
||||
initNetworkLayer();
|
||||
// 跨进程语音命令接收器::voice 进程 SkyworthVoiceAdapter 投递 INTERNAL_FORWARD 时由本接收器处理
|
||||
registerVoiceInternalReceiver();
|
||||
// 兜底拉起 :voice 进程 FGS,覆盖桌面图标 cold start / appExit 后再次进入 / force-stop 后首次启动场景
|
||||
ensureVoiceKeepAliveRunning(this);
|
||||
//preInitWebCore();
|
||||
//LogUtils.d("onCreate: channel_id : "+ AppConfiguration.getInstance().getChannelId()+ " isNeadMy : "+ AppConfiguration.getInstance().isNeedMy() + " control(Touch): "+AppConfiguration.getInstance().isTouchMode());
|
||||
}
|
||||
Fresco.initialize(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态注册主进程语音命令接收器。
|
||||
* 接收 :voice 进程 {@link com.pukka.jiecheng.voice.adapter.SkyworthVoiceAdapter}
|
||||
* 投递的 {@link VoiceInternalReceiver#ACTION_VOICE_FORWARD} 显式广播。
|
||||
*
|
||||
* <p>OEM 创维广播({@code ACTION_VOD})的注册已不在此处——移到 :voice 进程
|
||||
* {@link VoiceKeepAliveService} 的 FGS 内动态注册,避免主进程死亡时丢失接收能力。
|
||||
*/
|
||||
private void registerVoiceInternalReceiver() {
|
||||
try {
|
||||
IntentFilter filter = new IntentFilter(VoiceInternalReceiver.ACTION_VOICE_FORWARD);
|
||||
registerReceiver(new VoiceInternalReceiver(), filter);
|
||||
LogUtils.d("onCreate: voice internal receiver registered");
|
||||
} catch (Throwable t) {
|
||||
LogUtils.e("onCreate: voice internal receiver register failed: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 兜底拉起 :voice 进程 FGS:仅当 :voice 进程不存在时启动,避免通知闪烁。
|
||||
*
|
||||
* <p>触发场景:
|
||||
* <ul>
|
||||
* <li>桌面图标 cold start(开机后用户首次点开 App)</li>
|
||||
* <li>appExit() 后再次进入(主进程降为后台但 :voice 已被 LMK 杀掉)</li>
|
||||
* <li>force-stop 后用户首次启动</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>BOOT_COMPLETED 拉起由 {@link com.pukka.jiecheng.voice.receiver.BootCompletedReceiver}
|
||||
* 负责;本方法只在 MyApplication.onCreate 时(即用户进 App 的时刻)兜底。
|
||||
*
|
||||
* <p>另外通过 {@link KeepAlivePingReceiver#scheduleFirstPing} 注册 5s 后的自广播 ping,
|
||||
* 保证即便 BOOT_COMPLETED 主路径失败,本兜底也失败的情况下,仍有第三层保险接力。
|
||||
*/
|
||||
private void ensureVoiceKeepAliveRunning(Context ctx) {
|
||||
try {
|
||||
ActivityManager am = (ActivityManager) ctx.getSystemService(Context.ACTIVITY_SERVICE);
|
||||
if (am == null) return;
|
||||
String voiceProcess = ctx.getPackageName() + VOICE_PROCESS_SUFFIX;
|
||||
for (ActivityManager.RunningAppProcessInfo p : am.getRunningAppProcesses()) {
|
||||
if (voiceProcess.equals(p.processName)) return;
|
||||
}
|
||||
ContextCompat.startForegroundService(ctx, new Intent(ctx, VoiceKeepAliveService.class));
|
||||
LogUtils.d("onCreate: voice keepalive service start requested");
|
||||
// 第三层兜底:5s 后 KeepAlivePingReceiver 自广播接力(即便上面失败也接续)
|
||||
KeepAlivePingReceiver.scheduleFirstPing(ctx);
|
||||
} catch (Throwable t) {
|
||||
LogUtils.e("onCreate: voice keepalive service start failed: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化脚手架新网络层(Retrofit + OkHttp + Moshi + Coroutines)。
|
||||
*
|
||||
|
||||
69
app/src/main/java/com/pukka/jiecheng/TestActivity.java
Executable file → Normal file
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.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.pukka.jiecheng.order.OrderActivity;
|
||||
import com.pukka.jiecheng.request.AsApiRequester;
|
||||
import com.pukka.jiecheng.request.AsHeartBeatManager;
|
||||
import com.pukka.libcore.utils.LogUtils;
|
||||
|
||||
/**
|
||||
* @file TestActivity.java
|
||||
* @description 用于测试的 Activity,包含跳转到 MainActivity 的按钮,以实现简单的联调和入口测试
|
||||
* @description 用于测试的 Activity:进入页面即触发首签拿到 sign,启动心跳;
|
||||
* 然后通过按钮跳转到 OrderActivity,验证 sign 已自动同步到后续接口
|
||||
* @author ainetv
|
||||
* @date 2026-03-20
|
||||
*/
|
||||
public class TestActivity extends AppCompatActivity {
|
||||
|
||||
private static final String TAG = "TestActivity";
|
||||
|
||||
/**
|
||||
* AsApiRequester 单例(Kotlin object),由 @JvmStatic 暴露给 Java 端调用。
|
||||
* 与 MainActivity 保持一致:业务侧持有单例引用,避免每次现取。
|
||||
*/
|
||||
private final AsApiRequester asApiRequester = AsApiRequester.INSTANCE;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
@@ -25,9 +39,60 @@ public class TestActivity extends AppCompatActivity {
|
||||
btnGoToMain.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
Intent intent = new Intent(TestActivity.this, MainActivity.class);
|
||||
Intent intent = new Intent(TestActivity.this, OrderActivity.class);
|
||||
startActivity(intent);
|
||||
}
|
||||
});
|
||||
|
||||
// 进入页面即触发首签:拿到 sign 后才允许后续接口访问,否则 OrderActivity
|
||||
// 等业务页面的请求都会因缺 sign 被服务端 401
|
||||
triggerInitAndStartHeartBeat();
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发首签接口,并在成功后启动心跳调度。
|
||||
*
|
||||
* <p>sign 同步链路(关键,整个项目都依赖这条路径):</p>
|
||||
* <ol>
|
||||
* <li>requestAsInitDeviceApi 成功后,AsApiRequester 内部调用
|
||||
* {@code Network.tokenStore.refreshAndPersist(sign)}:</li>
|
||||
* <ul>
|
||||
* <li>写入 TokenStore(SpTokenStore,内存 + SP 异步)—— HeaderInterceptor 每次发请求都从这里读</li>
|
||||
* <li>同步写入 SharedPreferenceUtils(key="as_sign")—— 兼容旧 Java 代码直接读 SP</li>
|
||||
* </ul>
|
||||
* <li>HeaderInterceptor.intercept() 在每个 OkHttp 请求发出前注入 {@code sign} 请求头
|
||||
* (首签/刷新自身接口除外),所以从 TestActivity 跳转到 OrderActivity 后,
|
||||
* OrderActivity 发起的任意接口(订单列表、扫码等)都会自动带上正确的 sign,
|
||||
* 无需在跳转处做任何额外传参。</li>
|
||||
* <li>AsHeartBeatManager 内部的心跳请求也走同一套 HeaderInterceptor,
|
||||
* 因此心跳能持续携带有效 sign;服务端后续若在心跳响应里下发新的 sign
|
||||
* ({@code refreshSign} 等命令),TokenStore 也会被同步刷新,下一次请求自动接力。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>心跳启动必须用 getApplicationContext():AsHeartBeatManager 内部用
|
||||
* DispatchQueue.global.timer 启动了一个常驻定时器(应用生命周期内保持活跃),
|
||||
* Activity context 会泄漏,applicationContext 安全。</p>
|
||||
*/
|
||||
private void triggerInitAndStartHeartBeat() {
|
||||
asApiRequester.requestAsInitDeviceApi(this, new AsApiRequester.InitCallback() {
|
||||
@Override
|
||||
public void onSuccess(AsApiRequester.InitResult result) {
|
||||
// 初始化成功,启动心跳调度(应用生命周期内保持活跃)
|
||||
// 心跳本身也带 sign(HeaderInterceptor 自动注入),并在响应里可下发控制命令
|
||||
AsHeartBeatManager.getInstance().start(getApplicationContext());
|
||||
|
||||
String signPreview = (result != null && result.sign != null && !result.sign.isEmpty())
|
||||
? result.sign.substring(0, Math.min(8, result.sign.length())) + "…"
|
||||
: "null";
|
||||
LogUtils.i(TAG, "init success, sign=" + signPreview + ", roomNo=" + result.roomNo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFail(int errorCode, String message) {
|
||||
// 首签失败:心跳无法启动(缺 sign 触发前置校验 1001),OrderActivity 跳转后
|
||||
// 所有接口都会 401。仅打日志,不静默吞掉——调试页应该把失败暴露给开发者。
|
||||
LogUtils.e(TAG, "requestAsInitDeviceApi fail, code=" + errorCode + ", msg=" + message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
* 4. 提供酒店类型获取 (getHotelType)
|
||||
* 5. 提供是否显示我的页面配置 (isNeedMy)
|
||||
* 6. 提供家用版判断 (isHomeEdition)
|
||||
*
|
||||
* - 屏蔽底层 BuildConfig 细节,解耦业务逻辑。
|
||||
* - 统一入口,便于未来修改配置源。
|
||||
@@ -108,7 +109,7 @@ public class AppConfiguration {
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否需要显示"我的"页面
|
||||
* 是否需要显示“我的”页面
|
||||
* @return true: 显示; false: 隐藏
|
||||
*/
|
||||
public boolean isNeedMy() {
|
||||
@@ -116,11 +117,21 @@ public class AppConfiguration {
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为第三方变体
|
||||
* @return true: thirdparty flavor; false: 其他渠道
|
||||
* 是否为家用版(home edition)
|
||||
* @return true: 家用版; false: 商用版
|
||||
*/
|
||||
public boolean isThirdPartyVariant() {
|
||||
return BuildConfig.THIRDPARTY_VARIANT;
|
||||
public boolean isHomeEdition() {
|
||||
return BuildConfig.IS_HOME_EDITION;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为行业版(商用版中非扫码变体):aishi / ruigeer / ceshi 等。
|
||||
* 行业版语义上必须先激活,checkBenefits 业务码只能是 10200;落到 10900 / 10300-10800
|
||||
* 视为后端异常,由 {@link com.pukka.jiecheng.video.VideoDetailActivity}
|
||||
* 走 showEntitlementError 错误视图,不引导付费。
|
||||
*/
|
||||
public boolean isIndustryEdition() {
|
||||
return !isHomeEdition() && !BuildConfig.SCAN_VARIANT;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,6 +139,10 @@ public class AppConfiguration {
|
||||
* @return 心跳请求间隔周期 (毫秒)
|
||||
*/
|
||||
public long getHeartBeatInterval() {
|
||||
// Debug环境下心跳间隔10秒,其他环境使用默认5分钟
|
||||
if (!isRelease()) {
|
||||
return 30000L;
|
||||
}
|
||||
return HEART_BEAT_INTERVAL;
|
||||
}
|
||||
|
||||
|
||||
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.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import com.pukka.jiecheng.R
|
||||
import com.pukka.jiecheng.base.BaseActivity
|
||||
import com.pukka.jiecheng.config.AppConfiguration
|
||||
import com.pukka.jiecheng.utils.Surround51Manager
|
||||
import com.pukka.jiecheng.utils.ToastUtils
|
||||
import com.pukka.libcore.base.BaseConst
|
||||
import com.pukka.libcore.boxShared.BoxSharedParamsUtils
|
||||
@@ -37,7 +39,10 @@ class DeviceActivity : BaseActivity() {
|
||||
var mTvMac: TextView? = null
|
||||
var mTvSn: TextView? = null
|
||||
var mTvExpireTime: TextView? = null
|
||||
var mClCheckUpdate: ConstraintLayout? = null
|
||||
var mTvAudioPath: TextView? = null
|
||||
var mLlSurround51: LinearLayout? = null
|
||||
var mTvSurround51Value: TextView? = null
|
||||
var mClConfirm: ConstraintLayout? = null
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_device)
|
||||
@@ -68,20 +73,40 @@ class DeviceActivity : BaseActivity() {
|
||||
mTvExpireTime!!.text = "暂无数据"
|
||||
}
|
||||
|
||||
mClCheckUpdate = findViewById<ConstraintLayout>(R.id.id_cl_check_update)
|
||||
// 音频能力检测:AC3直通 > 平台硬解 > FFmpeg软解
|
||||
mTvAudioPath = findViewById<TextView>(R.id.id_tv_audio_path)
|
||||
mTvAudioPath!!.text = Surround51Manager.getAudioPathDesc(this)
|
||||
// 5.1 环绕声播放开关:关闭时播放普通 2.0 档
|
||||
mLlSurround51 = findViewById<LinearLayout>(R.id.id_ll_surround51)
|
||||
mTvSurround51Value = findViewById<TextView>(R.id.id_tv_surround51_value)
|
||||
updateSurround51Value()
|
||||
mLlSurround51?.setOnClickListener {
|
||||
Surround51Manager.set51Enabled(!Surround51Manager.is51Enabled())
|
||||
updateSurround51Value()
|
||||
ToastUtils.showShortToastSafe(
|
||||
if (Surround51Manager.is51Enabled()) "已开启 5.1 环绕声,重新进入播放生效" else "已关闭 5.1 环绕声,播放普通 2.0 声道"
|
||||
)
|
||||
}
|
||||
|
||||
mClConfirm = findViewById<ConstraintLayout>(R.id.id_cl_confirm)
|
||||
initGamePad()
|
||||
enableBackButton()
|
||||
}
|
||||
|
||||
private fun updateSurround51Value() {
|
||||
mTvSurround51Value?.text = if (Surround51Manager.is51Enabled()) "开" else "关"
|
||||
}
|
||||
|
||||
private fun initListener() {
|
||||
// mClCheckUpdate!!.setOnClickListener(View.OnClickListener { v: View? ->
|
||||
// // TODO: 2025/6/10 APK更新
|
||||
//// DownApkDialog(this).initDown2()
|
||||
//
|
||||
// //检测更新
|
||||
// // TODO: 2025/6/10 APK更新
|
||||
//// new DownApkDialog(getActivity()).initDown2();
|
||||
mClConfirm?.setOnClickListener(View.OnClickListener { v: View? ->
|
||||
finish()
|
||||
// TODO: 2025/6/10 APK更新
|
||||
// DownApkDialog(this).initDown2()
|
||||
|
||||
//检测更新
|
||||
// TODO: 2025/6/10 APK更新
|
||||
// new DownApkDialog(getActivity()).initDown2();
|
||||
// ToastUtils.showLongBottomToast("暂无新版本")
|
||||
// })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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.utils.LogUtils;
|
||||
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Locale;
|
||||
|
||||
@@ -87,7 +87,7 @@ public class DeviceIdentityProvider {
|
||||
return "";
|
||||
}
|
||||
MessageDigest digest = MessageDigest.getInstance("MD5");
|
||||
byte[] hashBytes = digest.digest(source.getBytes(Charset.forName("UTF-8")));
|
||||
byte[] hashBytes = digest.digest(source.getBytes(StandardCharsets.UTF_8));
|
||||
int usedBytes = Math.min(MD5_TRUNCATED_BYTES, hashBytes.length);
|
||||
if (usedBytes <= 0) {
|
||||
return "";
|
||||
@@ -112,7 +112,7 @@ public class DeviceIdentityProvider {
|
||||
return "";
|
||||
}
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hashBytes = digest.digest(source.getBytes(Charset.forName("UTF-8")));
|
||||
byte[] hashBytes = digest.digest(source.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder hexBuilder = new StringBuilder(hashBytes.length * 2);
|
||||
for (byte hashByte : hashBytes) {
|
||||
hexBuilder.append(String.format(Locale.ROOT, "%02X", hashByte));
|
||||
|
||||
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("");
|
||||
// }
|
||||
String info = ContentDetailUtils.getProgramInformation(contentInfoChooseList.get(position).getContenttype(),
|
||||
contentInfoChooseList.get(position).getEpisodecount());
|
||||
contentInfoChooseList.get(position).getEpisodeindex());
|
||||
itemHolder.mask.setVisibility(TextUtils.isEmpty(info) ? View.GONE : View.VISIBLE);
|
||||
itemHolder.id_tv_playtime.setText(info);
|
||||
|
||||
|
||||
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
269
app/src/main/java/com/pukka/jiecheng/index/IndexActivity.java
Executable file → Normal file
269
app/src/main/java/com/pukka/jiecheng/index/IndexActivity.java
Executable file → Normal file
@@ -1,6 +1,7 @@
|
||||
|
||||
package com.pukka.jiecheng.index;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
@@ -37,12 +38,14 @@ import com.pukka.jiecheng.index.bean.NavResideBean;
|
||||
import com.pukka.jiecheng.index.dialog.BackImgDialog;
|
||||
import com.pukka.jiecheng.index.dialog.FullimgDialog;
|
||||
import com.pukka.jiecheng.index.fragment.ContentFragment;
|
||||
import com.pukka.jiecheng.index.fragment.HomeMyFragment;
|
||||
import com.pukka.jiecheng.index.fragment.HotelMyFragment;
|
||||
import com.pukka.jiecheng.index.fragment.MyFragment;
|
||||
import com.pukka.jiecheng.index.fragment.ShortVideoFragment;
|
||||
import com.pukka.jiecheng.index.viewmodel.IndexViewModel;
|
||||
import com.pukka.jiecheng.index.viewmodel.NavViewModel;
|
||||
import com.pukka.jiecheng.index.viewmodel.TopViewModel;
|
||||
import com.pukka.jiecheng.login.WxMpLoginManager;
|
||||
import com.pukka.jiecheng.login.viewmodel.IsLoginViewModel;
|
||||
import com.pukka.jiecheng.newtv.NewTvListner;
|
||||
import com.pukka.jiecheng.newtv.NewTvUtils;
|
||||
@@ -54,7 +57,7 @@ import com.pukka.jiecheng.widget.HomeNavLinearLayoutManagerTv;
|
||||
import com.pukka.jiecheng.widget.TopView;
|
||||
import com.pukka.jiecheng.widget.VideoContainer;
|
||||
import com.pukka.jiecheng.widget.banner.Banner;
|
||||
import com.pukka.libcore.base.BuildConfig;
|
||||
import com.pukka.jiecheng.BuildConfig;
|
||||
import com.pukka.libcore.boxShared.BoxSharedParameters;
|
||||
import com.pukka.libcore.boxShared.BoxSharedParamsUtils;
|
||||
import com.pukka.libcore.threading.DispatchQueue;
|
||||
@@ -93,6 +96,8 @@ public class IndexActivity extends BaseActivity {
|
||||
|
||||
private MyFragment mMyFragment;
|
||||
private HotelMyFragment mHotelMyFragment;
|
||||
// 家用版(home flavor)专用"我的"Fragment:未登录态、隐藏 MAC
|
||||
private HomeMyFragment mHomeMyFragment;
|
||||
private ShortVideoFragment mShortVideoFragment;
|
||||
|
||||
private FilterFragment filterFragment;
|
||||
@@ -310,20 +315,80 @@ public class IndexActivity extends BaseActivity {
|
||||
return isContentLayoutReady;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为扫码版变体(as8Scan / asAScan)。
|
||||
* <p>判断依据:{@code BuildConfig.SCAN_VARIANT} 由 build.gradle 的 flavor 维度注入,
|
||||
* 扫码版自动置为 true。扫码版默认 tab 落「电影」+ 切 contentFragment;行业版默认 tab
|
||||
* 按 navName 包含"推荐"定位(接口 type 字段不可靠) + 动态切换对应 Fragment。</p>
|
||||
*/
|
||||
private boolean isScanVariant() {
|
||||
return BuildConfig.SCAN_VARIANT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行业版定位默认「推荐」tab:接口下发的 type 字段不可靠(如 navList[0]=「我的」type=2),
|
||||
* 直接遍历 navList 按 navName 包含"推荐"来定位(与扫码版找「电影」的方式一致)。
|
||||
*
|
||||
* <p>找不到时兜底回退到第 0 个 tab(与之前行为兼容)。</p>
|
||||
*/
|
||||
private NavFocusBean findDefaultNavIndex(List<GetLayoutNavBean.DataBean> navList) {
|
||||
NavFocusBean result = new NavFocusBean();
|
||||
if (ListUtils.isEmpty(navList)) {
|
||||
result.setIndex(0);
|
||||
result.setIndexSub(-1);
|
||||
return result;
|
||||
}
|
||||
for (int i = 0; i < navList.size(); i++) {
|
||||
GetLayoutNavBean.DataBean group = navList.get(i);
|
||||
List<GetLayoutNavBean.DataBean.SubNavDataBean> subList = group.getNavList();
|
||||
if (ListUtils.isEmpty(subList)) continue;
|
||||
boolean isSingle = TextUtils.isEmpty(group.getNavColor())
|
||||
&& TextUtils.isEmpty(group.getCornerUrl())
|
||||
&& subList.size() == 1;
|
||||
if (isSingle) {
|
||||
String navName = subList.get(0).getNavname();
|
||||
if (navName != null && navName.contains("推荐")) {
|
||||
result.setIndex(i);
|
||||
result.setIndexSub(-1);
|
||||
return result;
|
||||
}
|
||||
} else {
|
||||
for (int j = 0; j < subList.size(); j++) {
|
||||
String navName = subList.get(j).getNavname();
|
||||
if (navName != null && navName.contains("推荐")) {
|
||||
result.setIndex(i);
|
||||
result.setIndexSub(j);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 兜底:找不到「筛选」回退到 0
|
||||
result.setIndex(0);
|
||||
result.setIndexSub(-1);
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean applyInitialNavFocus() {
|
||||
if (mRvNav.getChildCount() <= 0 || mRvNav.getLayoutManager() == null) {
|
||||
return false;
|
||||
}
|
||||
NavFocusBean firstFocusBean = mIndexNavAdapter.getFistFocusIndex(mediaId);
|
||||
View focusView = mRvNav.getLayoutManager().findViewByPosition(firstFocusBean.getIndex());
|
||||
// 默认落焦位置由 registerTopNavDataObserver 按渠道设置:扫码版→「电影」,行业版→「推荐」
|
||||
DefaultNavFocusBean defaultBean = mNavViewModel.defaultFocusIndex.getValue();
|
||||
if (defaultBean == null || defaultBean.getDefaultIndex() < 0) {
|
||||
return false;
|
||||
}
|
||||
int focusIndex = defaultBean.getDefaultIndex();
|
||||
int focusIndexSub = defaultBean.getDefaultIndexSub();
|
||||
View focusView = mRvNav.getLayoutManager().findViewByPosition(focusIndex);
|
||||
if (focusView == null) {
|
||||
mRvNav.getLayoutManager().scrollToPosition(firstFocusBean.getIndex());
|
||||
mRvNav.getLayoutManager().scrollToPosition(focusIndex);
|
||||
mRvNav.post(mFocusDispatcher::attemptFocus);
|
||||
return false;
|
||||
}
|
||||
RecyclerView recyclerView = focusView.findViewById(R.id.id_rv_sub_nav);
|
||||
if (recyclerView != null && recyclerView.getLayoutManager() != null) {
|
||||
View focusViewSub = recyclerView.getLayoutManager().findViewByPosition(firstFocusBean.getIndexSub());
|
||||
if (recyclerView != null && recyclerView.getLayoutManager() != null && focusIndexSub >= 0) {
|
||||
View focusViewSub = recyclerView.getLayoutManager().findViewByPosition(focusIndexSub);
|
||||
if (focusViewSub != null) {
|
||||
focusViewSub.requestFocus();
|
||||
mTopView.mIvLogo.setFocusable(false);
|
||||
@@ -437,6 +502,43 @@ public class IndexActivity extends BaseActivity {
|
||||
return false;
|
||||
}
|
||||
|
||||
private View findNavResideView() {
|
||||
if (mRvNav.getLayoutManager() == null || mNavViewModel.resideIndex.getValue() == null) {
|
||||
return null;
|
||||
}
|
||||
int resideIndex = mNavViewModel.resideIndex.getValue().getResideIndex();
|
||||
int resideSubIndex = mNavViewModel.resideIndex.getValue().getResideIndexSub();
|
||||
View focusView = mRvNav.getLayoutManager().findViewByPosition(resideIndex);
|
||||
if (focusView == null) {
|
||||
return null;
|
||||
}
|
||||
if (resideSubIndex != -1) {
|
||||
RecyclerView subNavRv = focusView.findViewById(R.id.id_rv_sub_nav);
|
||||
if (subNavRv != null && subNavRv.getLayoutManager() != null) {
|
||||
View sub = subNavRv.getLayoutManager().findViewByPosition(resideSubIndex);
|
||||
if (sub != null) {
|
||||
return sub;
|
||||
}
|
||||
}
|
||||
}
|
||||
return focusView;
|
||||
}
|
||||
|
||||
private boolean isViewInside(View child, View ancestor) {
|
||||
if (child == null || ancestor == null) {
|
||||
return false;
|
||||
}
|
||||
View current = child;
|
||||
while (current != null) {
|
||||
if (current == ancestor) {
|
||||
return true;
|
||||
}
|
||||
android.view.ViewParent vp = current.getParent();
|
||||
current = vp instanceof View ? (View) vp : null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
@@ -571,28 +673,62 @@ public class IndexActivity extends BaseActivity {
|
||||
LogUtils.d("mNavList.size() == " + mNavList.size());
|
||||
mIndexNavAdapter.setLayoutNavList(mNavList);
|
||||
isNavDataReady = true;
|
||||
NavFocusBean firstFocusBean = mIndexNavAdapter.getFistFocusIndex(mediaId);
|
||||
// 扫码版:默认选中「电影」;行业版:默认选中「推荐」(type 字段不清晰,按 navName 兜底)
|
||||
NavFocusBean defaultFocus;
|
||||
if (isScanVariant()) {
|
||||
defaultFocus = mIndexNavAdapter.getFistFocusIndex(mediaId);
|
||||
} else {
|
||||
defaultFocus = findDefaultNavIndex(mNavList);
|
||||
}
|
||||
DefaultNavFocusBean defaultBean = mNavViewModel.defaultFocusIndex.getValue();
|
||||
if (defaultBean != null && defaultBean.getDefaultIndex() < 0) {
|
||||
defaultBean.setDefaultIndex(firstFocusBean.getIndex());
|
||||
defaultBean.setDefaultIndexSub(firstFocusBean.getIndexSub());
|
||||
defaultBean.setDefaultIndex(defaultFocus.getIndex());
|
||||
defaultBean.setDefaultIndexSub(defaultFocus.getIndexSub());
|
||||
}
|
||||
if (mNavList.size() == 1 && mNavList.get(0).getNavList().size() == 1) {
|
||||
dealOnlyOneTabUi(mNavList);
|
||||
} else {
|
||||
mRvNav.setVisibility(View.VISIBLE);
|
||||
// 根据默认 Tab 的 navType 和 navId 切换到对应 Fragment 并加载内容
|
||||
// (优先「电影」tab,找不到回退到第 0 个 tab)
|
||||
switchToDefaultFragmentByNavList(mNavList, firstFocusBean);
|
||||
if (isScanVariant()) {
|
||||
// 扫码版:按 defaultFocus(指向「电影」)切到对应 Fragment
|
||||
switchToDefaultFragmentByNavList(mNavList, defaultFocus);
|
||||
} else {
|
||||
// 行业版:按 defaultFocus(指向「推荐」)切到对应 Fragment
|
||||
switchToDefaultFragmentByNavList(mNavList, defaultFocus);
|
||||
}
|
||||
}
|
||||
mRvNav.postDelayed(() -> mFocusDispatcher.request(FOCUS_REQUEST_INITIAL), 500);
|
||||
if (isFirstLoad == 1) {
|
||||
isFirstLoad = 0;
|
||||
replayColdStartVoiceCommands();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 冷启动语音命令回放:导航就绪、首帧渲染完成后触发。
|
||||
* <p>
|
||||
* 设计要点(DIP 解耦):IndexActivity 属于 UI 层,voice 模块属于业务层,
|
||||
* 为避免相互 import 产生循环依赖,这里用反射调用
|
||||
* {@code VoiceCommandService.getInstance(context).replayColdStartQueue()},
|
||||
* 方法签名固定、零性能影响(仅首帧触发一次)。
|
||||
* </p>
|
||||
*/
|
||||
private void replayColdStartVoiceCommands() {
|
||||
try {
|
||||
Class<?> svcClz = Class.forName(
|
||||
"com.pukka.jiecheng.voice.service.VoiceCommandService");
|
||||
java.lang.reflect.Method getInstance = svcClz.getMethod(
|
||||
"getInstance", Context.class);
|
||||
Object svc = getInstance.invoke(null, IndexActivity.this);
|
||||
java.lang.reflect.Method replay = svcClz.getMethod("replayColdStartQueue");
|
||||
replay.invoke(svc);
|
||||
} catch (Throwable t) {
|
||||
LogUtils.w("replayColdStartQueue failed: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void dealOnlyOneTabUi(List<GetLayoutNavBean.DataBean> mNavList) {
|
||||
mRvNav.setVisibility(View.GONE);
|
||||
GetLayoutNavBean.DataBean.SubNavDataBean tempNavObj = mNavList.get(0).getNavList().get(0);
|
||||
@@ -653,9 +789,21 @@ public class IndexActivity extends BaseActivity {
|
||||
IndexAmberManager.navName = navName;
|
||||
LogUtils.d("default nav: name=" + navName + " navId=" + navId + " navType=" + navType);
|
||||
|
||||
if (navType == 6) {
|
||||
onNavTypeChanged(navType, i);
|
||||
} else {
|
||||
onTabSelected(i);
|
||||
}
|
||||
mFocusDispatcher.requestPosition(i, j);
|
||||
|
||||
if (navType == 2) {
|
||||
// 我的页面(电影不可能是该类型,保留以兼容后端特殊配置)
|
||||
if (BoxSharedParamsUtils.isJcHotel()) {
|
||||
// 家用版优先:home flavor 走专用 HomeMyFragment
|
||||
if (AppConfiguration.getInstance().isHomeEdition()) {
|
||||
if (null != mHomeMyFragment) {
|
||||
switchFragment(mHomeMyFragment);
|
||||
}
|
||||
} else if (BoxSharedParamsUtils.isJcHotel()) {
|
||||
switchFragment(mHotelMyFragment);
|
||||
} else if (mMyFragment != null) {
|
||||
mMyFragment.navId = mCurrentNavId;
|
||||
@@ -663,7 +811,6 @@ public class IndexActivity extends BaseActivity {
|
||||
}
|
||||
} else if (navType == 6) {
|
||||
// 筛选页
|
||||
onNavTypeChanged(navType, i);
|
||||
switchFragment(filterFragment);
|
||||
filterFragment.initPos();
|
||||
} else if (navType == 5 || navType == 7) {
|
||||
@@ -723,11 +870,22 @@ public class IndexActivity extends BaseActivity {
|
||||
ft.commit();
|
||||
filterFragment = FilterFragment.newInstance(true);
|
||||
currentFragment = mContentFragment;
|
||||
if (BoxSharedParamsUtils.isJcHotel()) {
|
||||
// 家用版优先:home flavor 走专用 HomeMyFragment(未登录态、隐藏 MAC)
|
||||
if (AppConfiguration.getInstance().isHomeEdition()) {
|
||||
mHomeMyFragment = HomeMyFragment.newInstance();
|
||||
} else if (BoxSharedParamsUtils.isJcHotel()) {
|
||||
mHotelMyFragment = HotelMyFragment.newInstance();
|
||||
} else {
|
||||
mMyFragment = MyFragment.newInstance();
|
||||
}
|
||||
// 家用版:监听扫码登录成功事件,刷新"我的"Tab 真实状态
|
||||
if (AppConfiguration.getInstance().isHomeEdition()) {
|
||||
WxMpLoginManager.INSTANCE.getLoginSuccessEvent().observe(this, success -> {
|
||||
if (Boolean.TRUE.equals(success) && null != mHomeMyFragment) {
|
||||
mHomeMyFragment.refreshUserState();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
mShortVideoFragment = ShortVideoFragment.newInstance();
|
||||
mBrowseFrameLayout = findViewById(R.id.id_nav_bfl);
|
||||
@@ -854,7 +1012,12 @@ public class IndexActivity extends BaseActivity {
|
||||
mediaId = mCurrentNavId;
|
||||
// 如果是我的页面,则切换到我的fragment上
|
||||
if (navType == 2) {
|
||||
if (BoxSharedParamsUtils.isJcHotel()) {
|
||||
// 家用版优先:home flavor 走专用 HomeMyFragment
|
||||
if (AppConfiguration.getInstance().isHomeEdition()) {
|
||||
if (null != mHomeMyFragment) {
|
||||
switchFragment(mHomeMyFragment);
|
||||
}
|
||||
} else if (BoxSharedParamsUtils.isJcHotel()) {
|
||||
switchFragment(mHotelMyFragment);
|
||||
} else {
|
||||
if (null != mMyFragment) {
|
||||
@@ -1038,24 +1201,34 @@ public class IndexActivity extends BaseActivity {
|
||||
mShortVideoFragment.setAdapterDirection(4);
|
||||
}
|
||||
}
|
||||
// BOTTOM 模板列表失去焦点,导航列表获取焦点
|
||||
// 模板的RecyclerView
|
||||
RecyclerView recyclerView = mContentFragment.getRvContent();
|
||||
if (recyclerView.getFocusedChild() != null && mRvNav.getFocusedChild() == null
|
||||
&& null != mRvNav.getLayoutManager() && null != mNavViewModel.resideIndex.getValue()) {
|
||||
if (mNavViewModel.resideIndex.getValue().getResideIndexSub() != -1) {
|
||||
View focusView = mRvNav.getLayoutManager()
|
||||
.findViewByPosition(mNavViewModel.resideIndex.getValue().getResideIndex());
|
||||
if (null != focusView) {
|
||||
RecyclerView subNavRv = focusView.findViewById(R.id.id_rv_sub_nav);
|
||||
if (null != subNavRv && null != subNavRv.getLayoutManager()) {
|
||||
return subNavRv.getLayoutManager()
|
||||
.findViewByPosition(mNavViewModel.resideIndex.getValue().getResideIndexSub());
|
||||
if (mRvNav.getFocusedChild() == null) {
|
||||
View navReside = findNavResideView();
|
||||
if (navReside != null) {
|
||||
ScrollView mySv = null;
|
||||
if (currentFragment == mMyFragment && mMyFragment != null) {
|
||||
mySv = mMyFragment.getScrollView();
|
||||
} else if (currentFragment == mHotelMyFragment && mHotelMyFragment != null) {
|
||||
mySv = mHotelMyFragment.getScrollView();
|
||||
}
|
||||
if (mySv != null) {
|
||||
if (!mySv.canScrollVertically(-1) && isViewInside(focused, mySv)) {
|
||||
return navReside;
|
||||
}
|
||||
}
|
||||
if (currentFragment == mHomeMyFragment && mHomeMyFragment != null) {
|
||||
View root = mHomeMyFragment.getView();
|
||||
if (root != null && !root.canScrollVertically(-1) && isViewInside(focused, root)) {
|
||||
return navReside;
|
||||
}
|
||||
}
|
||||
}
|
||||
return mRvNav.getLayoutManager()
|
||||
.findViewByPosition(mNavViewModel.resideIndex.getValue().getResideIndex());
|
||||
}
|
||||
RecyclerView recyclerView = mContentFragment.getRvContent();
|
||||
if (recyclerView.getFocusedChild() != null && mRvNav.getFocusedChild() == null) {
|
||||
View navReside = findNavResideView();
|
||||
if (navReside != null) {
|
||||
return navReside;
|
||||
}
|
||||
}
|
||||
|
||||
if ((null != mNavViewModel.resideIndex.getValue() && null != mNavViewModel.myIndex.getValue())
|
||||
@@ -1067,20 +1240,11 @@ public class IndexActivity extends BaseActivity {
|
||||
.getMyIndexSub()
|
||||
|| mNavViewModel.resideIndex.getValue().getResideIndexSub() == mNavViewModel.myIndex.getValue()
|
||||
.getMyIndex())
|
||||
&& mRvNav.getFocusedChild() == null && null != mRvNav.getLayoutManager()) {
|
||||
if (mNavViewModel.resideIndex.getValue().getResideIndexSub() != -1) {
|
||||
View focusView = mRvNav.getLayoutManager()
|
||||
.findViewByPosition(mNavViewModel.resideIndex.getValue().getResideIndex());
|
||||
if (null != focusView) {
|
||||
RecyclerView subNavRv = focusView.findViewById(R.id.id_rv_sub_nav);
|
||||
if (null != subNavRv && null != subNavRv.getLayoutManager()) {
|
||||
return subNavRv.getLayoutManager()
|
||||
.findViewByPosition(mNavViewModel.resideIndex.getValue().getResideIndexSub());
|
||||
}
|
||||
}
|
||||
&& mRvNav.getFocusedChild() == null) {
|
||||
View navReside = findNavResideView();
|
||||
if (navReside != null) {
|
||||
return navReside;
|
||||
}
|
||||
return mRvNav.getLayoutManager()
|
||||
.findViewByPosition(mNavViewModel.resideIndex.getValue().getResideIndex());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -1105,6 +1269,14 @@ public class IndexActivity extends BaseActivity {
|
||||
transaction.hide(currentFragment).show(targetFragment).commit();
|
||||
}
|
||||
currentFragment = targetFragment;
|
||||
// 家用版:首页 Tab 切换走 hide/show,Fragment.onResume 不会被触发。
|
||||
// 切到"我的"时主动调一次 refreshUserState(),确保扫码登录/退出登录等异步态变更
|
||||
// 落到 UI 上。
|
||||
if (AppConfiguration.getInstance().isHomeEdition()
|
||||
&& targetFragment == mHomeMyFragment
|
||||
&& mHomeMyFragment != null) {
|
||||
mHomeMyFragment.refreshUserState();
|
||||
}
|
||||
}
|
||||
|
||||
private static void releasePlayer() {
|
||||
@@ -1149,6 +1321,15 @@ public class IndexActivity extends BaseActivity {
|
||||
if (currentFragment == filterFragment) {
|
||||
filterFragment.requestFocusFirst();
|
||||
return super.onKeyDown(keyCode, event);
|
||||
} else if (currentFragment == mMyFragment && mMyFragment != null) {
|
||||
mMyFragment.requestFocusFirst();
|
||||
return super.onKeyDown(keyCode, event);
|
||||
} else if (currentFragment == mHotelMyFragment && mHotelMyFragment != null) {
|
||||
mHotelMyFragment.requestFocusFirst();
|
||||
return super.onKeyDown(keyCode, event);
|
||||
} else if (currentFragment == mHomeMyFragment && mHomeMyFragment != null) {
|
||||
mHomeMyFragment.requestFocusFirst();
|
||||
return super.onKeyDown(keyCode, event);
|
||||
}
|
||||
}
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == android.view.KeyEvent.KEYCODE_ENTER) {
|
||||
|
||||
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