diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 index c63206c..b83f4fe --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,13 @@ local.properties docs/* .agents/* .trae/* +.worktrees/ skills-lock.json *.log /CLAUDE.md .claude/* +.superpowers/* +.gitattributes +graphify-out/* +.vscode/ +.screenshots/ diff --git a/5.1声道接入成果总结.md b/5.1声道接入成果总结.md new file mode 100644 index 0000000..0605b21 --- /dev/null +++ b/5.1声道接入成果总结.md @@ -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 档位) | diff --git a/5.1声道改造-代码变更清单与复用指南.md b/5.1声道改造-代码变更清单与复用指南.md new file mode 100644 index 0000000..c01b207 --- /dev/null +++ b/5.1声道改造-代码变更清单与复用指南.md @@ -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 环绕声能力检测与播放开关。 + *
开关(默认开启)关闭时不选 5.1 独立档(H265+AC3_51),播放普通 2.0 档; + * 开启时按设备能力自动选择输出路径:AC3直通(HDMI bitstream) > 平台硬解(6ch LPCM) > FFmpeg软解(下混 2.0)。
+ *CDN 侧 5.1 档为单 AC3 音轨流(无 2.0 兜底音轨),故能力不足或开关关闭时必须回退普通档 URL。
+ */ +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:详见 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。 + * + *
两路来源: + *
复原后写入 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。 + * + *
状态清理规则(修复 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); } diff --git a/app/src/main/java/com/pukka/jiecheng/MyApplication.java b/app/src/main/java/com/pukka/jiecheng/MyApplication.java old mode 100755 new mode 100644 index 061af05..8310773 --- a/app/src/main/java/com/pukka/jiecheng/MyApplication.java +++ b/app/src/main/java/com/pukka/jiecheng/MyApplication.java @@ -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} 显式广播。 + * + *
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 进程不存在时启动,避免通知闪烁。 + * + *
触发场景: + *
BOOT_COMPLETED 拉起由 {@link com.pukka.jiecheng.voice.receiver.BootCompletedReceiver} + * 负责;本方法只在 MyApplication.onCreate 时(即用户进 App 的时刻)兜底。 + * + *
另外通过 {@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)。 * diff --git a/app/src/main/java/com/pukka/jiecheng/TestActivity.java b/app/src/main/java/com/pukka/jiecheng/TestActivity.java old mode 100755 new mode 100644 index 7f8f48f..404291f --- a/app/src/main/java/com/pukka/jiecheng/TestActivity.java +++ b/app/src/main/java/com/pukka/jiecheng/TestActivity.java @@ -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(); + } + + /** + * 触发首签接口,并在成功后启动心跳调度。 + * + *
sign 同步链路(关键,整个项目都依赖这条路径):
+ *心跳启动必须用 getApplicationContext():AsHeartBeatManager 内部用 + * DispatchQueue.global.timer 启动了一个常驻定时器(应用生命周期内保持活跃), + * Activity context 会泄漏,applicationContext 安全。
+ */ + 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); + } + }); } } diff --git a/app/src/main/java/com/pukka/jiecheng/ThirdPartyEntryActivity.java b/app/src/main/java/com/pukka/jiecheng/ThirdPartyEntryActivity.java deleted file mode 100644 index b1aa256..0000000 --- a/app/src/main/java/com/pukka/jiecheng/ThirdPartyEntryActivity.java +++ /dev/null @@ -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找不到时兜底回退到第 0 个 tab(与之前行为兼容)。
+ */ + private NavFocusBean findDefaultNavIndex(List+ * 设计要点(DIP 解耦):IndexActivity 属于 UI 层,voice 模块属于业务层, + * 为避免相互 import 产生循环依赖,这里用反射调用 + * {@code VoiceCommandService.getInstance(context).replayColdStartQueue()}, + * 方法签名固定、零性能影响(仅首帧触发一次)。 + *
+ */ + 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被以下两类入口复用,确保两条路径行为完全一致: + *
三步顺序执行: + *
容错策略:第 1 步失败(网络异常或服务端返回非 0)仅打日志,后续两步照常执行 — + * 本地状态必须与服务端"权益已撤销"语义一致,即使用户已确认退出也不会因为网络问题 + * 导致本地仍保留旧用户头像昵称。
+ * + *协程上下文:心跳回调跑在 [com.pukka.libcore.threading.DispatchQueue.global] 工作线程, + * 不绑定任何 Activity lifecycle;此处用 [GlobalScope] + [Dispatchers.IO] 包装, + * 不依赖 UI 生命周期,即使 Activity 已退也不影响执行。弹框与 `appExit()` 不等待网络结果。
+ */ +object HomeLogoutHandler { + + private const val TAG = "HomeLogoutHandler" + + /** + * 执行设备登出流程。立刻返回(网络请求异步进行), + * 不抛异常 — 所有失败情况仅写日志。 + */ + @JvmStatic + fun performDeviceLogout() { + GlobalScope.launch(Dispatchers.IO) { + try { + LogUtils.d(TAG, "performDeviceLogout: requesting device logout") + AsApiRequester.requestAsLogoutApi() + LogUtils.d(TAG, "performDeviceLogout: device logout api success") + } catch (e: java.lang.Exception) { + // 容错:网络异常 / 服务端错误码 — 仍继续本地清理, + // 与 HomeMyFragment.performLogout 的语义一致。 + // GlobalScope 没有父 Job 可取消,无需也无法继续传播 CancellationException, + // 一律吞掉走日志,避免触发 Thread.UncaughtExceptionHandler 引发的应用崩溃。 + // 收紧到 Exception:把 Error(OOM/StackOverflowError 等 JVM 致命信号)留给 JVM 处理。 + LogUtils.e(TAG, "performDeviceLogout: device logout failed: ${e.message}") + } + try { + HomeUserStore.clear() + LogUtils.d(TAG, "performDeviceLogout: HomeUserStore cleared") + } catch (e: java.lang.Exception) { + LogUtils.e(TAG, "performDeviceLogout: clear HomeUserStore failed: ${e.message}") + } + try { + JetsenxSdkUtil.clearReusableUid() + LogUtils.d(TAG, "performDeviceLogout: JetsenxSdkUtil reusable uid cleared") + } catch (e: java.lang.Exception) { + LogUtils.e(TAG, "performDeviceLogout: clearReusableUid failed: ${e.message}") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pukka/jiecheng/login/KeyboardAdapter.java b/app/src/main/java/com/pukka/jiecheng/login/KeyboardAdapter.java old mode 100755 new mode 100644 diff --git a/app/src/main/java/com/pukka/jiecheng/login/LoginActivity.java b/app/src/main/java/com/pukka/jiecheng/login/LoginActivity.java old mode 100755 new mode 100644 diff --git a/app/src/main/java/com/pukka/jiecheng/login/WxMpLoginActivity.java b/app/src/main/java/com/pukka/jiecheng/login/WxMpLoginActivity.java new file mode 100644 index 0000000..cec7690 --- /dev/null +++ b/app/src/main/java/com/pukka/jiecheng/login/WxMpLoginActivity.java @@ -0,0 +1,120 @@ +/** + * @file WxMpLoginActivity.java + * @description 家用版微信扫码登录 Activity(第二阶段:真实扫码)。 + * 1. onCreate:绑定视图,显示悬浮返回按钮; + * 2. onResume:启动 WxMpLoginManager.startLogin(this),订阅 state LiveData; + * 3. Manager.onQrBitmapReady:填充中央 QR ImageView; + * 4. Confirmed 状态:finish() 关闭页面,回到 HomeMyFragment; + * 5. onDestroy:调 WxMpLoginManager.cancelLogin() 释放 Activity 引用。 + * + *轮询简化逻辑:/me envelope code == 200 即视为微信扫码授权完成; + * 非 200 继续轮询,不区分 401 中间态(用户明确指示)。
+ * @author ainetv + * @date 2026-09-16 + */ +package com.pukka.jiecheng.login; + +import android.graphics.Bitmap; +import android.os.Bundle; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.Nullable; +import androidx.lifecycle.Observer; + +import com.pukka.jiecheng.R; +import com.pukka.jiecheng.base.BaseActivity; + +public class WxMpLoginActivity extends BaseActivity implements WxMpLoginCallback { + + private ImageView mIvQrCode; + private TextView mTvStatus; + private Button mBtnCancel; + + private Observer职责(简化版流程,依据用户明确指示): + *
线程模型: + * - 顶层 AppScope(进程级 SupervisorJob + Dispatchers.IO); + * - QR 渲染走 withContext(Main)切主线程喂 Bitmap 给 ImageView; + * - UI 状态通过 [state] LiveData 推送,WxMpLoginActivity 观察。
+ * + *调用方契约: + * - [startLogin] 必须在 [WxMpLoginActivity.onResume] 调一次; + * - [cancelLogin] 必须在 [WxMpLoginActivity.onDestroy] 调,释放 Activity 引用避免内存泄漏。
+ */ +object WxMpLoginManager { + + private const val TAG = "WxMpLoginManager" + + /** /me 轮询周期(ms);按 spec §1.5 + device-integration.md §8.4 取 3s */ + private const val POLL_PERIOD_MS = 3_000L + + /** /login-url 重试最大次数(设备未 init / 配置缺失等);超过后转 Failed */ + private const val LOGIN_URL_RETRY_MAX = 3 + + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** 当前持有的 Activity(供回调刷新 QR / status);Activity onDestroy 须调 cancelLogin 清空 */ + @Volatile private var attachedActivity: WxMpLoginActivity? = null + + /** 当前主流程 Job;cancelLogin 时取消,串行链路随之终止 */ + @Volatile private var loginJob: Job? = null + + private val _state = MutableLiveData先同步读 [HomeUserStore.getUser] 回调(已登录时立即回显头像昵称),再异步调 /me/benefits + * 取最新状态——无论本地是否有缓存(未登录)都请求,覆盖"后端已登录但本地缓存被清"的场景 + * (如 SP 被清/换设备初始化后后端仍记住该设备)。若新响应的字段与缓存不一致 → 写 HomeUserStore + * + 回调 true(强制 UI 重绘);响应中 user 为 null 表示后端也未登录,维持现状。
+ * + * @param scope Fragment lifecycleScope + * @param onResult (info, hasUpdate) hasUpdate=true 表示本地缓存被更新,UI 需要重绘 + */ + @JvmStatic + fun refreshUserState(scope: CoroutineScope, onResult: (HomeUserInfo, Boolean) -> Unit) { + val cached = HomeUserStore.getUser() + onResult(cached, false) + scope.launch { + try { + val resp = withContext(Dispatchers.IO) { + ApiRequesters.ui(scope) + .fromdata 形态:后端直接平铺 DeviceUserDto 字段(openid/appId/nickname/avatarUrl/unionid), + * 无 user 包装层。data 为 null 视为未授权,继续轮询。
+ */ + private suspend fun pollDeviceMeUntilSuccess(): DeviceUserDto? { + var result: DeviceUserDto? = null + while (result == null) { + delay(POLL_PERIOD_MS) + val resp = withContext(Dispatchers.IO) { + ApiRequesters.app().from为什么不复用 [BoxSharedParameters]:第一阶段 spec §1.3 + §7 明确"不动 BoxSharedParameters", + * 商用版 HotelMyFragment 也读 BoxSharedParameters,直接往里写字段会污染商用版行为。 + * 独立 SP 文件(`home_user_prefs`)按 flavor 隔离,即使 applicationId 相同也不会跨 flavor 串数据。
+ * + *字段语义: + * - nickname / avatarUrl: 微信昵称与头像(用于 HomeMyFragment 已登录态头部展示) + * - expireTime: ISO-8601 字符串,取自权益列表中 validUntil(按 validUntil 倒序取最大者) + * - unionid: 唯一标识,SDK init / 退出登录时透传给 JetsenxSdkUtil + * - isLoggedIn: 持久化登录标记(nickname 非空 → true;clear() 后 → false)
+ * + *线程安全:SharedPreferences 自身线程安全(getXxx / edit / put 均为同步);无需额外加锁。
+ */ +object HomeUserStore { + + private const val PREFS_NAME = "home_user_prefs" + private const val KEY_NICKNAME = "nickname" + private const val KEY_AVATAR_URL = "avatarUrl" + private const val KEY_EXPIRE_TIME = "expireTime" + private const val KEY_UNIONID = "unionid" + private const val KEY_LOGGED_IN = "loggedIn" + + private fun prefs(): SharedPreferences = + ContextUtils.getApplicationContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + /** + * 读取当前已登录用户信息(从 SP 同步读)。 + * + * @return 默认返回 `HomeUserInfo(isLoggedIn=false, ...)` 表示未登录 + */ + @JvmStatic + fun getUser(): HomeUserInfo { + val sp = prefs() + val loggedIn = sp.getBoolean(KEY_LOGGED_IN, false) + if (!loggedIn) return HomeUserInfo(isLoggedIn = false, nickname = null, avatarUrl = null, expireTime = null, unionid = null) + return HomeUserInfo( + isLoggedIn = true, + nickname = sp.getString(KEY_NICKNAME, null), + avatarUrl = sp.getString(KEY_AVATAR_URL, null), + expireTime = sp.getString(KEY_EXPIRE_TIME, null), + unionid = sp.getString(KEY_UNIONID, null) + ) + } + + /** + * 写入登录态(由 WxMpLoginManager 命中 /me 后调用)。 + * + * @param user /me 返回的用户身份(nickname / avatarUrl / unionid) + * @param expireTime 取自 /me/benefits 的权益 validUntil 最大值(无权益时传 null) + */ + @JvmStatic + fun saveLogin(user: DeviceUserDto, expireTime: String?) { + val sp = prefs() + sp.edit().apply { + putBoolean(KEY_LOGGED_IN, true) + putString(KEY_NICKNAME, user.nickname) + putString(KEY_AVATAR_URL, user.avatarUrl) + putString(KEY_UNIONID, user.unionid) + if (expireTime != null) putString(KEY_EXPIRE_TIME, expireTime) + else remove(KEY_EXPIRE_TIME) + }.apply() + } + + /** + * 清除登录态(退出登录 / token 过期)。 + */ + @JvmStatic + fun clear() { + prefs().edit().clear().apply() + } + + /** + * 仅从权益列表提取最大 validUntil(按 ISO-8601 字符串字典序即可,与 JsapiOrderMonitor.parseIso8601ToMillis 同源)。 + * + * @return ISO-8601 字符串;权益列表为空时返回 null + */ + @JvmStatic + fun pickMaxExpireTime(benefits: List生成(或复用 PENDING)登录二维码(device-integration.md §3)。 + * 响应 data.loginUrl 直接喂给 QR 渲染库; + * isNew=true 表示屏幕 QR 需要重渲染,false 表示可继续使用旧 QR。
+ * + *错误:500 系列(设备不存在 / 支付配置缺失 / 支付配置 appId 缺失 / H5 登录域名未配置) + * 由 service 层抛错;envelope code!=0 业务错误由调用方按 msg 分支处理。
+ */ + @GET("api/jst/benefit/device/login-url") + suspend fun loginUrl(): NetResponse轮询扫码用户身份(device-integration.md §4)。 + * 响应 data 直接是 DeviceUserDto(后端无 user 包装层);调用方简化逻辑: + * envelope code == 0 + data 非空 → 视为微信扫码授权完成,停止轮询; + * 非 0 一律继续轮询,不区分 401 "scanned 等待授权" 与 "过期" 中间态 + * (用户明确指定的简化语义)。
+ */ + @GET("api/jst/benefit/device/me") + suspend fun deviceMe(): NetResponse查询当前扫码用户的权益上下文(device-integration.md §5), + * 用于 HomeMyFragment 已登录态头部展示(头像 / 昵称 / 会员到期日)。
+ */ + @GET("api/jst/benefit/device/me/benefits") + suspend fun deviceBenefits(): NetResponse设备侧登出(微信扫码登录的逆操作)。无请求体;sign 请求头由 HeaderInterceptor + * 自动注入(本路径不在 SignRequestPolicy 排除名单内)。成功响应 code == 0,data 恒为 null。
+ */ + @POST("api/jst/benefit/device/logout") + suspend fun deviceLogout(): NetResponseenvelope 约定:code == 0 成功(jsapi 系列专用,区别于其他 AS 接口的 200), + * 调用方需手判 resp.code == 0 后取 data.items,不能用 .map<T>()(其解包 code==200)。
+ * + * @return data.items 套餐列表(cardType / priceFen / durationHours / goodsName / description) + */ + @POST("api/jst/benefit/order/price") + suspend fun price(): NetResponse请求体 cardType 取自套餐列表选中项;响应 h5Url 生成二维码供用户扫码进入 H5 订购页; + * expireTime 为 ISO-8601 字符串(订单 15 分钟后过期),到期需重新 create(可复用未过期订单)。
+ * + *envelope 约定:code == 0 成功;业务错误码 400(STORE_NOT_BOUND / DEVICE_NOT_FOUND / + * CARD_TYPE_DISABLED)、500(BENEFIT_CARD_OUT_OF_STOCK / ORDER_CREATE_RETRY),msg 携带描述。
+ */ + @POST("api/jst/benefit/order/jsapi/create") + suspend fun jsapiCreate(@Body req: BenefitJsapiOrderReq): NetResponsecreate 成功取得 orderNo 后,由 [com.pukka.jiecheng.order.JsapiOrderHelper] + * 周期调用(默认 10s 延迟 + 5s 周期,参见 §3.1 阶段 3);命中 + * {PAID, ACTIVATING, ACTIVE} 进入阶段 4(check 卡密轮询),命中 CANCEL 清缓存重 create。
+ * + *envelope 约定:code == 0 成功;404 ORDER_NOT_FOUND(订单不存在)/ 403 PERMISSION_DENIED + * (设备不匹配)等错误码在 data.status 缺失时由调用方按 envelope.code 判定。
+ * + * @param orderNo 来自 jsapiCreate 响应的 orderNo + */ + @GET("api/jst/benefit/order/jsapi/status/{orderNo}") + suspend fun jsapiStatus(@Path("orderNo") orderNo: String): NetResponseenvelope 约定:code == 0 成功(jsapi 系列接口专用,区别于其他 AS 接口的 200), + * 调用方需手判 resp.code == 0 后再取 data。
+ * + * @author ainetv + * @date 2026-09-04 + */ +@JsonClass(generateAdapter = true) +data class BenefitPriceDto( + @field:Json(name = "items") val items: List封装两个接口的调用,供 Java 侧 + * ([com.pukka.jiecheng.order.fragment.OrderCenterFragment]) 使用:
+ *职责收敛(详见 docs/superpowers/specs/2026-09-12-jsapi-order-monitor-design.md §3.1): + * status 轮询 / check 轮询 / 状态机推进 全部移交 [JsapiOrderMonitor], + * helper 不再持有任何协程 Job,Fragment destroy 时无需手动 cancel。
+ * + *envelope 约定:code == 0 成功(jsapi 系列,非 200),故不走 .map
成功后写入 [cachedItems],供 [switchItem] 兜底查找 pricingId。
+ */ + fun loadPrices(callback: PriceCallback) { + ApiRequesters.ui(scope) + .from供宿主判断阶段 1 选中套餐后应走 [createOrder](首次 / CANCEL 重建) + * 还是 [switchItem](切套餐,零网络请求)。
+ */ + fun hasCachedOrder(): Boolean = !cachedH5Url.isNullOrBlank() + + /** + * 拼接 QR URL:h5Url + ?pricingId=xxx(或 h5Url 已含 ? 时用 &)。 + * + *无 pricingId 时原样返回 h5Url(不破坏现有 URL 结构)。
+ */ + fun composeQrUrl(h5Url: String?, pricingId: Int?): String? { + if (h5Url.isNullOrBlank()) return null + if (pricingId == null) { + LogUtils.w(TAG, "composeQrUrl: pricingId is null, return h5Url unchanged") + return h5Url + } + val separator = if (h5Url.contains("?")) "&" else "?" + return "$h5Url${separator}pricingId=$pricingId" + } + + /** + * 切换选中套餐:本地拼 [pricingId] 刷新二维码,不发起任何网络请求。 + * + *仅在 [hasCachedOrder] 返回 true 时调用;否则返回 null,由宿主回退到 [createOrder]。
+ * + *pricingId 优先取 [item.pricingId],兜底从 [cachedItems] 按 cardType 查找。
+ */ + fun switchItem(item: BenefitPriceItemDto): String? { + val h5Url = cachedH5Url + if (h5Url.isNullOrBlank()) { + LogUtils.w(TAG, "switchItem skipped: cachedH5Url is null, should call createOrder first") + return null + } + val pricingId = item.pricingId + ?: cachedItems?.firstOrNull { it.cardType == item.cardType }?.pricingId + return composeQrUrl(h5Url, pricingId) + } + + /** + * 创建/复用 H5 扫码订购订单。 + * + *成功时同时写入 [cachedH5Url] / [cachedOrderNo],由宿主 Fragment 调 + * [JsapiOrderMonitor.arm] + [JsapiOrderMonitor.startStatusJobBg] 启动轮询。
+ * + *失败只回调一次,由宿主决定重试/提示。
+ */ + fun createOrder(cardType: String?, callback: CreateCallback) { + ApiRequesters.ui(scope) + .from持有 [statusJobBg](AppScope CoroutineScope, 5s 周期)跨 OrderActivity 存活, + * 通过 [JsapiOrderMonitorCallback] 把状态推进推给 [com.pukka.jiecheng.order.fragment.OrderCenterFragment]。
+ * + *关键设计:
+ *详见 docs/superpowers/specs/2026-09-12-jsapi-order-monitor-design.md §3 / §5.3。
+ * + * @param appScope AppScope CoroutineScope,生命周期 = 进程生命周期 + */ +class JsapiOrderMonitor private constructor(private val appScope: CoroutineScope) { + + /** 阶段 3 status 轮询 Job;Activity destroy 不停,终态时停 */ + private var statusJobBg: Job? = null + + /** 阶段 4 check 轮询 Job;仅 status 命中 ACTIVE 时由 Fragment 调 startCheckJob() 启动 */ + private var checkJob: Job? = null + + /** 缓存最近一次 arm 的 orderNo;statusJobBg 用作轮询 key */ + @Volatile private var currentOrderNo: String? = null + + /** 缓存最近一次 arm 的 cardType;超时自愈 recreate() 用 */ + @Volatile private var currentCardType: String? = null + + /** 缓存最近一次 status 响应里的 expireTime(Unix 毫秒);超时自愈判定用 */ + @Volatile private var currentExpireTime: Long? = null + + /** 回调订阅列表(线程安全);Monitor 状态推进时遍历回调 */ + private val callbacks = CopyOnWriteArraySet调用时机:[com.pukka.jiecheng.order.fragment.OrderCenterFragment.onCreateView]。 + * 不传 Activity Context,仅持有 applicationContext 防止内存泄漏。
+ * + * @param context 任意 Context,方法内部仅取 applicationContext + * @return 全局唯一的 [JsapiOrderMonitor] 实例 + */ + @Suppress("UNUSED_PARAMETER") + fun get(context: Context): JsapiOrderMonitor { + // 参数 context 在本骨架中尚未使用(Task 2 不创建任何依赖 Context 的资源), + // 保留入参是因未来 Task(N) 可能需要 applicationContext 做网络层初始化 + // (如注册 DeviceIdProvider、ChannelIdProvider 等)。 + // 此处约定调用方传 Application Context,不持有 Activity Context。 + return INSTANCE ?: synchronized(this) { + INSTANCE ?: JsapiOrderMonitor( + // AppScope CoroutineScope:SupervisorJob + Dispatchers.IO,进程级存活 + CoroutineScope(SupervisorJob() + Dispatchers.IO) + ).also { INSTANCE = it } + } + } + } + + /** + * 注册状态回调。 + * + *调用时机:OrderCenterFragment.onResume。
+ *线程安全:CopyOnWriteArraySet 保证并发 add/remove 不抛 ConcurrentModificationException。
+ * + * @param callback 状态回调实现方(典型为 OrderCenterFragment) + */ + fun registerCallback(callback: JsapiOrderMonitorCallback) { + callbacks.add(callback) + LogUtils.d(TAG, "registerCallback total=${callbacks.size}") + } + + /** + * 注销状态回调。 + * + *调用时机:OrderCenterFragment.onPause / onDestroyView。 + * 注销后 Monitor 状态推进不会再触达该 callback,但 statusJobBg 不会因此停止。
+ * + * @param callback 之前注册的状态回调实现方 + */ + fun unregisterCallback(callback: JsapiOrderMonitorCallback) { + callbacks.remove(callback) + LogUtils.d(TAG, "unregisterCallback total=${callbacks.size}") + } + + /** + * 通知 Monitor:OrderActivity.onStart 已调(用户回到页面)。 + * + *重置 [postExitIterationsRemaining] 为 0,取消"销毁后兜底倒计"。 + * 若 statusJobBg 已被 post-exit 倒计取消,OrderCenterFragment 在 onResume + * 注册回调后,后续 helper.createOrder / 重新 arm 路径会再次调 [startStatusJobBg] + * 拉起轮询,Monitor 不在此方法内主动重启。
+ */ + fun notifyOrderActivityResumed() { + if (postExitIterationsRemaining > 0) { + LogUtils.d(TAG, "notifyOrderActivityResumed: reset post-exit counter from $postExitIterationsRemaining to 0") + postExitIterationsRemaining = 0 + } + } + + /** + * 通知 Monitor:OrderActivity.onDestroy 已调(用户退出页面)。 + * + *无未完结订单([isArmed] == false)时直接 return,不进入倒计(statusJobBg 本就没跑)。
+ *有 armed 订单时把 [postExitIterationsRemaining] 置为 [POST_EXIT_POLL_ITERATIONS], + * 下一次 [statusJobBg] 迭代结束后倒计 --1,达到 0 时只取消 [statusJobBg] + * (保留 [checkJob] 让激活路径独立完成,即便用户已离开也能让新权益即时生效)。
+ */ + fun notifyOrderActivityDestroyed() { + if (!isArmed) { + LogUtils.d(TAG, "notifyOrderActivityDestroyed: no armed order, skip") + return + } + postExitIterationsRemaining = POST_EXIT_POLL_ITERATIONS + LogUtils.d(TAG, "notifyOrderActivityDestroyed: schedule stop after $POST_EXIT_POLL_ITERATIONS more polls") + } + + /** + * 在主线程投递状态回调。 + * + *[OrderCenterFragment] 等实现方在回调里直接操作 View(setText / Toast / 二维码刷新), + * 而 statusJobBg / checkJob 运行在 [Dispatchers.IO]——统一在这里切回主线程, + * 避免 CalledFromWrongThreadException(Only the original thread 可触碰 View)。
+ * + *采用 [mainHandler] 的 [Handler.post](fire-and-forget)而非 withContext(Main): + * FAILED / check 命中路径会先调 [stop] 取消当前协程,若此时再进 withContext(Main) + * 会因协程已取消直接抛 CancellationException、回调丢失;post 不依赖协程存活态。
+ */ + private fun dispatchToCallbacks(block: (JsapiOrderMonitorCallback) -> Unit) { + mainHandler.post { callbacks.forEach(block) } + } + + /** + * 装备(arm)轮询目标。 + * + *记录元数据供 [startStatusJobBg] 启动轮询用。仅当 orderNo != currentOrderNo 时才真正启动/重启 + * statusJobBg;orderNo == currentOrderNo 时仅更新 cardType/expireTime(如有变化),不重启协程(幂等保护)。
+ * + *调用时机:OrderCenterFragment.helper.createOrder 成功回调里 / OrderCenterFragment.onResume + * 同步 Monitor.armed 状态后调。
+ * + * @param orderNo 订单号(status 轮询 path 参数) + * @param cardType 卡种(超时自愈 recreate 用);可空 + * @param expireTime Unix 毫秒(超时自愈判定用);可空表示无过期(ORDER_CREATED 阶段) + */ + fun arm(orderNo: String, cardType: String?, expireTime: Long?) { + // 防御:orderNo 必须非空非空白,否则后续 status 轮询 key 无法构建 + require(orderNo.isNotBlank()) { "arm: orderNo must not be blank" } + synchronized(this) { + if (currentOrderNo == orderNo) { + // 幂等:仅更新 cardType/expireTime(若有变化),不重启协程 + currentCardType = cardType + currentExpireTime = expireTime + LogUtils.d(TAG, "arm idempotent orderNo=$orderNo cardType=$cardType expireTime=$expireTime") + return + } + currentOrderNo = orderNo + currentCardType = cardType + currentExpireTime = expireTime + LogUtils.d(TAG, "arm orderNo=$orderNo cardType=$cardType expireTime=$expireTime") + } + } + + /** + * 启动 statusJobBg 后台轮询。 + * + *幂等:若 statusJobBg?.isActive == true,直接 return。 + * 必须先调 [arm] 写入 currentOrderNo。
+ * + *轮询逻辑(详见 docs/superpowers/specs/2026-09-12-jsapi-order-monitor-design.md §4):
+ *调用方有两处:
+ *30s 内最多 10 次轮询 `/api/jst/check`,命中 10200 或 thirdCardNo 非空即调 + * [stop] 同时取消 statusJobBg + checkJob 并回调 onCheckActivated;超时回调 onCheckTimeout。
+ * + *幂等:若 checkJob?.isActive == true,直接 return。
+ */ + fun startCheckJob() { + synchronized(this) { + if (checkJob?.isActive == true) { + LogUtils.w(TAG, "startCheckJob ignored: already running") + return + } + LogUtils.d(TAG, "startCheckJob") + checkJob = appScope.launch { + val deadline = System.currentTimeMillis() + CHECK_TOTAL_BUDGET_MS + var attempt = 0 + + while (attempt < CHECK_MAX_ATTEMPTS + && System.currentTimeMillis() < deadline + && isActive) { + attempt++ + if (attempt > 1) delay(CHECK_INTERVAL_MS) + if (!isActive) break + + LogUtils.d(TAG, "check poll attempt=$attempt") + val apiResult: ApiResult从原 JsapiOrderHelper.parseIso8601 复制,仅用于 statusJobBg 内部超时自愈判定。 + * 解析失败返回 null,不影响主流程。
+ */ + private fun parseIso8601ToMillis(ts: String?): Long? { + if (ts.isNullOrBlank()) return null + val patterns = listOf( + "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", + "yyyy-MM-dd'T'HH:mm:ssXXX", + "yyyy-MM-dd'T'HH:mm:ss.SSS", + "yyyy-MM-dd'T'HH:mm:ss" + ) + for (pattern in patterns) { + try { + val sdf = java.text.SimpleDateFormat(pattern, java.util.Locale.US) + sdf.isLenient = false + return sdf.parse(ts)?.time + } catch (e: Exception) { + // 尝试下一种格式 + } + } + return null + } +} diff --git a/app/src/main/java/com/pukka/jiecheng/order/JsapiOrderMonitorCallback.kt b/app/src/main/java/com/pukka/jiecheng/order/JsapiOrderMonitorCallback.kt new file mode 100644 index 0000000..c2535d5 --- /dev/null +++ b/app/src/main/java/com/pukka/jiecheng/order/JsapiOrderMonitorCallback.kt @@ -0,0 +1,59 @@ +package com.pukka.jiecheng.order + +/** + * JsapiOrderMonitor 状态回调接口(家用版 H5 扫码订购链路)。 + * + *由 [JsapiOrderMonitor] 在 statusJobBg / checkJob 推进时回调, + * 由 [com.pukka.jiecheng.order.fragment.OrderCenterFragment] 实现。
+ * + *回调线程:主线程(由 [JsapiOrderMonitor.dispatchToCallbacks] 统一切到 + * [android.os.Looper.getMainLooper] 后投递)。实现方可直接操作 View,无需自行切线程。
+ * + *详见 docs/superpowers/specs/2026-09-12-jsapi-order-monitor-design.md §5.2。
+ */ +interface JsapiOrderMonitorCallback { + + /** + * status 状态更新(含 ORDER_CREATED / SCANNED / PENDING / PAID / ACTIVATING / ACTIVE 等中间态)。 + * + * @param status 当前订单状态字面量,与新协议 §3.1 状态机表对齐 + * @param msg 后端携带的用户提示文案(如"支付成功");可空 + */ + fun onStatusUpdate(status: String, msg: String?) + + /** + * status 命中 CANCELLED 或 TIMEOUT_CANCELED(终态,由 Quartz BenefitOrderTimeoutTask 推进)。 + * + *由回调方决定是否重建订单:典型实现是 OrderCenterFragment 收到回调后调 + * helper.createOrder(currentCardType),成功后再 Monitor.arm(newOrderNo, ...) 续接。
+ * + * @param reason 后端 msg(如"订单已取消");可空 + * @param expired true = TIMEOUT_CANCELED(15min 过期),false = CANCELLED(用户主动 / Quartz 推进) + */ + fun onOrderCancelled(reason: String?, expired: Boolean) + + /** + * status 命中 ACTIVE(终态前置,需启动 check 轮询拿 thirdCardNo)。 + * + *由回调方决定何时启动 check:典型实现是 OrderCenterFragment 收到回调后调 + * Monitor.startCheckJob()。Monitor 仅暴露 startCheckJob,不依赖 Helper。
+ */ + fun onOrderActive() + + /** + * check 命中 10200 或 thirdCardNo 非空 —— 卡密已下发,已写入 JetsenxSdkUtil.sUid。 + * + * @param cardNo 第三方卡号(非空) + */ + fun onCheckActivated(cardNo: String) + + /** check 30s/10 次超时,未拿到 thirdCardNo */ + fun onCheckTimeout() + + /** + * status 终态 FAILED(订单异常,如第三方激活失败等)。 + * + * @param reason 后端 msg 或异常描述 + */ + fun onOrderFailed(reason: String?) +} \ No newline at end of file diff --git a/app/src/main/java/com/pukka/jiecheng/order/OrderActivity.java b/app/src/main/java/com/pukka/jiecheng/order/OrderActivity.java deleted file mode 100755 index abb10a2..0000000 --- a/app/src/main/java/com/pukka/jiecheng/order/OrderActivity.java +++ /dev/null @@ -1,643 +0,0 @@ -package com.pukka.jiecheng.order; - -import static com.pukka.libcore.base.BaseConst.agentVendorCode; -import static com.pukka.libcore.base.BaseConst.businessLine; -import static com.pukka.libcore.base.BaseConst.serviceComboCode; -import static com.pukka.libcore.base.BaseConst.serviceGroupCode; - -import android.content.ComponentName; -import android.content.Intent; -import android.graphics.Bitmap; -import android.os.Bundle; -import android.text.TextUtils; -import android.view.KeyEvent; -import android.view.View; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.leanback.widget.BrowseFrameLayout; - -import com.alibaba.fastjson.JSONObject; -import com.migu.bean.GetsystemBean; -import com.migu.callback.AijiaCallback; -import com.migu.httputils.HomeHttpUtils; -import com.pukka.jiecheng.JcConfig; -import com.pukka.jiecheng.R; -import com.pukka.jiecheng.base.BaseActivity; -import com.pukka.jiecheng.login.viewmodel.IsLoginViewModel; -import com.pukka.jiecheng.order.fragment.OrderCenterFragment; -import com.pukka.jiecheng.order.fragment.OrderDuihuanFragment; -import com.pukka.jiecheng.order.fragment.OrderListFragment; -import com.pukka.jiecheng.order.fragment.OrderXufeiFragment; -import com.pukka.jiecheng.utils.IsLoginTimerUtil; -import com.pukka.jiecheng.utils.PayResultTimerUtil; -import com.pukka.jiecheng.utils.QRCodeUtil; -import com.pukka.libcore.boxShared.BoxSharedParameters; -import com.pukka.libcore.boxShared.BoxSharedParamsUtils; -import com.pukka.libcore.boxShared.MacUtils; -import com.pukka.libcore.fresco.PkSimpleDraweeView; -import com.pukka.libcore.threading.DispatchQueue; -import com.pukka.libcore.utils.ContextUtils; -import com.pukka.libcore.utils.LogUtils; -import com.pukka.libcore.utils.StringUtils; -import com.pukka.libcore.viewmodel.BaseVM; - -import java.net.URLEncoder; - -public class OrderActivity extends BaseActivity { - - public static final String media_classify_param = "media_classify";//媒资分类 例如:电影 - public static final String content_id_param = "content_id"; - public static final String content_name_param = "content_name"; - public static final String refer_page_path_param = "refer_page_path"; - //1=订购中心 2=我的订购 - public static final String go_type_param = "go_type_param"; - - private TextView mTvOrderCenter, mTvMyOrder, mTvDuihuan, mTvXufei; - private ConstraintLayout mClOrderCenter, mClMyOrder, mClLoginQrCode, mClDuihuan, mClXufei; - private ImageView mImgLoginQrCode; - private TextView mTvUserId; - private boolean orderCenterGetFocus; - private boolean firstFocus; - private BrowseFrameLayout mBrowseFrameLayout; - private OrderListFragment orderListFragment; - private OrderCenterFragment orderCenterFragment; - private OrderDuihuanFragment orderDuihuanFragment; - private IsLoginViewModel mIsLoginViewModel; - //埋点 - String media_classify, content_id, content_name, refer_page_path; - String go_type; - private PkSimpleDraweeView clbg; - private ImageView ivGame; - private String policyCode; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_order); - initView(); - getIntentParam(); - initListener(); - topFocusSearchListener(); - if (StringUtils.isEmpty(BoxSharedParameters.getInstance().getUserId())) { - //用户没登录,只能跳转到订购中心界面 - go_type = ""; - } - if (!StringUtils.isEmpty(go_type) && go_type.equals("2")) { - mClMyOrder.requestFocus(); - mClOrderCenter.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_unfocus_bg)); - mClMyOrder.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_focus_bg)); - mTvOrderCenter.setTextColor(mContext.getResources().getColor(R.color.white30)); - mTvMyOrder.setTextColor(mContext.getResources().getColor(R.color.order_btn_text_color)); - showOrderListFragment(); - } else { - showOrderCenterFragment(); - } - } - - //从上个页面获取值 - private void getIntentParam() { - Intent intent = getIntent(); - if (intent != null) { - media_classify = intent.getStringExtra(media_classify_param); - content_id = intent.getStringExtra(content_id_param); - content_name = intent.getStringExtra(content_name_param); - refer_page_path = intent.getStringExtra(refer_page_path_param); - go_type = intent.getStringExtra(go_type_param); - LogUtils.d("media_classify=" + media_classify + " content_id=" + content_id + " content_name=" + content_name + " go_type=" + go_type); - policyCode = intent.getStringExtra("policyCode"); - LogUtils.d("policyCode=" + policyCode); - } - } - - private void initView() { - ivGame = findViewById(R.id.ivGame); - clbg = findViewById(R.id.clbg); - mImgLoginQrCode = findViewById(R.id.id_img_login_qrcode); - mClLoginQrCode = findViewById(R.id.id_cl_login_qrcode); - mTvOrderCenter = findViewById(R.id.id_tv_orderCenter); - mTvMyOrder = findViewById(R.id.id_tv_myOrder); - mClOrderCenter = findViewById(R.id.id_cl_orderCenter); - mTvXufei = findViewById(R.id.id_tv_xufei); - mClXufei = findViewById(R.id.id_cl_xufei); - mTvDuihuan = findViewById(R.id.id_tv_duihuan); - mClDuihuan = findViewById(R.id.id_cl_duihuan); - mClMyOrder = findViewById(R.id.id_cl_myOrder); - mTvUserId = findViewById(R.id.id_tv_userId); - mTvUserId.setText("账号:" + BoxSharedParameters.getInstance().getUserId()); - if (StringUtils.isEmpty(BoxSharedParameters.getInstance().getUserId())) { - LogUtils.d("用户没有登录,显示登录二维码,轮询是否登录接口"); - mTvUserId.setText(""); - mClLoginQrCode.setVisibility(View.VISIBLE); - createQRCode("KMHX"); - initSever(); - } else { - mClLoginQrCode.setVisibility(View.GONE); - } - mBrowseFrameLayout = findViewById(R.id.id_nav_bfl); - mBrowseFrameLayout.getViewTreeObserver().addOnGlobalFocusChangeListener((oldFocus, newFocus) -> { - LogUtils.d("oldFocus:" + oldFocus + " newFocus:" + newFocus); - }); - orderListFragment = OrderListFragment.newInstance(); - orderCenterFragment = OrderCenterFragment.newInstance(); - orderDuihuanFragment = OrderDuihuanFragment.newInstance(); - initGamePad(); - changeBg(); - showGameIcon(); - } - - private void initSever() { - mIsLoginViewModel = BaseVM.getSafeViewModel(this, IsLoginViewModel.class); - mIsLoginViewModel.getIsLoginData().observe(this, response -> { - LogUtils.d("轮询结果: isLogin==" + response); - if ("success".equals(response)) { - try { - mClLoginQrCode.setVisibility(View.GONE); - mTvUserId.setText("账号:" + BoxSharedParameters.getInstance().getUserId()); - LogUtils.d("显示会员卡核销输入面板"); - if (null != orderDuihuanFragment) { - orderDuihuanFragment.showVipPanel(); - } - } catch (Exception ex) { - ex.printStackTrace(); - } - } - }); - IsLoginTimerUtil.getInstance().startTimer(mIsLoginViewModel); - } - - private boolean isFirstShowOrderCenter; - - private void initListener() { - ivGame.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - intentGame(); - } - }); - mClXufei.setOnFocusChangeListener(new View.OnFocusChangeListener() { - @Override - public void onFocusChange(View view, boolean b) { - if (b) { - showOrderXufeiFragment(); - } - if (b) { - mTvXufei.setTextColor(0xff4c3709); - - mClMyOrder.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_unfocus_bg)); - mTvMyOrder.setTextColor(mContext.getResources().getColor(R.color.white30)); - } else { - mTvXufei.setTextColor(0x80ffffff); - } - } - }); - mClOrderCenter.setOnFocusChangeListener(new View.OnFocusChangeListener() { - @Override - public void onFocusChange(View view, boolean b) { - LogUtils.d("mClOrderCenter onFocusChange b=" + b + " orderCenterGetFocus=" + orderCenterGetFocus); - if (orderCenterGetFocus) { - orderCenterGetFocus = false; - DispatchQueue.main.after(100, () -> { - mClMyOrder.setFocusable(true); - }); - } - if (b) { - showOrderCenterFragment(); - } - if (b) { - mClDuihuan.setFocusable(true); - mClXufei.setFocusable(false); - if (isFirstShowOrderCenter) { - mClOrderCenter.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_focused_bg)); - mTvOrderCenter.setTextColor(mContext.getResources().getColor(R.color.order_list_focus_text_color)); - } else { - isFirstShowOrderCenter = true; - mClOrderCenter.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_focus_bg)); - mTvOrderCenter.setTextColor(mContext.getResources().getColor(R.color.order_btn_text_color)); - } - - mClDuihuan.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_unfocus_bg)); - mTvDuihuan.setTextColor(mContext.getResources().getColor(R.color.white30)); - mClMyOrder.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_unfocus_bg)); - mTvMyOrder.setTextColor(mContext.getResources().getColor(R.color.white30)); - } else { - mClOrderCenter.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_focus_bg)); - mTvOrderCenter.setTextColor(mContext.getResources().getColor(R.color.order_btn_text_color)); - } - } - }); - mClMyOrder.setOnFocusChangeListener(new View.OnFocusChangeListener() { - @Override - public void onFocusChange(View view, boolean b) { - LogUtils.d("mClMyOrder onFocusChange b=" + b); - firstFocus = true; - if (b) { - showOrderListFragment(); - } - if (b) { - mClOrderCenter.setFocusable(true); - mClXufei.setFocusable(true); - mClMyOrder.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_focused_bg)); - mTvMyOrder.setTextColor(mContext.getResources().getColor(R.color.order_list_focus_text_color)); - - mClDuihuan.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_unfocus_bg)); - mTvDuihuan.setTextColor(mContext.getResources().getColor(R.color.white30)); - mClOrderCenter.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_unfocus_bg)); - mTvOrderCenter.setTextColor(mContext.getResources().getColor(R.color.white30)); - } else { - mClMyOrder.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_focus_bg)); - mTvMyOrder.setTextColor(mContext.getResources().getColor(R.color.order_btn_text_color)); - } - } - }); - mClDuihuan.setOnFocusChangeListener(new View.OnFocusChangeListener() { - @Override - public void onFocusChange(View view, boolean b) { - LogUtils.d("mClDuihuan onFocusChange b=" + b); - if (b) { - mClOrderCenter.setFocusable(true); - showOrderDuihuanFragment(); - mClDuihuan.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_focused_bg)); - mTvDuihuan.setTextColor(mContext.getResources().getColor(R.color.order_list_focus_text_color)); - - mClMyOrder.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_unfocus_bg)); - mTvMyOrder.setTextColor(mContext.getResources().getColor(R.color.white30)); - mClOrderCenter.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_unfocus_bg)); - mTvOrderCenter.setTextColor(mContext.getResources().getColor(R.color.white30)); - } else { - mClDuihuan.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_focus_bg)); - mTvDuihuan.setTextColor(mContext.getResources().getColor(R.color.order_btn_text_color)); - } - } - }); - } - - public String getPolicyCode() { - return policyCode; - } - - public void setDuihuanFoucs() { - mClDuihuan.setFocusable(false); - } - - public void showGameIcon() { - if (BoxSharedParamsUtils.isJcCloud()) { - HomeHttpUtils.getsystemData(new AijiaCallback.GetsystemCallBack() { - @Override - public void onResponse(GetsystemBean response) { - try { - if (response == null) { - return; - } - if (response.getResult() == null - || response.getResult().isEmpty()) { - return; - } - for (GetsystemBean.ResultBean bean : response.getResult() - ) { - if (bean.getType()==5) { - int status = bean.getStatus(); - if (status == 1) { - ivGameSetVisibility(true); - } else { - ivGameSetVisibility(false); - } - break; - } - } - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Override - public void netFail() { - - } - - @Override - public void fail() { - - } - }); - } else { - ivGameSetVisibility(false); - } - } - - private void ivGameSetVisibility(boolean isVISIBLE) { - LogUtils.d("ivGameSetVisibility:" + isVISIBLE); - ivGame.post(new Runnable() { - @Override - public void run() { - ivGame.setVisibility(isVISIBLE ? View.VISIBLE : View.GONE); - } - }); - } - - public void mClXufeiSetFocusableNot() { - mClXufei.setFocusable(false); - } - - /** - * 我的订购按下焦点处理 - */ - public void mClMyOrderSetNextFocusDownId(boolean isTuiDingBtn) { - if (!isTuiDingBtn) { - LogUtils.d("没有退订按钮,焦点聚焦到我的订购按钮"); - mClMyOrder.setNextFocusDownId(mClMyOrder.getId()); - } else { - mClMyOrder.setNextFocusDownId(View.NO_ID); - } - } - - public void requestFocusXufei() { - mClMyOrder.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_unfocus_bg)); - mTvMyOrder.setTextColor(mContext.getResources().getColor(R.color.white30)); - mClOrderCenter.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_unfocus_bg)); - mTvOrderCenter.setTextColor(mContext.getResources().getColor(R.color.white30)); - mClXufei.setFocusable(true); - mClXufei.requestFocus(); - } - - public void changeBg() { - HomeHttpUtils.getsystemData(new AijiaCallback.GetsystemCallBack() { - @Override - public void onResponse(GetsystemBean response) { - try { - if (response == null) { - return; - } - if (response.getResult() == null - || response.getResult().isEmpty()) { - return; - } - for (GetsystemBean.ResultBean bean : response.getResult() - ) { - if (bean.getType() == 4) { - String corner_url = bean.getCorner_url(); - LogUtils.d("changeBg corner_url:" + corner_url); - if (!TextUtils.isEmpty(corner_url)) { - clbg.post(new Runnable() { - @Override - public void run() { - if (TextUtils.isEmpty(corner_url)) { - return; - } - clbg.setImageURI(corner_url); - } - }); - } - break; - } - } - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Override - public void netFail() { - - } - - @Override - public void fail() { - - } - }); - } - - public void intentGame() { - try { - LogUtils.d("跳转UserRechargeActivity 开始"); - String packageName = "com.anning.game"; - String activityFullName = "com.anning.game.ui.activity.UserRechargeActivity"; // 目标Activity完整类名 - Intent intent = new Intent(); - ComponentName comp = new ComponentName(packageName, activityFullName); - intent.setComponent(comp); - startActivity(intent); - } catch (Exception e) { - LogUtils.d("跳转UserRechargeActivity 出错"); - e.printStackTrace(); - } - } - - public void isShowClXufei(int subscription) { - LogUtils.d("wxf isShowClXufei=" + subscription); - if (subscription > 0) { - mClXufei.setVisibility(View.VISIBLE); - } else { - mClXufei.setVisibility(View.GONE); - } - } - - // TOP的按钮 焦点控制 - private void topFocusSearchListener() { - mBrowseFrameLayout.setOnFocusSearchListener((focused, direction) -> { - LogUtils.d("wxf focused=" + focused + " direction=" + direction); - if (direction == View.FOCUS_DOWN) { - LogUtils.d("wxf, topFocusSearchListener FOCUS_DOWN"); - if (focused.getId() == R.id.id_cl_orderCenter) { - mClOrderCenter.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_focus_bg)); - } - } else if (direction == View.FOCUS_UP) { - LogUtils.d("wxf, topFocusSearchListener FOCUS_UP"); - - } else if (direction == View.FOCUS_RIGHT) { - LogUtils.d("wxf, topFocusSearchListener FOCUS_RIGHT"); - if (focused.getId() == R.id.id_cl_orderCenter) { - mClOrderCenter.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_unfocus_bg)); - mClMyOrder.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_focus_bg)); - mTvOrderCenter.setTextColor(mContext.getResources().getColor(R.color.white30)); - mTvMyOrder.setTextColor(mContext.getResources().getColor(R.color.order_btn_text_color)); - showOrderListFragment(); - } - } else if (direction == View.FOCUS_LEFT) { - LogUtils.d("wxf, topFocusSearchListener FOCUS_LEFT"); - if (focused.getId() == R.id.id_cl_myOrder) { - mClOrderCenter.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_focus_bg)); - mClMyOrder.setBackground(mContext.getResources().getDrawable(R.drawable.order_btn_unfocus_bg)); - mTvMyOrder.setTextColor(mContext.getResources().getColor(R.color.white30)); - mTvOrderCenter.setTextColor(mContext.getResources().getColor(R.color.order_btn_text_color)); - showOrderCenterFragment(); - } else if (focused.getId() == R.id.id_cl_xufei_ok) { - if (orderCenterFragment != null) { - orderCenterFragment.restDirection(); - } - } else if (focused.getId() == R.id.id_cl_update_ok) { - if (orderCenterFragment != null) { - orderCenterFragment.restDirection(); - } - } - } - return null; - }); - } - - private void showOrderXufeiFragment() { - LogUtils.d("showOrderCenterFragment"); - try { - hideClLoginQrCode(); - getSupportFragmentManager().beginTransaction() - .replace(R.id.id_fragment_container, OrderXufeiFragment.newInstance()) - .commit(); - } catch (Exception ex) { - ex.printStackTrace(); - } - } - - private void showOrderCenterFragment() { - LogUtils.d("showOrderCenterFragment"); - try { - hideClLoginQrCode(); - getSupportFragmentManager().beginTransaction() - .replace(R.id.id_fragment_container, orderCenterFragment) - .commit(); - OrderAmberManager.orderView(refer_page_path, media_classify, content_id, content_name); - } catch (Exception ex) { - ex.printStackTrace(); - } - } - - private void showOrderDuihuanFragment() { - LogUtils.d("showOrderDuihuanFragment"); - try { - showClLoginQrCode(); - getSupportFragmentManager().beginTransaction() - .replace(R.id.id_fragment_container, orderDuihuanFragment) - .commit(); - } catch (Exception ex) { - ex.printStackTrace(); - } - } - - private void showOrderListFragment() { - LogUtils.d("showOrderListFragment"); - try { - hideClLoginQrCode(); - getSupportFragmentManager().beginTransaction() - .replace(R.id.id_fragment_container, orderListFragment) - .commit(); - } catch (Exception ex) { - ex.printStackTrace(); - } - } - - public void requestFocusOrderCenter() { - if (null != mClOrderCenter) { - LogUtils.d("mClOrderCenter request focus"); - orderCenterGetFocus = true; - mClMyOrder.setFocusable(false); - mClOrderCenter.setFocusable(true); - mClOrderCenter.requestFocus(); - } - } - - public void requestFocusOrderList() { - if (null != mClMyOrder) { - LogUtils.d("mClOrderCenter request focus"); - orderCenterGetFocus = false; - mClMyOrder.setFocusable(true); - mClMyOrder.requestFocus(); - } - } - - public void setOrderCenterFocusable(boolean b) { - if (null != mClOrderCenter) { - mClOrderCenter.setFocusable(b); - } - } - - - public boolean isFirstFocus() { - return firstFocus; - } - - public void setFirstFocus(boolean firstFocus1) { - this.firstFocus = firstFocus1; - } - - @Override - protected void onPause() { - super.onPause(); - PayResultTimerUtil.getInstance().stopTimer(); - IsLoginTimerUtil.getInstance().stopTimer(); - } - - /** - * 获取登录二维码 - */ - public void createQRCode(String selectedPolicyCode) { - //分别是 hid(mac地址), appId、channelId(渠道id)、serviceComboCode(业务组合标识)、serviceGroupCode(业务分组标识)、businessLine(业务线标识) - //prefix 0代表测试环境,1代表正式环境 - JSONObject jsonObject = new JSONObject(); - jsonObject.put("hid", MacUtils.getMac(ContextUtils.getApplicationContext())); - jsonObject.put("channelId", agentVendorCode); - jsonObject.put("serviceComboCode", serviceComboCode); - jsonObject.put("serviceGroupCode", serviceGroupCode); - jsonObject.put("businessLine", businessLine); - jsonObject.put("prefix", JcConfig.prefix); - jsonObject.put("genToken", "1"); - jsonObject.put("selectedPolicyCode", selectedPolicyCode); - jsonObject.put("redirect", "pages/orderCenter/orderCenter"); - try { - // 将JSON转为字符串并进行URL编码 - String encodedParams = URLEncoder.encode(jsonObject.toString(), "UTF-8"); - String codeUrl = JcConfig.weiXinURL + "?params=" + encodedParams; - LogUtils.d("codeUrl==" + codeUrl); - if (!TextUtils.isEmpty(codeUrl)) { - int smallHeight = this.getResources().getDimensionPixelSize(R.dimen.qb_px_318); - int smallWidth = this.getResources().getDimensionPixelSize(R.dimen.qb_px_318); - Bitmap imgurl = QRCodeUtil.createQRCodeBitmap(codeUrl, smallWidth, smallHeight); - if (imgurl != null) { - mImgLoginQrCode.setImageBitmap(imgurl); - } - } - } catch (Exception ex) { - ex.printStackTrace(); - } - - } - - /** - * 隐藏微信扫一扫登录二维码UI - */ - public void hideClLoginQrCode() { - if (null != mClLoginQrCode) { - LogUtils.d("隐藏微信登录二维码"); - mClLoginQrCode.setVisibility(View.GONE); - } - } - - /** - * 显示微信登录二维码 - */ - public void showClLoginQrCode() { - if (StringUtils.isEmpty(BoxSharedParameters.getInstance().getUserId())) { - if (null != mClLoginQrCode) { - LogUtils.d("显示微信登录二维码"); - mClLoginQrCode.setVisibility(View.VISIBLE); - } - } - } - - - @Override - public boolean onKeyDown(int keyCode, KeyEvent event) { - return super.onKeyDown(keyCode, event); - } - - private void clearFragment() { - orderListFragment = null; - orderCenterFragment = null; - orderDuihuanFragment = null; - } - - @Override - protected void onDestroy() { - super.onDestroy(); - clearFragment(); - } -} \ No newline at end of file diff --git a/app/src/main/java/com/pukka/jiecheng/order/OrderActivity.kt b/app/src/main/java/com/pukka/jiecheng/order/OrderActivity.kt new file mode 100644 index 0000000..d6eceaf --- /dev/null +++ b/app/src/main/java/com/pukka/jiecheng/order/OrderActivity.kt @@ -0,0 +1,228 @@ +/** + * @file OrderActivity.kt + * @description 订购中心入口 Activity,包含"订购中心/兑换码/我的订购/自动续费"等 Tab 切换 + * 同时支持遥控(DPAD 焦点导航)与触摸点击两种交互模式 + * @author ainetv + * @date 2026-09-01 + */ +package com.pukka.jiecheng.order + +import android.os.Bundle +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.fragment.app.Fragment +import androidx.leanback.widget.BrowseFrameLayout +import com.pukka.jiecheng.R +import com.pukka.jiecheng.base.BaseActivity +import com.pukka.jiecheng.config.AppConfiguration +import com.pukka.jiecheng.order.fragment.OrderCenterFragment +import com.pukka.jiecheng.order.fragment.OrderDuihuanFragment +import com.pukka.jiecheng.order.fragment.OrderListFragment +import com.pukka.jiecheng.order.fragment.OrderXufeiFragment +import com.pukka.jiecheng.utils.JetsenxSdkUtil +import com.pukka.libcore.utils.LogUtils + +/** + * 订购中心 Activity + * + * 双模式交互策略: + * - 触摸模式:以 OnClickListener 为入口,点击 Tab 直接切换 Fragment;不绑定焦点监听,避免触摸点击触发焦点变化导致的二次切换 + * - 遥控模式:同时绑定 OnClickListener(DPAD 中键)与 OnFocusChangeListener(DPAD 方向键导航),通过 currentTab 防重入保证不会重复切换 + */ +class OrderActivity : BaseActivity() { + + // Tab 标识常量 + private companion object { + const val TAB_NONE = -1 + const val TAB_ORDER_CENTER = 0 + const val TAB_DUIHUAN = 1 + const val TAB_MY_ORDER = 2 + const val TAB_XUFEI = 3 + } + + private var mBrowseFrameLayout: BrowseFrameLayout? = null + + // ----------------------------- Tab View 引用 ----------------------------- + private var mClOrderCenter: ConstraintLayout? = null + private var mClDuihuan: ConstraintLayout? = null + private var mClMyOrder: ConstraintLayout? = null + private var mClXufei: ConstraintLayout? = null + private var mTvOrderCenter: android.widget.TextView? = null + private var mTvDuihuan: android.widget.TextView? = null + private var mTvMyOrder: android.widget.TextView? = null + private var mTvXufei: android.widget.TextView? = null + + // ----------------------------- Fragment 实例 ----------------------------- + private var orderListFragment: OrderListFragment? = null + private var orderCenterFragment: OrderCenterFragment? = null + private var orderDuihuanFragment: OrderDuihuanFragment? = null + private var orderXufeiFragment: OrderXufeiFragment? = null + + // 当前选中的 Tab。初始值设为 TAB_NONE(无效 Tab 标识), + // 让 onCreate 中首次调用 selectTab(TAB_ORDER_CENTER) 能真正进入 showOrderCenterFragment() 分支, + // 避免 selectTab 的幂等保护 `if (currentTab == tabId) return` 吞掉首次切换 + // (之前初始值=TAB_ORDER_CENTER 时 currentTab==tabId 直接 return,导致 Fragment 永远不显示)。 + private var currentTab: Int = TAB_NONE + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_order) + initView() + initListener() + // 默认展示订购中心 + selectTab(TAB_ORDER_CENTER) + // 触摸模式启用左上角悬浮返回按钮;BaseActivity 内部已自行 isTouchMode 判断 + enableBackButton() + } + + private fun initView() { + mBrowseFrameLayout = findViewById(R.id.id_nav_bfl) + mBrowseFrameLayout?.viewTreeObserver?.addOnGlobalFocusChangeListener { oldFocus, newFocus -> + LogUtils.d("oldFocus:$oldFocus newFocus:$newFocus") + } + mClOrderCenter = findViewById(R.id.id_cl_orderCenter) + mClDuihuan = findViewById(R.id.id_cl_duihuan) + mClMyOrder = findViewById(R.id.id_cl_myOrder) + mClXufei = findViewById(R.id.id_cl_xufei) + mTvOrderCenter = findViewById(R.id.id_tv_orderCenter) + mTvDuihuan = findViewById(R.id.id_tv_duihuan) + mTvMyOrder = findViewById(R.id.id_tv_myOrder) + mTvXufei = findViewById(R.id.id_tv_xufei) + + orderListFragment = OrderListFragment.newInstance() + orderCenterFragment = OrderCenterFragment.newInstance() + orderDuihuanFragment = OrderDuihuanFragment.newInstance() + orderXufeiFragment = OrderXufeiFragment.newInstance() + } + + /** + * 初始化顶部 Tab 的遥控 + 触摸事件 + * + * 触摸模式:仅绑定 OnClickListener + * 遥控模式:同时绑定 OnClickListener(DPAD 中键)与 OnFocusChangeListener(方向键) + */ + private fun initListener() { + val isTouchMode = AppConfiguration.getInstance().isTouchMode() + // 触摸模式仅 click;遥控模式额外绑 focus 监听以响应方向键 + listOf( + mClOrderCenter to TAB_ORDER_CENTER, + mClDuihuan to TAB_DUIHUAN, + mClMyOrder to TAB_MY_ORDER, + mClXufei to TAB_XUFEI + ).forEach { (cl, id) -> + cl?.setOnClickListener { selectTab(id) } + if (!isTouchMode) { + cl?.setOnFocusChangeListener { _, hasFocus -> if (hasFocus) selectTab(id) } + } + } + } + + /** + * 切换 Tab:切换 Fragment + 刷新按钮视觉态 + * + * @param tabId 目标 Tab 标识([TAB_ORDER_CENTER] / [TAB_DUIHUAN] / [TAB_MY_ORDER] / [TAB_XUFEI]) + */ + private fun selectTab(tabId: Int) { + // 同一 Tab 多次触发(OnClick + OnFocusChange)做幂等保护 + if (currentTab == tabId) return + currentTab = tabId + updateTabVisualState(tabId) + when (tabId) { + TAB_ORDER_CENTER -> showOrderCenterFragment() + TAB_DUIHUAN -> showOrderDuihuanFragment() + TAB_MY_ORDER -> showOrderListFragment() + TAB_XUFEI -> showOrderXufeiFragment() + } + } + + /** + * 刷新顶部 Tab 按钮的背景与文字色: + * - 选中 Tab 使用 order_btn_focus_bg + order_btn_text_color + * - 未选中 Tab 使用 order_btn_unfocus_bg + white30 + */ + private fun updateTabVisualState(activeTabId: Int) { + val focusBg = R.drawable.order_btn_focus_bg + val unfocusBg = R.drawable.order_btn_unfocus_bg + val focusTextColor = resources.getColor(R.color.order_btn_text_color, theme) + val unfocusTextColor = resources.getColor(R.color.white30, theme) + + mClOrderCenter?.setBackgroundResource(if (activeTabId == TAB_ORDER_CENTER) focusBg else unfocusBg) + mClDuihuan?.setBackgroundResource(if (activeTabId == TAB_DUIHUAN) focusBg else unfocusBg) + mClMyOrder?.setBackgroundResource(if (activeTabId == TAB_MY_ORDER) focusBg else unfocusBg) + mClXufei?.setBackgroundResource(if (activeTabId == TAB_XUFEI) focusBg else unfocusBg) + + mTvOrderCenter?.setTextColor(if (activeTabId == TAB_ORDER_CENTER) focusTextColor else unfocusTextColor) + mTvDuihuan?.setTextColor(if (activeTabId == TAB_DUIHUAN) focusTextColor else unfocusTextColor) + mTvMyOrder?.setTextColor(if (activeTabId == TAB_MY_ORDER) focusTextColor else unfocusTextColor) + mTvXufei?.setTextColor(if (activeTabId == TAB_XUFEI) focusTextColor else unfocusTextColor) + } + + private fun showOrderXufeiFragment() { + showFragment(orderXufeiFragment ?: OrderXufeiFragment.newInstance().also { + orderXufeiFragment = it + }) + } + + private fun showOrderCenterFragment() { + showFragment(orderCenterFragment ?: OrderCenterFragment.newInstance().also { + orderCenterFragment = it + }) + } + + private fun showOrderDuihuanFragment() { + showFragment(orderDuihuanFragment ?: OrderDuihuanFragment.newInstance().also { + orderDuihuanFragment = it + }) + } + + private fun showOrderListFragment() { + showFragment(orderListFragment ?: OrderListFragment.newInstance().also { + orderListFragment = it + }) + } + + /** + * 使用 FragmentManager.replace 切换内容区 Fragment + */ + private fun showFragment(fragment: Fragment) { + supportFragmentManager.beginTransaction() + .replace(R.id.id_fragment_container, fragment) + .commit() + } + + /** + * 清理 Fragment 引用(onDestroy 时调用,防止内存泄漏) + */ + private fun clearFragment() { + orderListFragment = null + orderCenterFragment = null + orderDuihuanFragment = null + orderXufeiFragment = null + } + + override fun onStart() { + super.onStart() + // 用户回到 OrderActivity:取消 statusJobBg 的"销毁后兜底倒计", + // 避免在用户重新进入后被前置的倒计提前停掉(详见 JsapiOrderMonitor.notifyOrderActivityResumed) + JsapiOrderMonitor.get(this).notifyOrderActivityResumed() + } + + /** + * onResume 时若 JetsenxSdkUtil.sUid 已缓存(用户已开通/激活),自动切到"我的订购" Tab, + * 跳过默认的"订购中心"引导,直达用户当前订单状态。 + * selectTab 自带 currentTab 幂等保护,重复触发同一 Tab 不会重建 Fragment。 + */ + override fun onResume() { + super.onResume() + if (JetsenxSdkUtil.getSuid().isNotEmpty()) { + selectTab(TAB_MY_ORDER) + } + } + + override fun onDestroy() { + // 用户退出 OrderActivity:进入 statusJobBg 的"销毁后兜底倒计", + // 允许额外轮询 POST_EXIT_POLL_ITERATIONS 次后再停,checkJob 不受影响独立完成激活 + JsapiOrderMonitor.get(this).notifyOrderActivityDestroyed() + super.onDestroy() + clearFragment() + } +} diff --git a/app/src/main/java/com/pukka/jiecheng/order/OrderAmberManager.java b/app/src/main/java/com/pukka/jiecheng/order/OrderAmberManager.java old mode 100755 new mode 100644 diff --git a/app/src/main/java/com/pukka/jiecheng/order/OrderStyleUtils.java b/app/src/main/java/com/pukka/jiecheng/order/OrderStyleUtils.java old mode 100755 new mode 100644 diff --git a/app/src/main/java/com/pukka/jiecheng/order/adapter/OrderCenterAdapter.java b/app/src/main/java/com/pukka/jiecheng/order/adapter/OrderCenterAdapter.java old mode 100755 new mode 100644 index eb40210..280433e --- a/app/src/main/java/com/pukka/jiecheng/order/adapter/OrderCenterAdapter.java +++ b/app/src/main/java/com/pukka/jiecheng/order/adapter/OrderCenterAdapter.java @@ -14,8 +14,9 @@ import androidx.recyclerview.widget.RecyclerView; import com.google.gson.reflect.TypeToken; import com.pukka.jiecheng.R; +import com.pukka.jiecheng.config.AppConfiguration; import com.pukka.jiecheng.filter.adapter.TopAdapter; -import com.pukka.jiecheng.order.OrderActivity; +import com.pukka.jiecheng.network.dto.BenefitPriceItemDto; import com.pukka.jiecheng.order.adapter.viewholder.CenterViewHolder; import com.pukka.jiecheng.order.adapter.viewholder.CenterVipViewHolder; import com.pukka.jiecheng.order.bean.OrderListBean; @@ -39,12 +40,21 @@ import java.util.Map; * @since V0.1.0.1 */ public class OrderCenterAdapter extends RecyclerView.Adapter覆盖文档 §6.3 中创维 VOD 场景:
+ * 第三方投递广播 → {@link com.pukka.jiecheng.voice.adapter.SkyworthVoiceAdapter}
+ * 归一化 JSON → 本 Parser 解析 → VoiceCommand(OPEN_FILM_DETAIL, filmName)。
+ */
+public class SkyworthVodParserTest {
+
+ private final SkyworthVodParser parser = new SkyworthVodParser();
+
+ // ===== 正常路径 =====
+
+ @Test
+ public void 应当正确解析JUMP_FILM并产出OPEN_FILM_DETAIL() {
+ VoiceCommand cmd = parser.parse(SkyworthVodParser.COMMAND_JUMP,
+ "{\"jumpType\":\"FILM\",\"filmName\":\"流浪地球\"}");
+
+ assertNotNull(cmd);
+ assertEquals(VoiceCommandType.OPEN_FILM_DETAIL, cmd.getCommandType());
+ assertEquals("流浪地球", cmd.getPayloadValue("filmName"));
+ // 未显式带 autoPlay 时,Parser 不额外注入(默认值由 Adapter 归一化层负责);
+ // 这里只透传 filmName,payload size = 1
+ assertEquals(1, cmd.getPayload().size());
+ }
+
+ @Test
+ public void 应当透传autoPlay字段到payload() {
+ // Adapter 归一化时默认写入 autoPlay=true;Parser 需原样透传给 Navigator。
+ VoiceCommand cmd = parser.parse(SkyworthVodParser.COMMAND_JUMP,
+ "{\"jumpType\":\"FILM\",\"filmName\":\"流浪地球\",\"autoPlay\":\"true\"}");
+
+ assertEquals(VoiceCommandType.OPEN_FILM_DETAIL, cmd.getCommandType());
+ assertEquals("流浪地球", cmd.getPayloadValue("filmName"));
+ assertEquals("true", cmd.getPayloadValue("autoPlay"));
+ // 白名单字段仅 filmName + autoPlay → size = 2
+ assertEquals(2, cmd.getPayload().size());
+ }
+
+ @Test
+ public void 应当透传autoPlay_false并忽略其他多余字段() {
+ // 调试场景下 extra 里显式传 autoPlay=false 能覆盖默认值;
+ // 同时任何非白名单字段(extra / customFlag)都必须被丢弃,避免 Navigator 误消费。
+ VoiceCommand cmd = parser.parse(SkyworthVodParser.COMMAND_JUMP,
+ "{\"jumpType\":\"FILM\",\"filmName\":\"战狼2\",\"autoPlay\":\"false\","
+ + "\"extra\":\"ignore\",\"customFlag\":\"x\"}");
+
+ assertEquals(VoiceCommandType.OPEN_FILM_DETAIL, cmd.getCommandType());
+ assertEquals("战狼2", cmd.getPayloadValue("filmName"));
+ assertEquals("false", cmd.getPayloadValue("autoPlay"));
+ assertNull(cmd.getPayloadValue("extra"));
+ assertNull(cmd.getPayloadValue("customFlag"));
+ // 白名单字段仅 filmName + autoPlay → size = 2
+ assertEquals(2, cmd.getPayload().size());
+ }
+
+ @Test
+ public void payload中多余字段应当被忽略() {
+ // Adapter 归一化时仅写 jumpType/filmName/autoPlay;防御性校验:
+ // 上游意外多塞的其他私有字段不得进入 payload。
+ // 注意:autoPlay 属于白名单保留字段,此处不放入输入,单独用上面用例覆盖。
+ VoiceCommand cmd = parser.parse(SkyworthVodParser.COMMAND_JUMP,
+ "{\"jumpType\":\"FILM\",\"filmName\":\"战狼2\",\"extra\":\"ignore\",\"customFlag\":\"drop-me\"}");
+
+ assertEquals(VoiceCommandType.OPEN_FILM_DETAIL, cmd.getCommandType());
+ assertEquals("战狼2", cmd.getPayloadValue("filmName"));
+ // 解析结果只保留 filmName(无 autoPlay 输入时不注入),避免后续 navigator 误消费上游私有字段
+ assertEquals(1, cmd.getPayload().size());
+ assertNull(cmd.getPayloadValue("extra"));
+ assertNull(cmd.getPayloadValue("customFlag"));
+ }
+
+ @Test
+ public void filmName包含特殊字符应能正确处理() {
+ // JSON 转义后的英文双引号
+ VoiceCommand cmd = parser.parse(SkyworthVodParser.COMMAND_JUMP,
+ "{\"jumpType\":\"FILM\",\"filmName\":\"\\\"Hello\\\" World\"}");
+ assertEquals(VoiceCommandType.OPEN_FILM_DETAIL, cmd.getCommandType());
+ assertEquals("\"Hello\" World", cmd.getPayloadValue("filmName"));
+
+ // 中文冒号 / 空格
+ VoiceCommand cmd2 = parser.parse(SkyworthVodParser.COMMAND_JUMP,
+ "{\"jumpType\":\"FILM\",\"filmName\":\" 黑客帝国 : 矩阵重启 \"}");
+ assertEquals(" 黑客帝国 : 矩阵重启 ", cmd2.getPayloadValue("filmName"));
+ }
+
+ @Test
+ public void payload字段顺序与换行不影响解析() {
+ VoiceCommand cmd = parser.parse(SkyworthVodParser.COMMAND_JUMP,
+ "{\n \"filmName\" : \"星际穿越\" ,\n \"jumpType\":\"FILM\"\n}");
+ assertEquals(VoiceCommandType.OPEN_FILM_DETAIL, cmd.getCommandType());
+ assertEquals("星际穿越", cmd.getPayloadValue("filmName"));
+ }
+
+ // ===== commandType 校验 =====
+
+ @Test
+ public void commandType为null应当抛异常() {
+ try {
+ parser.parse(null, "{\"jumpType\":\"FILM\",\"filmName\":\"x\"}");
+ fail("应当抛出 IllegalArgumentException");
+ } catch (IllegalArgumentException ex) {
+ assertTrue(ex.getMessage().contains("commandType"));
+ }
+ }
+
+ @Test
+ public void commandType不是JUMP应当抛异常() {
+ try {
+ parser.parse("SEARCH", "{\"filmName\":\"x\"}");
+ fail("应当抛出 IllegalArgumentException");
+ } catch (IllegalArgumentException ex) {
+ assertTrue(ex.getMessage().contains("SEARCH"));
+ }
+
+ try {
+ parser.parse("CONTROL", "{\"action\":\"PAUSE\"}");
+ fail("应当抛出 IllegalArgumentException");
+ } catch (IllegalArgumentException ex) {
+ assertTrue(ex.getMessage().contains("CONTROL"));
+ }
+ }
+
+ // ===== jumpType 校验 =====
+
+ @Test
+ public void jumpType缺失应当抛异常() {
+ try {
+ parser.parse(SkyworthVodParser.COMMAND_JUMP, "{\"filmName\":\"x\"}");
+ fail("应当抛出 IllegalArgumentException");
+ } catch (IllegalArgumentException ex) {
+ assertTrue(ex.getMessage().contains("jumpType"));
+ }
+ }
+
+ @Test
+ public void jumpType为空字符串应当抛异常() {
+ try {
+ parser.parse(SkyworthVodParser.COMMAND_JUMP, "{\"jumpType\":\"\",\"filmName\":\"x\"}");
+ fail("应当抛出 IllegalArgumentException");
+ } catch (IllegalArgumentException ex) {
+ assertTrue(ex.getMessage().contains("jumpType"));
+ }
+ }
+
+ @Test
+ public void jumpType不是FILM应当抛异常() {
+ // 创维 VOD Parser 仅认 FILM;CATEGORY/ACTOR/DIRECTOR 走 VendorAParser,
+ // 不在 SkyworthVodParser 支持范围内
+ try {
+ parser.parse(SkyworthVodParser.COMMAND_JUMP,
+ "{\"jumpType\":\"CATEGORY\",\"categoryName\":\"动作片\"}");
+ fail("应当抛出 IllegalArgumentException");
+ } catch (IllegalArgumentException ex) {
+ assertTrue(ex.getMessage().contains("CATEGORY"));
+ }
+ }
+
+ // ===== filmName 校验 =====
+
+ @Test
+ public void filmName缺失应当抛异常() {
+ try {
+ parser.parse(SkyworthVodParser.COMMAND_JUMP, "{\"jumpType\":\"FILM\"}");
+ fail("应当抛出 IllegalArgumentException");
+ } catch (IllegalArgumentException ex) {
+ assertTrue(ex.getMessage().contains("filmName"));
+ }
+ }
+
+ @Test
+ public void filmName为空字符串应当抛异常() {
+ try {
+ parser.parse(SkyworthVodParser.COMMAND_JUMP,
+ "{\"jumpType\":\"FILM\",\"filmName\":\"\"}");
+ fail("应当抛出 IllegalArgumentException");
+ } catch (IllegalArgumentException ex) {
+ assertTrue(ex.getMessage().contains("filmName"));
+ }
+ }
+
+ @Test
+ public void filmName为空白字符串应当被视作非法() {
+ // 注:当前实现按 isEmpty() 判断,空白字符串不算空但行为上等同非法。
+ // 本用例固定现状:空白原样透传,由下游 navigator / 搜索兜底。
+ VoiceCommand cmd = parser.parse(SkyworthVodParser.COMMAND_JUMP,
+ "{\"jumpType\":\"FILM\",\"filmName\":\" \"}");
+ assertEquals(VoiceCommandType.OPEN_FILM_DETAIL, cmd.getCommandType());
+ assertEquals(" ", cmd.getPayloadValue("filmName"));
+ }
+
+ // ===== payload 形态校验 =====
+
+ @Test
+ public void payload为null应当在jumpType校验时抛异常() {
+ try {
+ parser.parse(SkyworthVodParser.COMMAND_JUMP, null);
+ fail("应当抛出 IllegalArgumentException");
+ } catch (IllegalArgumentException ex) {
+ // jumpType 缺失触发
+ assertTrue(ex.getMessage().contains("jumpType"));
+ }
+ }
+
+ @Test
+ public void payload为非法JSON应当抛异常() {
+ // 用 JSON 数组:一定不是 JSONObject,JSONException 分支稳定触发
+ // (宽松的 malformed 对象在 org.json 不同实现下行为不一致,不作断言依据)
+ try {
+ parser.parse(SkyworthVodParser.COMMAND_JUMP, "[\"FILM\",\"流浪地球\"]");
+ fail("应当抛出 IllegalArgumentException");
+ } catch (IllegalArgumentException ex) {
+ assertTrue(ex.getMessage().contains("JSON"));
+ }
+ }
+
+ @Test
+ public void payload为JSON数组而非对象应当抛异常() {
+ try {
+ parser.parse(SkyworthVodParser.COMMAND_JUMP, "[\"jumpType\":\"FILM\"]");
+ fail("应当抛出 IllegalArgumentException");
+ } catch (IllegalArgumentException ex) {
+ assertTrue(ex.getMessage().contains("JSON"));
+ }
+ }
+
+ @Test
+ public void payload为空对象应当因缺少jumpType而抛异常() {
+ try {
+ parser.parse(SkyworthVodParser.COMMAND_JUMP, "{}");
+ fail("应当抛出 IllegalArgumentException");
+ } catch (IllegalArgumentException ex) {
+ assertTrue(ex.getMessage().contains("jumpType"));
+ }
+ }
+
+ // ===== 协议常量稳定性 =====
+
+ @Test
+ public void 公开常量应当与Adapter归一化协议保持一致() {
+ // Adapter 在归一化时会写死 JUMP + jumpType=FILM,本 Parser 常量被两端共同依赖。
+ // 单测固定下来,避免后续 rename 出现 Adapter / Parser 不一致。
+ assertEquals("JUMP", SkyworthVodParser.COMMAND_JUMP);
+ assertEquals("FILM", SkyworthVodParser.JUMP_FILM);
+ }
+
+ @Test
+ public void 多次解析应当互不影响() {
+ VoiceCommand a = parser.parse(SkyworthVodParser.COMMAND_JUMP,
+ "{\"jumpType\":\"FILM\",\"filmName\":\"AAA\"}");
+ VoiceCommand b = parser.parse(SkyworthVodParser.COMMAND_JUMP,
+ "{\"jumpType\":\"FILM\",\"filmName\":\"BBB\"}");
+
+ assertEquals("AAA", a.getPayloadValue("filmName"));
+ assertEquals("BBB", b.getPayloadValue("filmName"));
+ }
+
+ @Test
+ public void payload中filmName值含null字面量应当按字符串处理() {
+ // JSON 中 "filmName":null 会进入 result.put(k, "null") 而非真 null,
+ // 此时 isEmpty() 仍为 false,应被当作合法 filmName 透传(后续由搜索兜底)。
+ VoiceCommand cmd = parser.parse(SkyworthVodParser.COMMAND_JUMP,
+ "{\"jumpType\":\"FILM\",\"filmName\":null}");
+ assertEquals(VoiceCommandType.OPEN_FILM_DETAIL, cmd.getCommandType());
+ assertEquals("null", cmd.getPayloadValue("filmName"));
+ }
+}
diff --git a/app/src/test/java/com/pukka/jiecheng/voice/parser/VendorAParserTest.java b/app/src/test/java/com/pukka/jiecheng/voice/parser/VendorAParserTest.java
new file mode 100644
index 0000000..9bdb56d
--- /dev/null
+++ b/app/src/test/java/com/pukka/jiecheng/voice/parser/VendorAParserTest.java
@@ -0,0 +1,161 @@
+package com.pukka.jiecheng.voice.parser;
+
+import com.pukka.jiecheng.voice.protocol.VoiceCommand;
+import com.pukka.jiecheng.voice.protocol.VoiceCommandType;
+
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * VendorAParser 单测(文档 §6.1)。
+ */
+public class VendorAParserTest {
+
+ @Test
+ public void 应当正确解析JUMP_FILM并填充payload() {
+ Map 覆盖可行性评估文档 §6.1 协议定义:action / permission / component / extras 名称。
+ * FIFO 容量上限测试使用 5 个 VT-0..VT-4 入队,容量 3 意味着保留最新的
+ * VT-2 / VT-3 / VT-4(FIFO 抛弃最早)。
+ *
+ *
+ * 当两者真值相同(wifi-only 设备)但大小写不同时,统一成大写——便于后端按字符串比对 MAC。
+ * 双网卡设备上两者来自不同接口(有线 ≠ 无线),保留各自原值不强制统一。
*
- * @param context * @return
+ * @param context 上下文
+ * @return 长度固定为 2 的数组:[mac, wifiMac]
*/
- private static String getMacDefault(Context context) {
- String mac = "";
- if (context == null) {
- return mac;
+ public static String[] getDeviceMacPair(Context context) {
+ // 独立取两条路径,避免继承 getMac() 内部任何兜底行为
+ String ethMac = getMac(context);
+ String wifiMac = getWlan0Mac();
+ // 拿不到有线时以 wifi 为主(两者相同)
+ if (TextUtils.isEmpty(ethMac) && !TextUtils.isEmpty(wifiMac)) {
+ ethMac = wifiMac;
}
- WifiManager wifi = (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
- WifiInfo info = null;
- try {
- info = wifi.getConnectionInfo();
- } catch (Exception e) {
- e.printStackTrace();
+ // 拿不到 wifi 时以有线为主(双向兜底,保持两者一致)
+ if (TextUtils.isEmpty(wifiMac) && !TextUtils.isEmpty(ethMac)) {
+ wifiMac = ethMac;
}
-
- if (info == null) {
- return null;
+ // 两者都非空且真值相同(wifi-only 设备的常见场景),统一大小写
+ // 双网卡设备上两者来源不同,equalsIgnoreCase 不命中,保留各自原值
+ if (!TextUtils.isEmpty(ethMac) && !TextUtils.isEmpty(wifiMac)
+ && ethMac.equalsIgnoreCase(wifiMac)) {
+ String canonical = ethMac.toUpperCase(Locale.ENGLISH);
+ ethMac = canonical;
+ wifiMac = canonical;
}
- mac = info.getMacAddress();
- if (!TextUtils.isEmpty(mac)) {
- mac = mac.toUpperCase(Locale.ENGLISH);
- }
- return mac;
- }
-
- /**
- * Android 6.0-Android 7.0 获取mac地址
- */
- private static String getMacAddress() {
- String macSerial = null;
- String str = "";
-
- try {
- Process pp = Runtime.getRuntime().exec("cat /sys/class/net/wlan0/address");
- InputStreamReader ir = new InputStreamReader(pp.getInputStream());
- LineNumberReader input = new LineNumberReader(ir);
-
- while (null != str) {
- str = input.readLine();
- if (str != null) {
- macSerial = str.trim();//去空格
- break;
- }
- }
- } catch (IOException ex) {
- // 赋予默认值
- ex.printStackTrace();
- }
-
- return macSerial;
- }
-
- /**
- * Android 7.0之后获取Mac地址
- * 遍历循环所有的网络接口,找到接口是 wlan0
- * 必须的权限