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

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

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

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

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

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

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

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

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

View File

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

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

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

View File

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

View File

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

View File

View File

View File

View File

View File

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

View File

View File

View File

View File

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

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

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

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

@@ -0,0 +1,48 @@
/**
* @file HomeLogoutConfirmDialog.kt
* @description 家用版退出登录二次确认对话框:提示"是否退出登录?",确认/取消双按钮。
* 样式对齐家用版 ActivitedDialog / BenefitRevokedDialog(FullScreenDialog
* 全屏透明窗口 + 白底圆角卡片 + 焦点渐变按钮)。确认走 onConfirm 回调;
* 取消、返回键、点击外部均只关闭对话框。默认焦点落在"取消",防止误触退出。
* @author ainetv
* @date 2026-09-17
*/
package com.pukka.jiecheng.index.dialog
import android.app.Activity
import android.app.Dialog
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.Gravity
import android.view.View
import android.view.WindowManager
import com.pukka.jiecheng.R
class HomeLogoutConfirmDialog(
activity: Activity,
private val onConfirm: () -> Unit
) : Dialog(activity, R.style.FullScreenDialog) {
init {
setContentView(R.layout.dialog_home_logout_confirm)
window?.apply {
setLayout(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT
)
setGravity(Gravity.CENTER)
setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
}
findViewById<View>(R.id.btnConfirm).setOnClickListener {
dismiss()
onConfirm()
}
findViewById<View>(R.id.btnCancel).setOnClickListener { dismiss() }
}
override fun show() {
super.show()
// 破坏性操作:默认焦点落在"取消",用户需主动右移确认
findViewById<View>(R.id.btnCancel)?.requestFocus()
}
}

View File

View File

@@ -0,0 +1,297 @@
/**
* @file HomeMyFragment.kt
* @description 家用版(home flavor)"我的"Tab Fragment。第一阶段仅实现未登录态:
* 显示头像占位图 + "微信扫码登录"文字,不显示 MAC 地址,点击账号区域启动 WxMpLoginActivity。
* 第二阶段(不在本期范围):接入登录后刷新、退出登录、头像/昵称/会员到期日。
* 下方按钮区(设置/设备信息/检测更新/关于我们)复用 include_hotel_my_buttons.xml,
* 事件逻辑沿用 HotelMyFragment:跳转 MgRoute 对应页面 / Toast 提示 / 焦点态 TopStyleUtils。
* @author ainetv
* @date 2026-09-12
*/
package com.pukka.jiecheng.index.fragment
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.facebook.drawee.generic.GenericDraweeHierarchy
import com.facebook.drawee.drawable.ScalingUtils
import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder
import com.facebook.drawee.generic.RoundingParams
import com.facebook.drawee.view.SimpleDraweeView
import com.pukka.jiecheng.R
import com.pukka.jiecheng.downapk.StoreVersionUpgrader.checkUpdate
import com.pukka.jiecheng.index.IndexActivity
import com.pukka.jiecheng.index.dialog.HomeLogoutConfirmDialog
import com.pukka.jiecheng.login.HomeLogoutHandler
import com.pukka.jiecheng.login.WxMpLoginActivity
import com.pukka.jiecheng.login.WxMpLoginManager
import com.pukka.jiecheng.login.store.HomeUserInfo
import com.pukka.jiecheng.login.store.HomeUserStore
import com.pukka.jiecheng.utils.MgRoute
import com.pukka.jiecheng.utils.TopStyleUtils
import com.pukka.libcore.utils.LogUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
/**
* 家用版"我的"Tab Fragment(未登录态)
*
* 与 HotelMyFragment 区别:
* - 顶部账号区域整块作为点击热区,点击后跳转 WxMpLoginActivity(占位登录页)
* - 第一阶段不显示 MAC 地址,第二阶段接入登录态后展示头像/昵称/会员到期日
*
* 视图约束:顶部账号区域 id 与酒店版对齐为 R.id.id_cl_user_left,
* 让 fragment_home_my.xml 与 include_hotel_my_buttons.xml 共享同一锚点资源,
* 下方按钮区(设置/设备信息/检测更新/关于我们)按预期布局。
*/
class HomeMyFragment : Fragment(), View.OnClickListener, View.OnFocusChangeListener {
// 顶部账号区域(整块作为点击热区;同时是 include_hotel_my_buttons.xml 内兄弟节点的左侧锚点)
private var mClUserArea: ConstraintLayout? = null
// 头像占位图
private var mIvAvatar: SimpleDraweeView? = null
// 登录提示文字
private var mTvLoginHint: TextView? = null
// 已登录态 UI
private var mTvNickname: TextView? = null
private var mTvExpire: TextView? = null
private var mBtnLogout: Button? = null
// 下方按钮区(与 HotelMyFragment 共用 ID)
private var mIvSetting: RelativeLayout? = null // id_iv_3 设置
private var mIvDevice: RelativeLayout? = null // id_iv_device_4 设备信息
private var mIvUpdate: RelativeLayout? = null // id_iv_5 检测更新
private var mIvAbout: RelativeLayout? = null // id_iv_6 关于我们
// toTop 区(与 HotelMyFragment 共用 ID,来自 include index_floor_totop)
private var mClToTop: ConstraintLayout? = null // id_cl_to_top 回到顶部
private var mClSearch: ConstraintLayout? = null // id_bottom_cl_search 搜索
private var mContext: Context? = null
/**
* 静态工厂方法,与 HotelMyFragment.newInstance() 保持一致。
*/
companion object {
private const val TAG = "HomeMyFragment"
@JvmStatic
fun newInstance(): HomeMyFragment = HomeMyFragment()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = inflater.inflate(R.layout.fragment_home_my, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mContext = context
initView(view)
userBind()
}
/**
* 初始化视图引用。顶部账号区域点击启动登录页;
* 下方 4 个按钮(设置/设备信息/检测更新/关于我们)+ toTop 区(回到顶部/搜索)
* 事件逻辑沿用 HotelMyFragment。
*/
private fun initView(view: View) {
mClUserArea = view.findViewById(R.id.id_cl_user_left)
mIvAvatar = view.findViewById(R.id.id_iv_home_avatar)
applyAvatarHierarchy()
mTvLoginHint = view.findViewById(R.id.id_tv_home_login_hint)
userBind()
mIvSetting = view.findViewById(R.id.id_iv_3)
mIvDevice = view.findViewById(R.id.id_iv_device_4)
mIvUpdate = view.findViewById(R.id.id_iv_5)
mIvAbout = view.findViewById(R.id.id_iv_6)
mClToTop = view.findViewById(R.id.id_cl_to_top)
mClSearch = view.findViewById(R.id.id_bottom_cl_search)
mIvSetting?.setOnClickListener(this)
mIvDevice?.setOnClickListener(this)
mIvUpdate?.setOnClickListener(this)
mIvAbout?.setOnClickListener(this)
mClToTop?.setOnClickListener(this)
mClSearch?.setOnClickListener(this)
mClUserArea?.setOnFocusChangeListener(this)
mIvSetting?.setOnFocusChangeListener(this)
mIvDevice?.setOnFocusChangeListener(this)
mIvUpdate?.setOnFocusChangeListener(this)
mIvAbout?.setOnFocusChangeListener(this)
mClToTop?.setOnFocusChangeListener(this)
mClSearch?.setOnFocusChangeListener(this)
mClToTop?.visibility = View.GONE
view.findViewById<View>(R.id.view_space)?.visibility = View.GONE
// 已登录态 UI 绑定
mTvNickname = view.findViewById(R.id.id_tv_home_nickname)
mTvExpire = view.findViewById(R.id.id_tv_home_expire)
mBtnLogout = view.findViewById(R.id.id_btn_home_logout)
mBtnLogout?.setOnClickListener(this)
}
/**
* 第一阶段 userBind:仅设置登录提示文字。
* 第二阶段改名为 applyUserState:根据 HomeUserInfo 切换未登录/已登录态 UI。
*/
private fun userBind() {
applyUserState(HomeUserStore.getUser())
}
/**
* 为头像 SimpleDraweeView 注入圆形剪裁的 GenericDraweeHierarchy。
* placeholder 与网络图均用 CENTER_CROP 铺满控件(不自适应留白);
* 外框白色描边由 layout XML 的 android:background=@drawable/bg_avatar_white_border 提供。
* 注意:此函数会整体替换 hierarchy,XML 上的 app:scaleType 会被覆盖,缩放类型以此处为准。 */
private fun applyAvatarHierarchy() {
val avatar = mIvAvatar ?: return
val builder = GenericDraweeHierarchyBuilder(resources)
.setPlaceholderImage(R.mipmap.my_avatar_default)
.setPlaceholderImageScaleType(ScalingUtils.ScaleType.CENTER_CROP)
.setActualImageScaleType(ScalingUtils.ScaleType.CENTER_CROP)
.setRoundingParams(RoundingParams.asCircle())
avatar.hierarchy = builder.build()
}
/**
* 根据 HomeUserInfo 切换 UI:
* - 未登录:头像占位 + 显示登录提示 + 隐藏 nickname/expire/logout + 启动扫码 Activity
* - 已登录:头像用 Fresco 加载 + 显示昵称 + 显示到期日 + 显示退出按钮
*/
private fun applyUserState(info: HomeUserInfo) {
if (info.isLoggedIn) {
mIvAvatar?.setImageURI(info.avatarUrl)
mTvLoginHint?.visibility = View.GONE
mTvNickname?.visibility = View.VISIBLE
mTvNickname?.text = info.nickname ?: ""
mTvExpire?.visibility = View.VISIBLE
if (info.expireTime.isNullOrBlank()) {
mTvExpire?.setText(R.string.home_my_expire_empty)
} else {
mTvExpire?.text = getString(R.string.home_my_expire_format, info.expireTime)
}
mBtnLogout?.visibility = View.VISIBLE
// 已登录态:点击头像区不再启动扫码
mClUserArea?.setOnClickListener(null)
} else {
// 清空 URI:由 hierarchy 中的 placeholder(R.mipmap.my_avatar_default)渲染,
// 经 Fresco 的圆角+描边 pipeline 输出。未走 setImageResource 是为了避免绕过 hierarchy。
mIvAvatar?.setImageURI(null as String?)
mTvLoginHint?.visibility = View.VISIBLE
mTvNickname?.visibility = View.GONE
mTvExpire?.visibility = View.GONE
mBtnLogout?.visibility = View.GONE
// 未登录态:点击启动扫码
mClUserArea?.setOnClickListener {
val ctx = context ?: return@setOnClickListener
startActivity(Intent(ctx, WxMpLoginActivity::class.java))
}
}
}
/**
* 点击事件统一分发:
* - 设置 / 设备信息 / 关于我们:MgRoute 跳转对应页面(与 HotelMyFragment 一致)
* - 检测更新:第一阶段沿用酒店版占逻辑(DownApkDialog 已注释),仅 Toast 提示
* - 回到顶部:仅在宿主为 IndexActivity 时生效(与酒店版 cast 思路一致)
* - 搜索:MgRoute.go2Search(2)
* - 退出登录:先弹 HomeLogoutConfirmDialog 二次确认,确认后委托
* HomeLogoutHandler.performDeviceLogout()(与心跳 revokeCardSecret 共用路径)
*/
override fun onClick(view: View) {
val ctx = mContext ?: return
when (view.id) {
R.id.id_iv_3 -> MgRoute.go2Setting(ctx)
R.id.id_iv_device_4 -> MgRoute.go2Device(ctx)
R.id.id_iv_5 ->
// 手动触发 App 升级检查(新网络栈 POST /api/jst/device/store-version)
// 复用 UpgradeHelper.upgrade 弹框 + 下载 + 安装链路,无新弹框/无下载器改动
checkUpdate(getActivity())
R.id.id_iv_6 -> MgRoute.go2AboutMy(ctx)
R.id.id_cl_to_top -> {
// 回到顶部:仅当宿主是 IndexActivity 时调用,否则 no-op(与酒店版逻辑一致)
(ctx as? IndexActivity)?.scrollToTop()
}
R.id.id_bottom_cl_search -> MgRoute.go2Search(ctx, 2)
R.id.id_btn_home_logout -> {
// 先弹二次确认框(样式对齐家用版 ActivitedDialog/BenefitRevokedDialog),
// 确认后才执行退出;取消/返回键仅关闭弹框
val act = activity ?: return
HomeLogoutConfirmDialog(act) { performLogout() }.show()
}
}
}
/**
* 执行退出登录:委托给 [HomeLogoutHandler.performDeviceLogout],
* 与心跳收到 revokeCardSecret 时走同一条清理路径(通知后端登出 + 清 HomeUserStore + 清 SDK 卡密缓存)。
* 完成后通过 lifecycleScope 在主线程刷新 UI 状态。
*/
private fun performLogout() {
HomeLogoutHandler.performDeviceLogout()
viewLifecycleOwner.lifecycleScope.launch {
// 给协程一点时间启动网络请求,但不阻塞 UI:本地状态在 HomeLogoutHandler
// 内部异步清理完成,这里只需刷新一次 UI 即可
try {
kotlinx.coroutines.delay(300)
applyUserState(HomeUserStore.getUser())
} catch (ce: CancellationException) {
// 必须 rethrow:否则 view 销毁触发的 lifecycleScope 取消会被吞,
// applyUserState 仍在 detached Fragment 上跑 getString / setText,必崩。
throw ce
} catch (e: Throwable) {
LogUtils.e(TAG, "refresh user state after logout failed: ${e.message}")
}
}
}
/**
* 焦点态变更:复用酒店版的 TopStyleUtils.topImageChange,保持 4 个按钮的焦点样式一致。
*/
override fun onFocusChange(view: View, hasFocus: Boolean) {
TopStyleUtils.topImageChange(view, hasFocus)
}
override fun onResume() {
super.onResume()
applyUserState(HomeUserStore.getUser())
WxMpLoginManager.refreshUserState(viewLifecycleOwner.lifecycleScope) { info, hasUpdate ->
if (hasUpdate) applyUserState(info)
}
}
/**
* Activity 调用入口:触发一次完整 refresh(供 WxMpLoginActivity finish 后 / 首页 Tab 切换使用)。
*
* 注意:hide/show 模式下 view 一旦创建就常驻,但仍存在 view 销毁的窗口期——
* Activity 因后台回收/重启后,fragment 实例被重新 attach 但 view 重建尚未完成,
* 此时访问 viewLifecycleOwner 会抛 IllegalStateException(见 IndexActivity:1237 崩溃栈)。
* 因此 view 未就绪时直接 return,onResume() 会自然跑一次同步回显+异步刷新,无 UI 滞后。
*/
fun refreshUserState() {
// view 未创建/已销毁:跳过;下次 onResume() 会自然执行同样的刷新逻辑
if (view == null || !isAdded) return
applyUserState(HomeUserStore.getUser())
WxMpLoginManager.refreshUserState(viewLifecycleOwner.lifecycleScope) { info, hasUpdate ->
if (hasUpdate) applyUserState(info)
}
}
fun requestFocusFirst() {
LogUtils.d(TAG, "requestFocusFirst")
val target = mClUserArea ?: return
target.post {
if (isAdded && view != null) {
target.requestFocus()
}
}
}
}

View File

@@ -16,12 +16,11 @@ import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.fragment.app.Fragment;
import com.pukka.jiecheng.R;
import com.pukka.jiecheng.downapk.DownApkDialog;
import com.pukka.jiecheng.downapk.StoreVersionUpgrader;
import com.pukka.jiecheng.index.HotelMyActivity;
import com.pukka.jiecheng.index.IndexActivity;
import com.pukka.jiecheng.index.viewmodel.MyViewModel;
import com.pukka.jiecheng.utils.MgRoute;
import com.pukka.jiecheng.utils.ToastUtils;
import com.pukka.jiecheng.utils.TopStyleUtils;
import com.pukka.libcore.boxShared.MacUtils;
import com.pukka.libcore.utils.LogUtils;
@@ -128,14 +127,13 @@ public class HotelMyFragment extends Fragment implements View.OnClickListener ,
if (view.getId() == R.id.id_bottom_cl_search) {
MgRoute.go2Search(mContext, 2);
} else if (view.getId() == R.id.id_iv_3) {
ToastUtils.showLongToast("功能开发中");
MgRoute.go2Setting(mContext);
} else if (view.getId() == R.id.id_iv_device_4) {
MgRoute.go2Device(mContext);
} else if (view.getId() == R.id.id_iv_5) {
//检测更新
// TODO: 2025/6/10 APK更新
// new DownApkDialog(getActivity()).initDown2();
ToastUtils.showLongBottomToast("暂无新版本");
// 手动触发 App 升级检查(新网络栈 POST /api/jst/device/store-version)
// 复用 UpgradeHelper.upgrade 弹框 + 下载 + 安装链路,无新弹框/无下载器改动
StoreVersionUpgrader.checkUpdate(getActivity());
} else if (view.getId() == R.id.id_cl_to_top) {
IndexActivity indexActivity = (IndexActivity) mContext;
indexActivity.scrollToTop();
@@ -148,6 +146,18 @@ public class HotelMyFragment extends Fragment implements View.OnClickListener ,
return mSvContent;
}
public void requestFocusFirst() {
LogUtils.d("HotelMyFragment requestFocusFirst");
if (mSvContent == null) {
return;
}
mSvContent.post(() -> {
if (mClLeftUser != null) {
mClLeftUser.requestFocus();
}
});
}
@Override
public void onDestroy() {
super.onDestroy();

View File

@@ -313,6 +313,22 @@ public class MyFragment extends Fragment implements View.OnClickListener, View.O
return mSvContent;
}
public void requestFocusFirst() {
LogUtils.d("MyFragment requestFocusFirst");
if (mSvContent == null) {
return;
}
mSvContent.post(() -> {
if (getView() == null) {
return;
}
View firstTarget = getView().findViewById(R.id.id_iv_1);
if (firstTarget != null) {
firstTarget.requestFocus();
}
});
}
public static HorizontalRecyclerViewTv getRvHistory() {
return mRvHistory;
}

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