Compare commits
15 Commits
appbase-v1
...
appbase-v1
| Author | SHA1 | Date | |
|---|---|---|---|
| ad6175f977 | |||
| 8b659f4b24 | |||
| 13b841f923 | |||
| e9ad701db4 | |||
| 0aaf71f285 | |||
| 4ea2b5fad0 | |||
| 760fe4613f | |||
| a656dfcc62 | |||
| e9605fa991 | |||
| 8546b6c8ad | |||
| f5ddefa895 | |||
| 35527374da | |||
| 2751ce4a39 | |||
| 730022a9f0 | |||
| a3bc90d9b8 |
@@ -1,8 +1,8 @@
|
||||
#Created by .winboll/winboll_app_build.gradle
|
||||
#Fri Jan 23 03:11:07 HKT 2026
|
||||
stageCount=8
|
||||
#Sat Jan 24 20:32:20 HKT 2026
|
||||
stageCount=12
|
||||
libraryProject=libappbase
|
||||
baseVersion=15.15
|
||||
publishVersion=15.15.7
|
||||
publishVersion=15.15.11
|
||||
buildCount=0
|
||||
baseBetaVersion=15.15.8
|
||||
baseBetaVersion=15.15.12
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#Created by .winboll/winboll_app_build.gradle
|
||||
#Fri Jan 23 03:11:07 HKT 2026
|
||||
stageCount=8
|
||||
#Sat Jan 24 20:32:20 HKT 2026
|
||||
stageCount=12
|
||||
libraryProject=libappbase
|
||||
baseVersion=15.15
|
||||
publishVersion=15.15.7
|
||||
publishVersion=15.15.11
|
||||
buildCount=0
|
||||
baseBetaVersion=15.15.8
|
||||
baseBetaVersion=15.15.12
|
||||
|
||||
@@ -6,7 +6,17 @@
|
||||
<!-- 拥有完全的网络访问权限 -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<!-- 读取您共享存储空间中的内容 -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
|
||||
<!-- 修改或删除您共享存储空间中的内容 -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
|
||||
<!-- MANAGE_EXTERNAL_STORAGE -->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||
|
||||
<application
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<activity
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package cc.winboll.studio.libappbase.dialogs;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.libappbase.R;
|
||||
import cc.winboll.studio.libappbase.ToastUtils;
|
||||
import cc.winboll.studio.libappbase.utils.APPUtils;
|
||||
import cc.winboll.studio.libappbase.utils.ApkSignUtils;
|
||||
|
||||
/**
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @CreateTime 2026-01-20 21:20:00
|
||||
* @LastEditTime 2026-01-24 18:45:00
|
||||
* @Describe 签名显示+正版校验对话框:展示应用签名字节位信息,调用网络接口完成正版合法性校验,实时返回校验结果
|
||||
*/
|
||||
public class AppValidationDialog extends Dialog {
|
||||
// ===================================== 全局常量 =====================================
|
||||
public static final String TAG = "AppValidationDialog";
|
||||
// 签名字节位分组大小
|
||||
private static final int BIT_GROUP_SIZE = 16;
|
||||
|
||||
// ===================================== 控件与上下文属性 =====================================
|
||||
private Context mContext;
|
||||
private EditText etSignFingerprint;
|
||||
private TextView tvAuthResult;
|
||||
|
||||
// ===================================== 业务入参属性 =====================================
|
||||
private String projectName;
|
||||
private String versionName;
|
||||
private String clientSign;
|
||||
private String clientHash;
|
||||
|
||||
// ===================================== 构造方法 =====================================
|
||||
public AppValidationDialog(Context context, String projectName, String versionName) {
|
||||
super(context, R.style.DialogStyle);
|
||||
this.mContext = context;
|
||||
this.projectName = projectName;
|
||||
this.versionName = versionName;
|
||||
LogUtils.d(TAG, "AppValidationDialog: 构造方法初始化,入参-> projectName=" + projectName + ", versionName=" + versionName);
|
||||
}
|
||||
|
||||
// ===================================== 生命周期方法 =====================================
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
LogUtils.d(TAG, "onCreate: 对话框创建,开始初始化布局与业务逻辑");
|
||||
setContentView(R.layout.dialog_sign_get);
|
||||
setCancelable(true);
|
||||
// 初始化应用签名与哈希
|
||||
initSignAndHash();
|
||||
// 初始化页面控件
|
||||
initView();
|
||||
// 执行签名展示与正版校验
|
||||
doSignShowAndAuthCheck();
|
||||
LogUtils.d(TAG, "onCreate: 对话框初始化流程执行完成");
|
||||
}
|
||||
|
||||
// ===================================== 页面与数据初始化方法 =====================================
|
||||
/**
|
||||
* 初始化页面控件,绑定视图并设置基础属性
|
||||
*/
|
||||
private void initView() {
|
||||
LogUtils.d(TAG, "initView: 开始初始化页面控件");
|
||||
etSignFingerprint = findViewById(R.id.et_sign_fingerprint);
|
||||
tvAuthResult = findViewById(R.id.tv_auth_result);
|
||||
// 签名显示框设为只读,方便用户复制
|
||||
etSignFingerprint.setEnabled(false);
|
||||
// 填充签名字节位信息
|
||||
etSignFingerprint.setText(convertSignToBitArrayWithWrap(clientSign));
|
||||
LogUtils.d(TAG, "initView: 控件初始化完成,已填充签名字节位信息");
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化应用签名与SHA256哈希,调用工具类获取与服务端对齐的参数
|
||||
*/
|
||||
private void initSignAndHash() {
|
||||
LogUtils.d(TAG, "initSignAndHash: 开始获取应用签名与SHA256哈希");
|
||||
this.clientSign = ApkSignUtils.getApkSignAlignedWithServer(mContext);
|
||||
this.clientHash = ApkSignUtils.getApkSHA256Hash(mContext);
|
||||
LogUtils.d(TAG, "initSignAndHash: 签名与哈希获取完成-> clientSign=" + clientSign + ", clientHash=" + clientHash);
|
||||
}
|
||||
|
||||
// ===================================== 核心业务方法 =====================================
|
||||
/**
|
||||
* 核心业务:展示签名字节位信息,发起网络正版校验请求
|
||||
*/
|
||||
private void doSignShowAndAuthCheck() {
|
||||
LogUtils.d(TAG, "doSignShowAndAuthCheck: 开始执行应用正版合法性校验");
|
||||
// 校验签名与哈希非空,避免空参请求
|
||||
if (clientSign == null || clientHash == null) {
|
||||
String errorMsg = "应用签名或哈希获取失败,无法执行正版校验";
|
||||
LogUtils.e(TAG, "doSignShowAndAuthCheck: " + errorMsg);
|
||||
tvAuthResult.setTextColor(Color.RED);
|
||||
tvAuthResult.setText(errorMsg);
|
||||
ToastUtils.show(errorMsg);
|
||||
return;
|
||||
}
|
||||
// 调用网络校验接口
|
||||
new APPUtils().checkAPKValidation(
|
||||
mContext,
|
||||
projectName,
|
||||
versionName,
|
||||
clientSign,
|
||||
clientHash,
|
||||
new APPUtils.CheckResultCallback() {
|
||||
@Override
|
||||
public void onResult(boolean isValid, String message) {
|
||||
LogUtils.d(TAG, "checkAPKValidation: 校验结果返回-> isValid=" + isValid + ", message=" + message);
|
||||
handleAuthResult(isValid, message);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理正版校验结果,更新UI并提示用户
|
||||
* @param isValid 校验是否通过
|
||||
* @param message 服务端返回提示信息
|
||||
*/
|
||||
private void handleAuthResult(boolean isValid, String message) {
|
||||
String showMessage;
|
||||
if (isValid) {
|
||||
showMessage = "< 这是正版的 WinBoLL 应用,请放心使用。 >";
|
||||
tvAuthResult.setTextColor(Color.BLUE);
|
||||
LogUtils.d(TAG, "handleAuthResult: 正版校验通过," + showMessage + ",服务端信息:" + message);
|
||||
} else {
|
||||
showMessage = "< 您使用的可能不是正版的 WinBoLL 应用。 >";
|
||||
tvAuthResult.setTextColor(Color.RED);
|
||||
LogUtils.e(TAG, "handleAuthResult: 正版校验失败," + showMessage + ",失败原因:" + message);
|
||||
}
|
||||
// 更新UI并弹提示
|
||||
tvAuthResult.setText(showMessage);
|
||||
ToastUtils.show(showMessage);
|
||||
}
|
||||
|
||||
// ===================================== 工具方法 =====================================
|
||||
/**
|
||||
* 签名字符串转0/1比特数组格式:每2个bit加空格,每16位换行,提升可读性
|
||||
* @param signStr 原始签名字符串
|
||||
* @return 格式化后的比特数字符串,签名字符为空返回空串
|
||||
*/
|
||||
private String convertSignToBitArrayWithWrap(String signStr) {
|
||||
LogUtils.d(TAG, "convertSignToBitArrayWithWrap: 开始格式化签名字符串为比特数组");
|
||||
if (signStr == null || signStr.isEmpty()) {
|
||||
LogUtils.w(TAG, "convertSignToBitArrayWithWrap: 原始签名字符串为空,返回空串");
|
||||
return "";
|
||||
}
|
||||
// 字符转8位补零的二进制字符串
|
||||
StringBuilder bitBuilder = new StringBuilder();
|
||||
for (char c : signStr.toCharArray()) {
|
||||
String bit8 = String.format("%8s", Integer.toBinaryString(c)).replace(' ', '0');
|
||||
bitBuilder.append(bit8);
|
||||
}
|
||||
String fullBitStr = bitBuilder.toString();
|
||||
LogUtils.d(TAG, "convertSignToBitArrayWithWrap: 签名转二进制完成,总长度=" + fullBitStr.length() + "bit");
|
||||
|
||||
// 按16位分组,组内每2bit加空格,分组后换行
|
||||
StringBuilder finalBuilder = new StringBuilder();
|
||||
for (int i = 0; i < fullBitStr.length(); i += BIT_GROUP_SIZE) {
|
||||
int end = Math.min(i + BIT_GROUP_SIZE, fullBitStr.length());
|
||||
String group = fullBitStr.substring(i, end);
|
||||
// 组内加空格
|
||||
StringBuilder groupWithSpace = new StringBuilder();
|
||||
for (int j = 0; j < group.length(); j++) {
|
||||
groupWithSpace.append(group.charAt(j));
|
||||
if ((j + 1) % 2 == 0 && j != group.length() - 1) {
|
||||
groupWithSpace.append(" ");
|
||||
}
|
||||
}
|
||||
finalBuilder.append(groupWithSpace);
|
||||
// 最后一组不换行
|
||||
if (end < fullBitStr.length()) {
|
||||
finalBuilder.append("\n");
|
||||
}
|
||||
}
|
||||
LogUtils.d(TAG, "convertSignToBitArrayWithWrap: 签名比特数组格式化完成");
|
||||
return finalBuilder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
package cc.winboll.studio.libappbase.dialogs;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.libappbase.R;
|
||||
import cc.winboll.studio.libappbase.ToastUtils;
|
||||
import cc.winboll.studio.libappbase.utils.APPUtils;
|
||||
import cc.winboll.studio.libappbase.utils.SignGetUtils;
|
||||
|
||||
/**
|
||||
* @Describe 签名显示+正版校验对话框
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/20 21:20:00
|
||||
* @LastEditTime 2026/01/21 11:00:00
|
||||
*/
|
||||
public class SignGetDialog extends Dialog {
|
||||
public static final String TAG = "SignGetDialog";
|
||||
private EditText etSignFingerprint;
|
||||
private TextView tvAuthResult;
|
||||
private Context mContext;
|
||||
|
||||
public SignGetDialog(Context context) {
|
||||
super(context, R.style.DialogStyle); // 适配默认对话框样式
|
||||
this.mContext = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.dialog_sign_get); // 绑定xml布局
|
||||
setCancelable(true); // 点击外部可关闭
|
||||
initView();
|
||||
initSignAndCheck(); // 获取签名+正版校验
|
||||
}
|
||||
|
||||
private void initView() {
|
||||
etSignFingerprint = findViewById(R.id.et_sign_fingerprint);
|
||||
tvAuthResult = findViewById(R.id.tv_auth_result);
|
||||
// 输入框只读,方便复制
|
||||
etSignFingerprint.setEnabled(false);
|
||||
}
|
||||
|
||||
// 核心:获取签名+调用APPUtils校验
|
||||
private void initSignAndCheck() {
|
||||
// 1. 获取当前应用签名
|
||||
String sign = getCurrentSign();
|
||||
if (sign == null) {
|
||||
etSignFingerprint.setText("签名获取失败");
|
||||
} else {
|
||||
// 签名字符串转0/1 bit数组(每2个bit加空格,每16位换行,下一行无前置空格)
|
||||
String bitArrayStr = convertSignToBitArrayWithWrap(sign);
|
||||
etSignFingerprint.setText(bitArrayStr);
|
||||
}
|
||||
LogUtils.d(TAG, "当前应用签名:" + sign);
|
||||
|
||||
// 2. 正版校验+显示结果
|
||||
APPUtils.checkAppValid(mContext, new APPUtils.CheckResultCallback() {
|
||||
@Override
|
||||
public void onResult(boolean isValid, String message) {
|
||||
String szOfficialMessage;
|
||||
// if (isValid) {
|
||||
// // 校验通过,执行正常逻辑
|
||||
// } else {
|
||||
// // 校验失败,提示用户
|
||||
// ToastUtils.show(message);
|
||||
// }
|
||||
if (isValid) {
|
||||
LogUtils.d(TAG, "校验通过:" + message);
|
||||
szOfficialMessage = "< 这是正版的 WinBoLL 应用,请放心使用。 >";
|
||||
tvAuthResult.setTextColor(Color.BLUE);
|
||||
} else {
|
||||
LogUtils.e(TAG, "校验失败:" + message);
|
||||
szOfficialMessage = "< 您使用的可能不是正版的 WinBoLL 应用。 >";
|
||||
tvAuthResult.setTextColor(Color.RED);
|
||||
}
|
||||
ToastUtils.show(szOfficialMessage);
|
||||
tvAuthResult.setText(szOfficialMessage);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
// 核心修改:签名字符串转0/1 bit数组(每2个bit加空格,每16位换行,下一行无前置空格)
|
||||
private String convertSignToBitArrayWithWrap(String signStr) {
|
||||
StringBuilder bitBuilder = new StringBuilder();
|
||||
// 1. 字符转8位bit
|
||||
for (char c : signStr.toCharArray()) {
|
||||
String bit8 = String.format("%8s", Integer.toBinaryString(c)).replace(' ', '0');
|
||||
bitBuilder.append(bit8);
|
||||
}
|
||||
String fullBitStr = bitBuilder.toString();
|
||||
|
||||
// 2. 按16位分组,组内每2个bit加空格(避免换行后带空格)
|
||||
StringBuilder finalBuilder = new StringBuilder();
|
||||
int groupSize = 16; // 每组16个bit
|
||||
for (int i = 0; i < fullBitStr.length(); i += groupSize) {
|
||||
// 截取16位bit为一组
|
||||
int end = Math.min(i + groupSize, fullBitStr.length());
|
||||
String group = fullBitStr.substring(i, end);
|
||||
|
||||
// 组内每2个bit加空格
|
||||
StringBuilder groupWithSpace = new StringBuilder();
|
||||
for (int j = 0; j < group.length(); j++) {
|
||||
groupWithSpace.append(group.charAt(j));
|
||||
if ((j + 1) % 2 == 0 && j != group.length() - 1) {
|
||||
groupWithSpace.append(" ");
|
||||
}
|
||||
}
|
||||
|
||||
// 添加组到最终结果,每组后换行(最后一组不换行)
|
||||
finalBuilder.append(groupWithSpace);
|
||||
if (end < fullBitStr.length()) {
|
||||
finalBuilder.append("\n");
|
||||
}
|
||||
}
|
||||
return finalBuilder.toString();
|
||||
}
|
||||
|
||||
// 获取签名(复用SignGetUtils逻辑,避免重复代码)
|
||||
private String getCurrentSign() {
|
||||
try {
|
||||
return SignGetUtils.getSignStr(mContext); // 复用工具类逻辑
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "获取签名失败", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 校验签名是否合法(匹配APPUtils目标签名)
|
||||
// private boolean isSignValid() {
|
||||
// String currentSign = getCurrentSign();
|
||||
// String targetSign = APPUtils.TARGET_SIGN_FINGERPRINT; // 取APPUtils目标签名
|
||||
// return currentSign != null && targetSign != null && currentSign.equals(targetSign);
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
package cc.winboll.studio.libappbase.utils;
|
||||
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
|
||||
/**
|
||||
* APK文件工具类(单例)- 生产级签名+哈希双校验版(修复Too short异常)
|
||||
* 1. 稳定解析CERT.RSA原始字节,与客户端Signature.toByteArray()1:1对齐,解决X509解析异常
|
||||
* 2. 支持SHA256文件哈希字节级唯一校验,签名+哈希双重验证
|
||||
* 3. 入参包含:项目名/版本名/APK名/客户端签名/客户端哈希,适配生产级版本管理
|
||||
* 4. APK路径规范:apks_root/项目名/debug/tag/APK文件(支持调试/正式环境)
|
||||
* @Author ZhanGSKen<zhangsken@qq.com>
|
||||
*/
|
||||
public class APKFileUtils {
|
||||
// 单例实例
|
||||
private static volatile APKFileUtils sInstance;
|
||||
// 配置项
|
||||
private static final String CONFIG_SECTION = "APP";
|
||||
private static final String KEY_APKS_FOLDER = "apks_folder_path";
|
||||
// 算法常量(与客户端严格对齐)
|
||||
private static final String SIGN_ALGORITHM = "SHA1"; // 签名摘要算法
|
||||
private static final String HASH_ALGORITHM = "SHA-256"; // 文件哈希算法
|
||||
// 签名文件(兼容大小写,适配所有打包工具)
|
||||
private static final String CERT_RSA_UPPER = "META-INF/CERT.RSA";
|
||||
private static final String CERT_RSA_LOWER = "META-INF/cert.rsa";
|
||||
// APK根目录
|
||||
private String apksRootPath;
|
||||
|
||||
private APKFileUtils() {}
|
||||
|
||||
/**
|
||||
* 初始化工具类(需在应用启动时调用)
|
||||
*/
|
||||
public static void init() {
|
||||
if (sInstance == null) {
|
||||
synchronized (APKFileUtils.class) {
|
||||
if (sInstance == null) {
|
||||
sInstance = new APKFileUtils();
|
||||
//sInstance.loadConfig();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*/
|
||||
public static APKFileUtils getInstance() {
|
||||
if (sInstance == null) {
|
||||
LogUtils.e("APKFileUtils", "请先调用init()初始化工具类");
|
||||
throw new IllegalStateException("APKFileUtils未初始化,请先调用init()");
|
||||
}
|
||||
return sInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载配置文件中的APK根目录
|
||||
*/
|
||||
// private void loadConfig() {
|
||||
// try {
|
||||
// apksRootPath = IniConfigUtils.getConfigValue(CONFIG_SECTION, KEY_APKS_FOLDER, "").trim();
|
||||
// if (apksRootPath.isEmpty()) {
|
||||
// LogUtils.e("APKFileUtils", "配置项apks_folder_path为空,初始化失败");
|
||||
// return;
|
||||
// }
|
||||
// File rootDir = new File(apksRootPath);
|
||||
// if (!rootDir.exists() && !rootDir.mkdirs()) {
|
||||
// LogUtils.e("APKFileUtils", "APK根目录创建失败:" + apksRootPath);
|
||||
// apksRootPath = "";
|
||||
// return;
|
||||
// }
|
||||
// LogUtils.i("APKFileUtils", "APK根目录加载成功:" + apksRootPath);
|
||||
// } catch (Exception e) {
|
||||
// LogUtils.e("APKFileUtils", "加载APK根目录配置失败", e);
|
||||
// apksRootPath = "";
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* 对外暴露核心校验方法:签名 + SHA256文件哈希 双校验
|
||||
* 入参包含:项目名/版本名/APK文件名/客户端签名Base64/客户端文件哈希
|
||||
* APK路径规范:apksRootPath/项目名/版本名/APK文件
|
||||
* @param projectName 项目名(非空)
|
||||
* @param versionName 版本名(非空,如15.11.11)
|
||||
* @param apkFileName APK文件名(非空,需以.apk结尾)
|
||||
* @param clientSignBase64 客户端传入的签名Base64(非空)
|
||||
* @param clientFileHash 客户端传入的APK文件SHA256哈希(小写/大写均可,非空)
|
||||
* @return 校验通过返回true,否则false
|
||||
*/
|
||||
public static boolean checkAPK(String projectName, String versionName, String apkFileName,
|
||||
String clientSignBase64, String clientFileHash) {
|
||||
return getInstance().doCheckAPK(projectName, versionName, apkFileName, clientSignBase64, clientFileHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心校验实现:严格按「哈希先验,签名后验」顺序,哈希不匹配直接返回
|
||||
*/
|
||||
private boolean doCheckAPK(String projectName, String versionName, String apkFileName,
|
||||
String clientSignBase64, String clientFileHash) {
|
||||
// 1. 基础入参非空校验
|
||||
if (isParamEmpty(projectName) || isParamEmpty(versionName) || isParamEmpty(apkFileName)
|
||||
|| isParamEmpty(clientSignBase64) || isParamEmpty(clientFileHash)) {
|
||||
LogUtils.w("APKFileUtils", "基础参数不能为空:projectName/versionName/apkFileName/clientSignBase64/clientFileHash");
|
||||
return false;
|
||||
}
|
||||
// 2. APK文件名格式校验
|
||||
if (!apkFileName.endsWith(".apk")) {
|
||||
LogUtils.w("APKFileUtils", "APK文件名格式错误,需以.apk结尾:" + apkFileName);
|
||||
return false;
|
||||
}
|
||||
// 3. APK根目录校验
|
||||
if (isParamEmpty(apksRootPath)) {
|
||||
LogUtils.w("APKFileUtils", "APK根目录未配置,无法进行校验");
|
||||
return false;
|
||||
}
|
||||
// 4. 拼接标准APK路径:根目录/项目名/debug/项目名_版本名.apk(调试环境,可切换tag)
|
||||
String apkFullPath = String.format("%s/%s/debug/%s_%s.apk",
|
||||
apksRootPath,
|
||||
projectName,
|
||||
projectName,
|
||||
versionName);
|
||||
//正式环境路径(注释保留,切换时解开即可)
|
||||
// String apkFullPath = String.format("%s/%s/tag/%s_%s.apk",
|
||||
// apksRootPath,
|
||||
// projectName,
|
||||
// projectName,
|
||||
// versionName);
|
||||
LogUtils.d("APKFileUtils", String.format("apkFullPath : %s", apkFullPath));
|
||||
File apkFile = new File(apkFullPath);
|
||||
// 5. APK文件存在性校验
|
||||
if (!apkFile.exists() || !apkFile.isFile()) {
|
||||
LogUtils.w("APKFileUtils", "APK文件不存在或非文件类型:" + apkFullPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// ===== 第一步:SHA256文件哈希校验(字节级唯一,优先级最高)=====
|
||||
String serverFileHash = getAPKFileHash(apkFile);
|
||||
if (isParamEmpty(serverFileHash)) {
|
||||
LogUtils.w("APKFileUtils", "解析服务端APK文件哈希失败:" + apkFileName);
|
||||
return false;
|
||||
}
|
||||
boolean isHashMatch = serverFileHash.equalsIgnoreCase(clientFileHash.trim());
|
||||
LogUtils.d("APKFileUtils", "【哈希对比】服务端SHA256:" + serverFileHash);
|
||||
LogUtils.d("APKFileUtils", "【哈希对比】客户端SHA256:" + clientFileHash.trim());
|
||||
if (!isHashMatch) {
|
||||
LogUtils.i("APKFileUtils", "【哈希对比结果】❌ 不匹配(字节级文件不一致)");
|
||||
return false;
|
||||
}
|
||||
LogUtils.i("APKFileUtils", "【哈希对比结果】✅ 匹配(字节级文件完全一致)");
|
||||
|
||||
// ===== 第二步:签名校验(直接读取CERT.RSA原始字节,与客户端严格对齐)=====
|
||||
String serverSignBase64 = getAPKSign(apkFile);
|
||||
if (isParamEmpty(serverSignBase64)) {
|
||||
LogUtils.w("APKFileUtils", "解析服务端APK签名失败:" + apkFileName);
|
||||
return false;
|
||||
}
|
||||
boolean isSignMatch = serverSignBase64.equals(clientSignBase64.trim());
|
||||
LogUtils.d("APKFileUtils", "【签名对比】服务端Base64:" + serverSignBase64);
|
||||
LogUtils.d("APKFileUtils", "【签名对比】客户端Base64:" + clientSignBase64.trim());
|
||||
if (!isSignMatch) {
|
||||
LogUtils.i("APKFileUtils", "【签名对比结果】❌ 不匹配(签名不一致)");
|
||||
return false;
|
||||
}
|
||||
LogUtils.i("APKFileUtils", "【签名对比结果】✅ 匹配(签名完全一致)");
|
||||
|
||||
// 所有校验通过
|
||||
LogUtils.i("APKFileUtils", "APK双校验全部通过:项目名=" + projectName + ",版本名=" + versionName + ",文件名=" + apkFileName);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
LogUtils.e("APKFileUtils", "APK双校验异常", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 稳定解析APK签名:直接读取CERT.RSA原始字节,SHA1+Base64(与客户端1:1对齐)
|
||||
* 解决X509证书解析的Too short异常,兼容所有APK(普通/加固/自定义打包)
|
||||
* @param apkFile APK文件
|
||||
* @return 签名Base64字符串,失败返回null
|
||||
*/
|
||||
private String getAPKSign(File apkFile) {
|
||||
JarFile jarFile = null;
|
||||
InputStream certIs = null;
|
||||
try {
|
||||
jarFile = new JarFile(apkFile);
|
||||
// 先找大写CERT.RSA,找不到再找小写,兼容所有打包工具
|
||||
JarEntry certEntry = jarFile.getJarEntry(CERT_RSA_UPPER);
|
||||
if (certEntry == null) {
|
||||
certEntry = jarFile.getJarEntry(CERT_RSA_LOWER);
|
||||
if (certEntry == null) {
|
||||
LogUtils.w("APKFileUtils", "APK中未找到签名文件:META-INF/CERT.RSA/cert.rsa");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// 核心:直接读取CERT.RSA的原始字节流(不做证书解析,适配PKCS7签名块)
|
||||
certIs = jarFile.getInputStream(certEntry);
|
||||
byte[] sigRawBytes = readStreamToBytes(certIs);
|
||||
if (sigRawBytes == null || sigRawBytes.length == 0) {
|
||||
LogUtils.w("APKFileUtils", "读取CERT.RSA原始字节为空");
|
||||
return null;
|
||||
}
|
||||
// 与客户端完全一致的处理流程:SHA1摘要 → Base64编码(去换行)
|
||||
MessageDigest md = MessageDigest.getInstance(SIGN_ALGORITHM);
|
||||
byte[] signDigest = md.digest(sigRawBytes);
|
||||
String signBase64 = Base64.getEncoder().encodeToString(signDigest)
|
||||
.replaceAll("\\r", "").replaceAll("\\n", "");
|
||||
|
||||
LogUtils.d("APKFileUtils", "APK签名解析成功(Base64):" + signBase64);
|
||||
return signBase64;
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
LogUtils.e("APKFileUtils", "解析签名失败:" + SIGN_ALGORITHM + "算法不存在", e);
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
LogUtils.e("APKFileUtils", "解析APK签名异常", e);
|
||||
return null;
|
||||
} finally {
|
||||
// 强制关闭流资源,避免内存泄漏
|
||||
try {
|
||||
if (certIs != null) certIs.close();
|
||||
if (jarFile != null) jarFile.close();
|
||||
} catch (IOException e) {
|
||||
LogUtils.e("APKFileUtils", "关闭签名文件流失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析APK文件的SHA256哈希(字节级唯一,任何字节修改都会改变)
|
||||
* @param apkFile APK文件
|
||||
* @return 小写64位SHA256哈希字符串,失败返回null
|
||||
*/
|
||||
private String getAPKFileHash(File apkFile) {
|
||||
FileInputStream fis = null;
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance(HASH_ALGORITHM);
|
||||
fis = new FileInputStream(apkFile);
|
||||
byte[] buffer = new byte[8192]; // 8K缓冲区,提升大APK读取效率
|
||||
int len;
|
||||
while ((len = fis.read(buffer)) != -1) {
|
||||
md.update(buffer, 0, len);
|
||||
}
|
||||
// 哈希字节转小写16进制字符串(64位,官方标准格式)
|
||||
byte[] hashBytes = md.digest();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
String fileHash = sb.toString();
|
||||
LogUtils.d("APKFileUtils", "APK文件SHA256哈希解析成功:" + fileHash);
|
||||
return fileHash;
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
LogUtils.e("APKFileUtils", "获取文件哈希失败:" + HASH_ALGORITHM + "算法不存在", e);
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
LogUtils.e("APKFileUtils", "解析APK文件哈希异常", e);
|
||||
return null;
|
||||
} finally {
|
||||
if (fis != null) {
|
||||
try {
|
||||
fis.close();
|
||||
} catch (IOException e) {
|
||||
LogUtils.e("APKFileUtils", "关闭APK文件流失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 流转字节数组工具方法:稳定读取任意输入流,无截断/空指针问题
|
||||
*/
|
||||
private byte[] readStreamToBytes(InputStream is) throws IOException {
|
||||
if (is == null) {
|
||||
LogUtils.w("APKFileUtils", "readStreamToBytes: 输入流为null");
|
||||
return new byte[0];
|
||||
}
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[4096];
|
||||
int len;
|
||||
while ((len = is.read(buffer)) != -1) {
|
||||
bos.write(buffer, 0, len);
|
||||
}
|
||||
byte[] result = bos.toByteArray();
|
||||
// 按顺序关闭流
|
||||
is.close();
|
||||
bos.close();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具方法:判断参数是否为空(null/空字符串/全空格)
|
||||
*/
|
||||
private boolean isParamEmpty(String param) {
|
||||
return param == null || param.trim().isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
package cc.winboll.studio.libappbase.utils;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.Signature;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Base64;
|
||||
|
||||
import cc.winboll.studio.libappbase.GlobalApplication;
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.libappbase.models.SignCheckResponse;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Date;
|
||||
|
||||
import okhttp3.Call;
|
||||
import okhttp3.Callback;
|
||||
import okhttp3.OkHttpClient;
|
||||
@@ -21,171 +24,174 @@ import okhttp3.Response;
|
||||
|
||||
/**
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/20 19:17
|
||||
* @Describe APPUtils 应用包名、签名校验工具类(OKHTTP网络校验版)
|
||||
* @CreateTime 2026-01-20 19:17:00
|
||||
* @LastEditTime 2026-01-24 17:58:00
|
||||
* @Describe APPUtils 应用合法性校验工具类(OKHTTP网络校验版,兼容Java7)
|
||||
* 对外传入签名/哈希值,拼接调试标识后发起网络校验,主线程返回校验结果
|
||||
*/
|
||||
public class APPUtils {
|
||||
// ===================================== 全局常量/单例属性 =====================================
|
||||
public static final String TAG = "APPUtils";
|
||||
// 网络校验接口地址
|
||||
// 网络校验接口基础地址
|
||||
private static final String CHECK_API_URI = "api/app-signatures-check";
|
||||
// OKHTTP客户端(单例复用)
|
||||
private static OkHttpClient sOkHttpClient = new OkHttpClient();
|
||||
// Gson解析实例
|
||||
private static Gson sGson = new Gson();
|
||||
// OKHTTP客户端单例(复用连接,避免资源浪费)
|
||||
private static final OkHttpClient sOkHttpClient = new OkHttpClient();
|
||||
// Gson解析单例(全局复用,提高解析效率)
|
||||
private static final Gson sGson = new Gson();
|
||||
|
||||
// ===================================== 对外核心校验方法 =====================================
|
||||
/**
|
||||
* 检查应用合法性(包名校验+OKHTTP网络校验签名)
|
||||
* @param context 上下文
|
||||
* @param callback 校验结果回调(主线程回调)
|
||||
* 检查应用合法性(外部传入签名+哈希,拼接调试标识发起网络校验)
|
||||
* @param context 上下文,用于主线程回调
|
||||
* @param projectName 项目名称(服务端区分项目标识)
|
||||
* @param versionName 应用版本名(服务端版本校验)
|
||||
* @param clientSign 外部计算的应用签名字符串(Base64)
|
||||
* @param clientHash 外部计算的APK SHA256哈希字符串(小写16进制)
|
||||
* @param callback 校验结果回调(主线程调用,返回是否合法+提示信息)
|
||||
*/
|
||||
public static void checkAppValid(Context context, final CheckResultCallback callback) {
|
||||
public void checkAPKValidation(Context context, String projectName, String versionName,
|
||||
String clientSign, String clientHash, final CheckResultCallback callback) {
|
||||
// 方法调用+全量入参调试日志
|
||||
LogUtils.d(TAG, "checkAPKValidation: 方法调用,入参-> projectName=" + projectName
|
||||
+ ", versionName=" + versionName + ", clientSign=" + clientSign + ", clientHash=" + clientHash);
|
||||
|
||||
// 1. 核心入参空值校验(快速失败)
|
||||
if (context == null) {
|
||||
LogUtils.w(TAG, "checkAppValid: context为空,跳过校验");
|
||||
if (callback != null) callback.onResult(false, "context为空");
|
||||
LogUtils.w(TAG, "checkAPKValidation: 入参context为空,直接返回校验失败");
|
||||
callCallbackOnMainThread(callback, false, "上下文对象不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 获取当前应用签名(SHA1+Base64)和证书生效时间
|
||||
String currentSign = getAppSignFingerprint(context);
|
||||
long certValidTime = getCertValidTime(context); // 证书生效时间(毫秒时间戳)
|
||||
if (currentSign == null) {
|
||||
String errorMsg = "获取应用签名失败";
|
||||
LogUtils.e(TAG, "checkAppValid: " + errorMsg);
|
||||
if (callback != null) callback.onResult(false, errorMsg);
|
||||
if (isStringEmpty(projectName)) {
|
||||
LogUtils.w(TAG, "checkAPKValidation: 入参projectName为空/空白,直接返回校验失败");
|
||||
callCallbackOnMainThread(callback, false, "项目名称不能为空");
|
||||
return;
|
||||
}
|
||||
if (isStringEmpty(versionName)) {
|
||||
LogUtils.w(TAG, "checkAPKValidation: 入参versionName为空/空白,直接返回校验失败");
|
||||
callCallbackOnMainThread(callback, false, "应用版本名不能为空");
|
||||
return;
|
||||
}
|
||||
if (isStringEmpty(clientSign)) {
|
||||
LogUtils.w(TAG, "checkAPKValidation: 入参clientSign为空/空白,直接返回校验失败");
|
||||
callCallbackOnMainThread(callback, false, "应用签名字符串不能为空");
|
||||
return;
|
||||
}
|
||||
if (isStringEmpty(clientHash)) {
|
||||
LogUtils.w(TAG, "checkAPKValidation: 入参clientHash为空/空白,直接返回校验失败");
|
||||
callCallbackOnMainThread(callback, false, "APK SHA256哈希字符串不能为空");
|
||||
return;
|
||||
}
|
||||
LogUtils.d(TAG, "checkAPKValidation: 入参校验通过,开始处理网络请求");
|
||||
|
||||
// 新增:对currentSign进行Base64二次加密(URL安全编码,避免特殊字符)
|
||||
String encryptedSign = base64Encode(currentSign);
|
||||
LogUtils.d(TAG, "checkAppValid: 原始签名=" + currentSign + ",Base64二次加密后=" + encryptedSign);
|
||||
// 2. 动态参数URL编码(避免特殊字符导致请求解析异常)
|
||||
LogUtils.d(TAG, "checkAPKValidation: 开始对动态参数进行UTF-8 URL编码");
|
||||
String encodeProjectName = urlEncode(projectName);
|
||||
String encodeVersionName = urlEncode(versionName);
|
||||
String encodeClientSign = urlEncode(clientSign);
|
||||
String encodeClientHash = urlEncode(clientHash);
|
||||
String isDebug = String.valueOf(GlobalApplication.isDebugging());
|
||||
LogUtils.d(TAG, "checkAPKValidation: 参数编码完成,debug标识=" + isDebug);
|
||||
|
||||
// 3. 构建请求URL(拼接加密后的签名参数)
|
||||
String requestUrl = String.format("%s?signature=%s&validTime=%d",
|
||||
GlobalApplication.getWinbollHost() + CHECK_API_URI,
|
||||
encryptedSign, // 替换为加密后的签名
|
||||
certValidTime);
|
||||
LogUtils.d(TAG, "checkAppValid: 发起网络校验请求,URL=" + requestUrl);
|
||||
// 3. 构建完整网络校验请求URL
|
||||
String requestUrl = String.format("%s?isDebug=%s&projectName=%s&versionName=%s&clientSign=%s&clientHash=%s",
|
||||
GlobalApplication.getWinbollHost() + CHECK_API_URI,
|
||||
isDebug,
|
||||
encodeProjectName,
|
||||
encodeVersionName,
|
||||
encodeClientSign,
|
||||
encodeClientHash);
|
||||
LogUtils.d(TAG, "checkAPKValidation: 构建网络校验请求URL=" + requestUrl);
|
||||
|
||||
// 4. OKHTTP发起异步GET请求
|
||||
// 4. 发起OKHTTP异步GET请求(避免阻塞主线程)
|
||||
LogUtils.d(TAG, "checkAPKValidation: 发起异步网络校验请求");
|
||||
Request request = new Request.Builder().url(requestUrl).build();
|
||||
sOkHttpClient.newCall(request).enqueue(new Callback() {
|
||||
@Override
|
||||
public void onFailure(Call call, IOException e) {
|
||||
final String errorMsg = "网络校验请求失败:" + e.getMessage();
|
||||
LogUtils.e(TAG, "checkAppValid: " + errorMsg, e);
|
||||
if (callback != null) {
|
||||
// 切换到主线程回调
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
callback.onResult(false, errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
String errorMsg = "网络校验请求失败:" + e.getMessage();
|
||||
LogUtils.e(TAG, "checkAPKValidation: " + errorMsg, e);
|
||||
callCallbackOnMainThread(callback, false, errorMsg);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResponse(Call call, Response response) throws IOException {
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
// 响应成功,解析返回JSON
|
||||
String responseJson = response.body().string();
|
||||
LogUtils.d(TAG, "checkAppValid: 网络校验响应JSON=" + responseJson);
|
||||
// 解析JSON响应
|
||||
LogUtils.d(TAG, "checkAPKValidation: 网络校验响应成功,JSON=" + responseJson);
|
||||
SignCheckResponse checkResponse = sGson.fromJson(responseJson, SignCheckResponse.class);
|
||||
final boolean isValid = checkResponse != null && checkResponse.isValid();
|
||||
final String msg = checkResponse != null ? checkResponse.getMessage() : "响应解析失败";
|
||||
if (callback != null) {
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
callback.onResult(isValid, msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
boolean isValid = checkResponse != null && checkResponse.isValid();
|
||||
String msg = checkResponse != null ? checkResponse.getMessage() : "服务端响应解析失败";
|
||||
LogUtils.d(TAG, "checkAPKValidation: 校验结果解析完成,isValid=" + isValid + ", 提示信息=" + msg);
|
||||
callCallbackOnMainThread(callback, isValid, msg);
|
||||
} else {
|
||||
final String errorMsg = "网络校验响应失败,code=" + response.code();
|
||||
LogUtils.e(TAG, "checkAppValid: " + errorMsg);
|
||||
if (callback != null) {
|
||||
new android.os.Handler(android.os.Looper.getMainLooper()).post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
callback.onResult(false, errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
// 响应失败,返回状态码信息
|
||||
String errorMsg = "网络校验响应失败,服务端状态码=" + response.code();
|
||||
LogUtils.e(TAG, "checkAPKValidation: " + errorMsg);
|
||||
callCallbackOnMainThread(callback, false, errorMsg);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===================================== 内部工具方法 =====================================
|
||||
/**
|
||||
* 新增:Base64加密工具(URL安全编码,避免特殊字符影响URL拼接)
|
||||
* @param content 待加密内容
|
||||
* @return 加密后的Base64字符串
|
||||
* 字符串空值/空白校验工具
|
||||
* @param str 待校验字符串
|
||||
* @return true=空/空白,false=非空
|
||||
*/
|
||||
private static String base64Encode(String content) {
|
||||
private boolean isStringEmpty(String str) {
|
||||
return str == null || str.trim().isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* URL编码工具(Java7适配,UTF-8编码,处理特殊字符)
|
||||
* @param content 待编码内容
|
||||
* @return 编码后的字符串,编码失败返回原内容
|
||||
*/
|
||||
private String urlEncode(String content) {
|
||||
try {
|
||||
// 使用URL安全的Base64编码(替换+为-,/为_,去除=)
|
||||
byte[] contentBytes = content.getBytes("UTF-8");
|
||||
return Base64.encodeToString(contentBytes, Base64.URL_SAFE | Base64.NO_PADDING | Base64.NO_WRAP);
|
||||
return URLEncoder.encode(content, "UTF-8");
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "base64Encode: 加密失败", e);
|
||||
return content; // 加密失败则返回原始内容,避免请求异常
|
||||
LogUtils.e(TAG, "urlEncode: 字符串编码失败,content=" + content, e);
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前应用签名SHA1指纹(BASE64编码)
|
||||
* 主线程执行回调(统一处理,避免外部线程切换)
|
||||
* @param callback 回调接口
|
||||
* @param isValid 是否合法
|
||||
* @param message 提示信息
|
||||
*/
|
||||
private static String getAppSignFingerprint(Context context) {
|
||||
try {
|
||||
PackageManager pm = context.getPackageManager();
|
||||
PackageInfo pkgInfo = pm.getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
|
||||
Signature[] signatures = pkgInfo.signatures;
|
||||
if (signatures == null || signatures.length == 0) {
|
||||
LogUtils.w(TAG, "getAppSignFingerprint: 未获取到应用签名");
|
||||
return null;
|
||||
}
|
||||
MessageDigest md = MessageDigest.getInstance("SHA1");
|
||||
md.update(signatures[0].toByteArray());
|
||||
return Base64.encodeToString(md.digest(), Base64.NO_WRAP);
|
||||
} catch (PackageManager.NameNotFoundException | NoSuchAlgorithmException e) {
|
||||
LogUtils.e(TAG, "getAppSignFingerprint: 获取签名异常", e);
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "getAppSignFingerprint: 未知异常", e);
|
||||
private void callCallbackOnMainThread(final CheckResultCallback callback,
|
||||
final boolean isValid, final String message) {
|
||||
if (callback == null) {
|
||||
LogUtils.w(TAG, "callCallbackOnMainThread: 回调接口为null,无需执行");
|
||||
return;
|
||||
}
|
||||
// 已在主线程直接执行,否则切换主线程
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
callback.onResult(isValid, message);
|
||||
} else {
|
||||
new Handler(Looper.getMainLooper()).post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
callback.onResult(isValid, message);
|
||||
}
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===================================== 校验结果回调接口 =====================================
|
||||
/**
|
||||
* 获取应用证书生效时间(毫秒时间戳)
|
||||
* 应用合法性校验结果回调接口(主线程调用)
|
||||
*/
|
||||
private static long getCertValidTime(Context context) {
|
||||
try {
|
||||
PackageManager pm = context.getPackageManager();
|
||||
PackageInfo pkgInfo = pm.getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
|
||||
Signature[] signatures = pkgInfo.signatures;
|
||||
if (signatures == null || signatures.length == 0) {
|
||||
LogUtils.w(TAG, "getCertValidTime: 未获取到应用签名");
|
||||
return new Date().getTime(); // 默认当前时间
|
||||
}
|
||||
// 解析签名证书,获取生效时间(简化实现,实际需解析X.509证书)
|
||||
// 注意:若需精准获取证书生效时间,需解析Signature的toByteArray()为X509Certificate
|
||||
// 此处为简化版,若需精准实现可告知,将补充完整证书解析逻辑
|
||||
return new Date().getTime();
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
LogUtils.e(TAG, "getCertValidTime: 获取包信息异常", e);
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "getCertValidTime: 未知异常", e);
|
||||
}
|
||||
return new Date().getTime();
|
||||
}
|
||||
|
||||
// ==================== 校验结果回调接口 ====================
|
||||
public interface CheckResultCallback {
|
||||
/**
|
||||
* 校验结果回调(主线程调用)
|
||||
* @param isValid 是否合法
|
||||
* @param message 校验信息
|
||||
* 校验结果回调方法
|
||||
* @param isValid 是否合法(true=校验通过,false=校验失败)
|
||||
* @param message 校验提示信息(失败时返回错误原因,成功时返回服务端提示)
|
||||
*/
|
||||
void onResult(boolean isValid, String message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
package cc.winboll.studio.libappbase.utils;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.Signature;
|
||||
import android.util.Base64;
|
||||
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Enumeration;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
|
||||
/**
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @CreateTime 2026-01-24 10:00:00
|
||||
* @LastEditTime 2026-01-24 22:00:00
|
||||
* @Describe 客户端签名工具类:与服务端APKFileUtils签名/哈希校验逻辑严格对齐,纯Java7实现;兼容MT重签名(遍历META-INF所有RSA文件),增加PackageManager兜底方案
|
||||
*/
|
||||
public class ApkSignUtils {
|
||||
// ===================================== 全局常量定义 =====================================
|
||||
private static final String TAG = "ApkSignUtils";
|
||||
// 加密算法常量
|
||||
private static final String ALGORITHM_SHA1 = "SHA1";
|
||||
private static final String ALGORITHM_SHA256 = "SHA-256";
|
||||
// 缓冲区大小常量(按业务场景区分)
|
||||
private static final int BUFFER_4K = 4096;
|
||||
private static final int BUFFER_8K = 8192;
|
||||
// 签名文件目录与后缀
|
||||
private static final String META_INF_DIR = "META-INF/";
|
||||
private static final String RSA_SUFFIX_UPPER = ".RSA";
|
||||
private static final String RSA_SUFFIX_LOWER = ".rsa";
|
||||
|
||||
// ===================================== 对外核心方法 =====================================
|
||||
/**
|
||||
* 获取与服务端对齐的签名Base64串(兼容MT重签名)
|
||||
* 优先逻辑:遍历APK内META-INF所有.RSA文件 → 读取第一个有效文件原始字节 → SHA1摘要 → Base64.NO_WRAP
|
||||
* 兜底逻辑:PackageManager获取系统解析的签名 → SHA1摘要 → Base64.NO_WRAP
|
||||
* @param context 上下文,用于获取当前应用APK路径/包信息
|
||||
* @return 签名Base64字符串,任意步骤失败返回null
|
||||
*/
|
||||
public static String getApkSignAlignedWithServer(Context context) {
|
||||
LogUtils.d(TAG, "getApkSignAlignedWithServer: 方法调用,开始执行服务端对齐签名计算(兼容MT重签名)");
|
||||
// 入参空值快速校验
|
||||
if (context == null) {
|
||||
LogUtils.w(TAG, "getApkSignAlignedWithServer: 入参context为null,直接返回null");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 方案1:优先读取APK内META-INF目录下所有RSA文件(兼容MT重签名任意命名)
|
||||
String signBase64 = getSignFromApkRsaFile(context);
|
||||
if (signBase64 != null) {
|
||||
LogUtils.d(TAG, "getApkSignAlignedWithServer: 方案1成功(APK内读取RSA文件),返回签名Base64");
|
||||
return signBase64;
|
||||
}
|
||||
|
||||
// 方案2:兜底 - PackageManager获取系统解析的应用签名(避免APK文件读取失败)
|
||||
signBase64 = getSignFromPackageManager(context);
|
||||
if (signBase64 != null) {
|
||||
LogUtils.d(TAG, "getApkSignAlignedWithServer: 方案2成功(PackageManager兜底),返回签名Base64");
|
||||
return signBase64;
|
||||
}
|
||||
|
||||
// 所有方案失败
|
||||
LogUtils.e(TAG, "getApkSignAlignedWithServer: 所有签名获取方案均失败");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前运行APK的SHA256哈希值(兼容重签名APK)
|
||||
* 逻辑:读取APK完整文件字节流 → SHA256摘要 → 转小写64位16进制字符串,服务端同款校验逻辑
|
||||
* @param context 上下文,用于获取当前应用APK的真实安装路径
|
||||
* @return SHA256小写16进制字符串,任意步骤失败返回null
|
||||
*/
|
||||
public static String getApkSHA256Hash(Context context) {
|
||||
LogUtils.d(TAG, "getApkSHA256Hash: 方法调用,开始执行APK文件SHA256哈希计算");
|
||||
// 入参空值快速校验
|
||||
if (context == null) {
|
||||
LogUtils.w(TAG, "getApkSHA256Hash: 入参context为null,直接返回null");
|
||||
return null;
|
||||
}
|
||||
|
||||
JarFile jarFile = null;
|
||||
FileInputStream fis = null;
|
||||
try {
|
||||
// 1. 获取当前应用APK真实路径
|
||||
ApplicationInfo appInfo = context.getApplicationContext().getApplicationInfo();
|
||||
String apkPath = appInfo.sourceDir;
|
||||
LogUtils.d(TAG, "getApkSHA256Hash: 成功获取APK路径,path=" + apkPath);
|
||||
if (apkPath == null || apkPath.trim().isEmpty()) {
|
||||
LogUtils.e(TAG, "getApkSHA256Hash: 获取到的APK路径为空,无法读取文件计算哈希");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 读取APK文件并计算SHA256哈希(完善流关闭)
|
||||
File apkFile = new File(apkPath);
|
||||
MessageDigest md = MessageDigest.getInstance(ALGORITHM_SHA256);
|
||||
fis = new FileInputStream(apkFile);
|
||||
byte[] buffer = new byte[BUFFER_8K];
|
||||
int readLen;
|
||||
while ((readLen = fis.read(buffer)) != -1) {
|
||||
md.update(buffer, 0, readLen);
|
||||
}
|
||||
LogUtils.d(TAG, "getApkSHA256Hash: APK文件读取完成,开始转换哈希结果");
|
||||
|
||||
// 3. 哈希字节数组转小写64位16进制字符串
|
||||
byte[] hashBytes = md.digest();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
String sha256Hash = sb.toString();
|
||||
LogUtils.d(TAG, "getApkSHA256Hash: APK SHA256哈希计算完成,成功返回结果");
|
||||
return sha256Hash;
|
||||
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
LogUtils.e(TAG, "getApkSHA256Hash: 获取SHA-256算法实例失败", e);
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "getApkSHA256Hash: 计算APK SHA256哈希发生未知异常", e);
|
||||
} finally {
|
||||
// 强制关闭流,避免重签名APK解析的流泄漏
|
||||
try {
|
||||
if (fis != null) fis.close();
|
||||
if (jarFile != null) jarFile.close();
|
||||
} catch (IOException e) {
|
||||
LogUtils.e(TAG, "getApkSHA256Hash: 关闭流资源异常", e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===================================== 内部核心工具方法(兼容重签名) =====================================
|
||||
/**
|
||||
* 方案1:遍历APK内META-INF所有.RSA/.rsa文件,读取第一个有效文件计算签名
|
||||
* @param context 上下文
|
||||
* @return 签名Base64,失败返回null
|
||||
*/
|
||||
private static String getSignFromApkRsaFile(Context context) {
|
||||
JarFile jarFile = null;
|
||||
InputStream is = null;
|
||||
try {
|
||||
// 获取APK路径
|
||||
ApplicationInfo appInfo = context.getApplicationContext().getApplicationInfo();
|
||||
String apkPath = appInfo.sourceDir;
|
||||
if (apkPath == null || apkPath.trim().isEmpty()) {
|
||||
LogUtils.w(TAG, "getSignFromApkRsaFile: APK路径为空,跳过该方案");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 打开APK的JarFile
|
||||
jarFile = new JarFile(apkPath);
|
||||
Enumeration<JarEntry> entries = jarFile.entries();
|
||||
JarEntry targetRsaEntry = null;
|
||||
|
||||
// 遍历所有条目,找到META-INF下第一个.RSA/.rsa文件
|
||||
while (entries.hasMoreElements()) {
|
||||
JarEntry entry = entries.nextElement();
|
||||
String entryName = entry.getName();
|
||||
// 过滤:META-INF目录下 + 以.RSA/.rsa结尾 + 非目录
|
||||
if (entryName.startsWith(META_INF_DIR) && !entry.isDirectory()
|
||||
&& (entryName.endsWith(RSA_SUFFIX_UPPER) || entryName.endsWith(RSA_SUFFIX_LOWER))) {
|
||||
targetRsaEntry = entry;
|
||||
LogUtils.d(TAG, "getSignFromApkRsaFile: 找到有效签名文件,name=" + entryName);
|
||||
break; // 取第一个有效RSA文件即可
|
||||
}
|
||||
}
|
||||
|
||||
// 未找到任何RSA文件
|
||||
if (targetRsaEntry == null) {
|
||||
LogUtils.w(TAG, "getSignFromApkRsaFile: 未在META-INF找到任何.RSA/.rsa签名文件");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 读取RSA文件原始字节
|
||||
is = jarFile.getInputStream(targetRsaEntry);
|
||||
byte[] certRawBytes = readStreamToBytes(is);
|
||||
if (certRawBytes == null || certRawBytes.length == 0) {
|
||||
LogUtils.w(TAG, "getSignFromApkRsaFile: 读取RSA文件字节为空");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算SHA1+Base64
|
||||
MessageDigest md = MessageDigest.getInstance(ALGORITHM_SHA1);
|
||||
byte[] signDigest = md.digest(certRawBytes);
|
||||
return Base64.encodeToString(signDigest, Base64.NO_WRAP);
|
||||
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "getSignFromApkRsaFile: 从APK内读取RSA文件失败", e);
|
||||
return null;
|
||||
} finally {
|
||||
// 强制关闭所有流
|
||||
try {
|
||||
if (is != null) is.close();
|
||||
if (jarFile != null) jarFile.close();
|
||||
} catch (IOException e) {
|
||||
LogUtils.e(TAG, "getSignFromApkRsaFile: 关闭流资源异常", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 方案2:兜底 - 通过PackageManager获取系统解析的应用签名
|
||||
* 避免APK文件读取失败(如权限、解析问题),兼容所有重签名场景
|
||||
* @param context 上下文
|
||||
* @return 签名Base64,失败返回null
|
||||
*/
|
||||
private static String getSignFromPackageManager(Context context) {
|
||||
try {
|
||||
// 获取当前应用包信息(包含签名)
|
||||
PackageInfo packageInfo = context.getPackageManager()
|
||||
.getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
|
||||
if (packageInfo == null || packageInfo.signatures == null || packageInfo.signatures.length == 0) {
|
||||
LogUtils.w(TAG, "getSignFromPackageManager: 未获取到应用签名信息");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 取第一个签名(重签名后一般只有一个签名)
|
||||
Signature signature = packageInfo.signatures[0];
|
||||
byte[] signBytes = signature.toByteArray();
|
||||
|
||||
// 计算SHA1+Base64,与服务端逻辑对齐
|
||||
MessageDigest md = MessageDigest.getInstance(ALGORITHM_SHA1);
|
||||
byte[] signDigest = md.digest(signBytes);
|
||||
return Base64.encodeToString(signDigest, Base64.NO_WRAP);
|
||||
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
LogUtils.e(TAG, "getSignFromPackageManager: 包名未找到,无法获取签名", e);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
LogUtils.e(TAG, "getSignFromPackageManager: 获取SHA1算法实例失败", e);
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "getSignFromPackageManager: PackageManager获取签名失败", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入流转字节数组,通用工具方法(完善try-finally)
|
||||
* 4K缓冲区,适配小文件读取(如RSA签名文件),保证流资源正常关闭
|
||||
* @param is 待读取的输入流
|
||||
* @return 转换后的字节数组,流为null/读取失败返回空字节数组
|
||||
* @throws IOException 流读取相关异常向上抛出
|
||||
*/
|
||||
private static byte[] readStreamToBytes(InputStream is) throws IOException {
|
||||
if (is == null) {
|
||||
LogUtils.w(TAG, "readStreamToBytes: 入参输入流为null,返回空字节数组");
|
||||
return new byte[0];
|
||||
}
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[BUFFER_4K];
|
||||
int readLen;
|
||||
try {
|
||||
while ((readLen = is.read(buffer)) != -1) {
|
||||
bos.write(buffer, 0, readLen);
|
||||
}
|
||||
return bos.toByteArray();
|
||||
} finally {
|
||||
// 强制关闭所有流
|
||||
is.close();
|
||||
bos.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,21 +51,21 @@ public class SignGetUtils {
|
||||
}
|
||||
|
||||
// 新增:直接返回签名字符串,供对话框调用
|
||||
public static String getSignStr(Context context) {
|
||||
if (context == null) return null;
|
||||
try {
|
||||
PackageManager pm = context.getPackageManager();
|
||||
PackageInfo pkgInfo = pm.getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
|
||||
Signature[] signatures = pkgInfo.signatures;
|
||||
if (signatures == null || signatures.length == 0) return null;
|
||||
|
||||
MessageDigest md = MessageDigest.getInstance("SHA1");
|
||||
md.update(signatures[0].toByteArray());
|
||||
return Base64.encodeToString(md.digest(), Base64.NO_WRAP);
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "获取签名字符串失败", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// public static String getSignStr(Context context) {
|
||||
// if (context == null) return null;
|
||||
// try {
|
||||
// PackageManager pm = context.getPackageManager();
|
||||
// PackageInfo pkgInfo = pm.getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
|
||||
// Signature[] signatures = pkgInfo.signatures;
|
||||
// if (signatures == null || signatures.length == 0) return null;
|
||||
//
|
||||
// MessageDigest md = MessageDigest.getInstance("SHA1");
|
||||
// md.update(signatures[0].toByteArray());
|
||||
// return Base64.encodeToString(md.digest(), Base64.NO_WRAP);
|
||||
// } catch (Exception e) {
|
||||
// LogUtils.e(TAG, "获取签名字符串失败", e);
|
||||
// return null;
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
@@ -12,71 +12,81 @@ import android.widget.ImageButton;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import cc.winboll.studio.libappbase.GlobalApplication;
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.libappbase.R;
|
||||
import cc.winboll.studio.libappbase.ToastUtils;
|
||||
import cc.winboll.studio.libappbase.dialogs.DebugHostDialog;
|
||||
import cc.winboll.studio.libappbase.dialogs.SignGetDialog;
|
||||
import cc.winboll.studio.libappbase.dialogs.AppValidationDialog;
|
||||
import cc.winboll.studio.libappbase.models.APPInfo;
|
||||
|
||||
/**
|
||||
* @Describe AboutView 原生实现关于页面,无第三方依赖,适配API30,抽象通用功能控件(邮件/网页跳转)
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/11 12:23:00
|
||||
* @LastEditTime 2026/01/20 20:45:00
|
||||
* @CreateTime 2026-01-11 12:23:00
|
||||
* @LastEditTime 2026-01-24 20:50:00
|
||||
* @Describe AboutView 原生实现关于页面,无第三方依赖,适配API30;抽象通用功能控件(邮件/网页跳转),支持调试工具入口动态显隐,集成应用正版校验、调试地址配置弹窗
|
||||
*/
|
||||
public class AboutView extends LinearLayout {
|
||||
// 全局常量区(标识、回调标识)
|
||||
// ===================================== 全局常量 =====================================
|
||||
public static final String TAG = "AboutView";
|
||||
public static final int MSG_APPUPDATE_CHECKED = 0;
|
||||
|
||||
// 固定链接常量
|
||||
// 固定链接/邮件常量
|
||||
private static final String WINBOLL_OFFICIAL_HOME = "https://www.winboll.cc";
|
||||
// 邮件相关常量(统一封装,便于维护)
|
||||
private static final String EMAIL_TITLE = "联系WinBoLLStudio";
|
||||
private static final String EMAIL_ADDRESS = "studio@winboll.cc";
|
||||
private static final String EMAIL_TYPE = "message/rfc822";
|
||||
|
||||
// 布局尺寸常量(统一管理,适配多屏幕,dp为基准单位)
|
||||
// 布局尺寸常量(dp)
|
||||
private static final int PADDING_LARGE = 32;
|
||||
private static final int PADDING_MID = 16;
|
||||
private static final int PADDING_SMALL = 8;
|
||||
private static final int ICON_SIZE = 48;
|
||||
private static final int ITEM_ICON_SIZE = 24;
|
||||
|
||||
// 成员属性区(按 核心依赖→业务配置→视图相关 归类排序,注释清晰)
|
||||
private Context mContext; // 上下文对象,全局复用
|
||||
private APPInfo mAPPInfo; // 应用核心信息实体
|
||||
private OnRequestDevUserInfoAutofillListener mOnRequestDevUserInfoAutofillListener; // 调试信息填充监听
|
||||
// 服务器默认地址常量
|
||||
private static final String SERVER_DEBUG_HOST = "https://yun-preivew.winboll.cc";
|
||||
private static final String SERVER_RELEASE_HOST = "https://yun.winboll.cc";
|
||||
|
||||
private String mszAppName = ""; // 应用名称
|
||||
private String mszAppVersionName = ""; // 应用版本号
|
||||
private String mszAppDescription = ""; // 应用描述文案
|
||||
private String mszHomePage = ""; // 应用主页/APK下载地址
|
||||
private String mszGitea = ""; // 应用Git源码地址
|
||||
private String mszAppGitName = ""; // 应用Git仓库名称
|
||||
private String mszAppAPKName = ""; // 应用APK基础名称
|
||||
private String mszAppAPKFolderName = ""; // 应用APK存储文件夹
|
||||
private String mszCurrentAppPackageName = "";// 当前APK完整文件名
|
||||
private String mszReleaseAPKName = ""; // 正式版APK完整文件名
|
||||
private volatile String mszNewestAppPackageName = ""; // 最新版APK文件名(支持异步更新)
|
||||
private String mszWinBoLLServerHost = ""; // 服务器地址
|
||||
private int mnAppIcon = 0; // 应用图标资源ID
|
||||
private boolean mIsAddDebugTools = false; // 是否启用调试工具标识
|
||||
private EditText metDevUserName; // 调试用户名输入框
|
||||
private EditText metDevUserPassword; // 调试密码输入框
|
||||
// ===================================== 核心成员属性 =====================================
|
||||
// 上下文与业务实体
|
||||
private Context mContext;
|
||||
private APPInfo mAPPInfo;
|
||||
private OnRequestDevUserInfoAutofillListener mOnRequestDevUserInfoAutofillListener;
|
||||
|
||||
// 视图绑定
|
||||
// 应用基础信息
|
||||
private String mszAppName = "";
|
||||
private String mszAppVersionName = "";
|
||||
private String mszAppDescription = "";
|
||||
private String mszHomePage = "";
|
||||
private String mszGitea = "";
|
||||
private String mszAppGitName = "";
|
||||
private String mszAppAPKName = "";
|
||||
private String mszAppAPKFolderName = "";
|
||||
private String mszCurrentAppPackageName = "";
|
||||
private String mszReleaseAPKName = "";
|
||||
private volatile String mszNewestAppPackageName = "";
|
||||
private String mszWinBoLLServerHost = "";
|
||||
private int mnAppIcon = 0;
|
||||
private boolean mIsAddDebugTools = false;
|
||||
|
||||
// 调试视图
|
||||
private EditText metDevUserName;
|
||||
private EditText metDevUserPassword;
|
||||
|
||||
// ===================================== 页面视图控件 =====================================
|
||||
private ImageView ivAppIcon;
|
||||
private TextView tvAppNameVersion;
|
||||
private TextView tvAppDesc;
|
||||
private LinearLayout llFunctionContainer;
|
||||
private ImageButton ibSigngetDialog;
|
||||
private ImageButton ibWinBoLLHostDialog;
|
||||
|
||||
// 构造方法区(按 参数从少到多 排序,适配 代码创建+XML引用 场景)
|
||||
// ===================================== 构造方法(按参数从少到多排序) =====================================
|
||||
public AboutView(Context context) {
|
||||
super(context);
|
||||
LogUtils.d(TAG, "AboutView(Context) 构造方法调用,代码创建视图场景");
|
||||
LogUtils.d(TAG, "AboutView(Context):代码创建视图,执行默认初始化");
|
||||
this.mContext = context;
|
||||
initDefaultParams();
|
||||
initViewFromXml();
|
||||
@@ -84,7 +94,7 @@ public class AboutView extends LinearLayout {
|
||||
|
||||
public AboutView(Context context, APPInfo appInfo) {
|
||||
super(context);
|
||||
LogUtils.d(TAG, "AboutView(Context,APPInfo) 构造调用,入参APPInfo:" + (appInfo == null ? "null" : appInfo.getAppName()));
|
||||
LogUtils.d(TAG, "AboutView(Context,APPInfo):传入应用信息,appName=" + (appInfo == null ? "null" : appInfo.getAppName()));
|
||||
this.mContext = context;
|
||||
this.mAPPInfo = appInfo;
|
||||
initViewFromXml();
|
||||
@@ -93,7 +103,7 @@ public class AboutView extends LinearLayout {
|
||||
|
||||
public AboutView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
LogUtils.d(TAG, "AboutView(Context,AttributeSet) 构造调用,XML布局引用场景");
|
||||
LogUtils.d(TAG, "AboutView(Context,AttributeSet):XML布局引用,执行默认初始化");
|
||||
this.mContext = context;
|
||||
initDefaultParams();
|
||||
initViewFromXml();
|
||||
@@ -101,70 +111,22 @@ public class AboutView extends LinearLayout {
|
||||
|
||||
public AboutView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
LogUtils.d(TAG, "AboutView(Context,AttributeSet,int) 构造调用,XML布局+样式配置,defStyleAttr:" + defStyleAttr);
|
||||
LogUtils.d(TAG, "AboutView(Context,AttributeSet,int):XML布局+样式配置,defStyleAttr=" + defStyleAttr);
|
||||
this.mContext = context;
|
||||
initDefaultParams();
|
||||
initViewFromXml();
|
||||
}
|
||||
|
||||
// 核心:加载xml布局并绑定视图
|
||||
// private void initViewFromXml() {
|
||||
// View.inflate(mContext, R.layout.layout_about_view, this);
|
||||
// ivAppIcon = findViewById(R.id.iv_app_icon);
|
||||
// tvAppNameVersion = findViewById(R.id.tv_app_name_version);
|
||||
// tvAppDesc = findViewById(R.id.tv_app_desc);
|
||||
// llFunctionContainer = findViewById(R.id.ll_function_container);
|
||||
// LogUtils.d(TAG, "initViewFromXml 布局加载+视图绑定完成");
|
||||
// }
|
||||
// 1. 新增视图绑定属性(加在原有视图属性后面)
|
||||
private ImageButton ibSigngetDialog;
|
||||
private ImageButton ibWinBoLLHostDialog;
|
||||
|
||||
// 2. 完善initViewFromXml方法,新增按钮绑定
|
||||
private void initViewFromXml() {
|
||||
View.inflate(mContext, R.layout.layout_about_view, this);
|
||||
ivAppIcon = findViewById(R.id.iv_app_icon);
|
||||
tvAppNameVersion = findViewById(R.id.tv_app_name_version);
|
||||
tvAppDesc = findViewById(R.id.tv_app_desc);
|
||||
llFunctionContainer = findViewById(R.id.ll_function_container);
|
||||
ibSigngetDialog = findViewById(R.id.ib_signgetdialog); // 新增按钮绑定
|
||||
ibWinBoLLHostDialog = findViewById(R.id.ib_winbollhostdialog); // 新增按钮绑定
|
||||
ibWinBoLLHostDialog.setVisibility(GlobalApplication.isDebugging()?View.VISIBLE:View.GONE);
|
||||
setBtnClickListener(); // 新增绑定点击事件
|
||||
LogUtils.d(TAG, "initViewFromXml 布局加载+视图绑定完成");
|
||||
}
|
||||
|
||||
// 3. 新增按钮点击事件方法(放在initViewFromXml下面即可)
|
||||
private void setBtnClickListener() {
|
||||
ibSigngetDialog.setOnClickListener(new OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
LogUtils.d(TAG, "签名获取按钮点击,弹出SignGetDialog");
|
||||
new SignGetDialog(mContext).show(); // 弹出对话框
|
||||
}
|
||||
});
|
||||
ibWinBoLLHostDialog.setOnClickListener(new OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
LogUtils.d(TAG, "签名获取按钮点击,弹出SignGetDialog");
|
||||
new DebugHostDialog(mContext).show(); // 弹出对话框
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 对外公开方法区(供外部调用,职责单一,注释明确)
|
||||
// ===================================== 对外公开方法 =====================================
|
||||
/**
|
||||
* 一站式初始化所有关于页逻辑,包含参数、信息、视图全流程初始化
|
||||
* 一站式初始化所有关于页逻辑,包含参数、应用信息、页面视图全流程
|
||||
*/
|
||||
public void initAll() {
|
||||
LogUtils.d(TAG, "initAll() 一站式初始化调用,APPInfo是否为空:" + (mAPPInfo == null));
|
||||
LogUtils.d(TAG, "initAll():开始一站式初始化,APPInfo是否为空=" + (mAPPInfo == null));
|
||||
if (mAPPInfo == null) {
|
||||
LogUtils.w(TAG, "initAll() 初始化终止:APPInfo 为 null,无法获取应用核心信息");
|
||||
LogUtils.w(TAG, "initAll():初始化终止,APPInfo为null");
|
||||
return;
|
||||
}
|
||||
|
||||
// 按初始化流程执行,有序无冗余
|
||||
initDefaultParams();
|
||||
initAppBaseInfo();
|
||||
initAppVersionInfo();
|
||||
@@ -172,60 +134,81 @@ public class AboutView extends LinearLayout {
|
||||
initAppLinkInfo();
|
||||
initReleaseAPKInfo();
|
||||
initAboutPageView();
|
||||
LogUtils.d(TAG, "initAll() 所有初始化流程执行完成");
|
||||
LogUtils.d(TAG, "initAll():所有初始化流程执行完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置应用信息并重新初始化关于页,支持动态更新页面内容
|
||||
* 重置应用信息并重新初始化页面,支持动态更新关于页内容
|
||||
* @param appInfo 新的应用信息实体
|
||||
*/
|
||||
public void setAPPInfoAndInit(APPInfo appInfo) {
|
||||
LogUtils.d(TAG, "setAPPInfoAndInit() 调用,传入新APPInfo:" + (appInfo == null ? "null" : appInfo.getAppName()));
|
||||
LogUtils.d(TAG, "setAPPInfoAndInit():重置应用信息,appName=" + (appInfo == null ? "null" : appInfo.getAppName()));
|
||||
this.mAPPInfo = appInfo;
|
||||
llFunctionContainer.removeAllViews();
|
||||
if (llFunctionContainer != null) llFunctionContainer.removeAllViews();
|
||||
initAll();
|
||||
LogUtils.d(TAG, "setAPPInfoAndInit() 应用信息重置+页面重构完成");
|
||||
LogUtils.d(TAG, "setAPPInfoAndInit():应用信息重置+页面重构完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置应用信息(兼容旧调用逻辑),设置后自动重构页面
|
||||
* 设置应用信息,兼容旧调用逻辑,设置后自动重构页面
|
||||
* @param appInfo 应用核心信息实体
|
||||
*/
|
||||
public void setAPPInfo(APPInfo appInfo) {
|
||||
LogUtils.d(TAG, "setAPPInfo() 调用,传入APPInfo:" + (appInfo == null ? "null" : appInfo.getAppName()));
|
||||
LogUtils.d(TAG, "setAPPInfo():设置应用信息,appName=" + (appInfo == null ? "null" : appInfo.getAppName()));
|
||||
this.mAPPInfo = appInfo;
|
||||
llFunctionContainer.removeAllViews();
|
||||
if (llFunctionContainer != null) llFunctionContainer.removeAllViews();
|
||||
initAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置调试信息自动填充监听,用于调试场景的信息回调
|
||||
* 设置调试信息自动填充监听,供调试场景回调使用
|
||||
* @param l 监听回调接口实现
|
||||
*/
|
||||
public void setOnRequestDevUserInfoAutofillListener(OnRequestDevUserInfoAutofillListener l) {
|
||||
LogUtils.d(TAG, "setOnRequestDevUserInfoAutofillListener() 调试监听设置完成");
|
||||
LogUtils.d(TAG, "setOnRequestDevUserInfoAutofillListener():设置调试信息填充监听完成");
|
||||
this.mOnRequestDevUserInfoAutofillListener = l;
|
||||
}
|
||||
|
||||
// 内部初始化方法区(按 基础→业务→视图 流程排序,单一职责)
|
||||
// ===================================== 内部初始化方法 =====================================
|
||||
/**
|
||||
* 初始化默认兜底参数,防止空指针,为后续初始化做基础铺垫
|
||||
*/
|
||||
private void initDefaultParams() {
|
||||
LogUtils.d(TAG, "initDefaultParams() 执行默认参数初始化");
|
||||
mszWinBoLLServerHost = GlobalApplication.isDebugging() ? "https://yun-preivew.winboll.cc" : "https://yun.winboll.cc";
|
||||
mnAppIcon = mnAppIcon == 0 ? R.drawable.ic_winboll : mnAppIcon;
|
||||
LogUtils.d(TAG, "initDefaultParams():开始初始化默认参数");
|
||||
mszWinBoLLServerHost = GlobalApplication.isDebugging() ? SERVER_DEBUG_HOST : SERVER_RELEASE_HOST;
|
||||
mnAppIcon = (mnAppIcon == 0) ? R.drawable.ic_winboll : mnAppIcon;
|
||||
mIsAddDebugTools = false;
|
||||
LogUtils.d(TAG, "initDefaultParams() 完成,默认服务器地址:" + mszWinBoLLServerHost + ",默认图标ID:" + mnAppIcon);
|
||||
LogUtils.d(TAG, "initDefaultParams():默认参数初始化完成,服务器地址=" + mszWinBoLLServerHost + ",应用图标ID=" + mnAppIcon);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载XML布局并绑定所有视图控件,初始化按钮点击事件
|
||||
*/
|
||||
private void initViewFromXml() {
|
||||
LogUtils.d(TAG, "initViewFromXml():开始加载布局并绑定控件");
|
||||
View.inflate(mContext, R.layout.layout_about_view, this);
|
||||
// 基础控件绑定
|
||||
ivAppIcon = findViewById(R.id.iv_app_icon);
|
||||
tvAppNameVersion = findViewById(R.id.tv_app_name_version);
|
||||
tvAppDesc = findViewById(R.id.tv_app_desc);
|
||||
llFunctionContainer = findViewById(R.id.ll_function_container);
|
||||
// 功能按钮绑定
|
||||
ibSigngetDialog = findViewById(R.id.ib_signgetdialog);
|
||||
ibWinBoLLHostDialog = findViewById(R.id.ib_winbollhostdialog);
|
||||
// 调试地址按钮动态显隐
|
||||
ibWinBoLLHostDialog.setVisibility(GlobalApplication.isDebugging() ? View.VISIBLE : View.GONE);
|
||||
// 绑定按钮点击事件
|
||||
setBtnClickListener();
|
||||
LogUtils.d(TAG, "initViewFromXml():布局加载+控件绑定+事件初始化完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 从APPInfo实体读取应用基础核心配置,赋值到本地属性
|
||||
*/
|
||||
private void initAppBaseInfo() {
|
||||
LogUtils.d(TAG, "initAppBaseInfo() 读取APPInfo基础配置");
|
||||
LogUtils.d(TAG, "initAppBaseInfo():开始读取APPInfo基础配置");
|
||||
if (mAPPInfo == null) {
|
||||
LogUtils.w(TAG, "initAppBaseInfo() 跳过执行:APPInfo 为 null");
|
||||
LogUtils.w(TAG, "initAppBaseInfo():跳过执行,APPInfo为null");
|
||||
return;
|
||||
}
|
||||
mszAppName = mAPPInfo.getAppName() == null ? "" : mAPPInfo.getAppName();
|
||||
@@ -233,44 +216,44 @@ public class AboutView extends LinearLayout {
|
||||
mszAppAPKName = mAPPInfo.getAppAPKName() == null ? "" : mAPPInfo.getAppAPKName();
|
||||
mszAppGitName = mAPPInfo.getAppGitName() == null ? "" : mAPPInfo.getAppGitName();
|
||||
mszAppDescription = mAPPInfo.getAppDescription() == null ? "" : mAPPInfo.getAppDescription();
|
||||
mnAppIcon = mAPPInfo.getAppIcon() != 0 ? mAPPInfo.getAppIcon() : mnAppIcon;
|
||||
mnAppIcon = (mAPPInfo.getAppIcon() != 0) ? mAPPInfo.getAppIcon() : mnAppIcon;
|
||||
mIsAddDebugTools = mAPPInfo.isAddDebugTools();
|
||||
LogUtils.d(TAG, "initAppBaseInfo() 读取完成,应用名:" + mszAppName + ",调试开关:" + mIsAddDebugTools);
|
||||
LogUtils.d(TAG, "initAppBaseInfo():基础配置读取完成,应用名=" + mszAppName + ",调试开关=" + mIsAddDebugTools);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化应用版本信息,从包管理中获取当前应用版本号
|
||||
* 从包管理中获取当前应用版本号,初始化版本相关信息
|
||||
*/
|
||||
private void initAppVersionInfo() {
|
||||
LogUtils.d(TAG, "initAppVersionInfo() 初始化应用版本信息");
|
||||
LogUtils.d(TAG, "initAppVersionInfo():开始初始化应用版本信息");
|
||||
try {
|
||||
mszAppVersionName = mContext.getPackageManager().getPackageInfo(mContext.getPackageName(), 0).versionName;
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
LogUtils.d(TAG, "initAppVersionInfo() 获取版本号失败,默认赋值unknown", e);
|
||||
LogUtils.e(TAG, "initAppVersionInfo():获取版本号失败,默认赋值unknown", e);
|
||||
mszAppVersionName = "unknown";
|
||||
}
|
||||
mszCurrentAppPackageName = String.format("%s_%s.apk", mszAppVersionName, mszAppVersionName);
|
||||
LogUtils.d(TAG, "initAppVersionInfo() 完成,版本号:" + mszAppVersionName + ",当前APK名:" + mszCurrentAppPackageName);
|
||||
LogUtils.d(TAG, "initAppVersionInfo():版本信息初始化完成,版本号=" + mszAppVersionName + ",当前APK名=" + mszCurrentAppPackageName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化服务器相关配置,预留扩展接口
|
||||
*/
|
||||
private void initServerConfig() {
|
||||
LogUtils.d(TAG, "initServerConfig() 服务器配置初始化(预留扩展)");
|
||||
LogUtils.d(TAG, "initServerConfig():服务器配置初始化,预留扩展接口");
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化应用相关链接(主页+Git源码地址),动态拼接Git地址
|
||||
* 初始化应用相关链接(主页+Git源码地址),根据分支配置动态拼接Git地址
|
||||
*/
|
||||
private void initAppLinkInfo() {
|
||||
LogUtils.d(TAG, "initAppLinkInfo() 初始化应用链接信息");
|
||||
LogUtils.d(TAG, "initAppLinkInfo():开始初始化应用链接信息");
|
||||
if (mAPPInfo == null) {
|
||||
LogUtils.w(TAG, "initAppLinkInfo() 跳过执行:APPInfo 为 null");
|
||||
LogUtils.w(TAG, "initAppLinkInfo():跳过执行,APPInfo为null");
|
||||
return;
|
||||
}
|
||||
mszHomePage = mAPPInfo.getAppHomePage() == null ? "" : mAPPInfo.getAppHomePage();
|
||||
// 分场景拼接Git地址,兼容无分支配置场景
|
||||
// 拼接Git地址,兼容无分支配置场景
|
||||
if (mAPPInfo.getAppGitAPPBranch() == null || mAPPInfo.getAppGitAPPBranch().trim().isEmpty()) {
|
||||
mszGitea = String.format("https://gitea.winboll.cc/%s/%s", mAPPInfo.getAppGitOwner(), mszAppGitName);
|
||||
} else {
|
||||
@@ -278,31 +261,31 @@ public class AboutView extends LinearLayout {
|
||||
mAPPInfo.getAppGitOwner(), mszAppGitName,
|
||||
mAPPInfo.getAppGitAPPBranch(), mAPPInfo.getAppGitAPPSubProjectFolder());
|
||||
}
|
||||
LogUtils.d(TAG, "initAppLinkInfo() 完成,应用主页:" + mszHomePage + ",Git地址:" + mszGitea);
|
||||
LogUtils.d(TAG, "initAppLinkInfo():链接信息初始化完成,应用主页=" + mszHomePage + ",Git地址=" + mszGitea);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化正式版APK信息,去除beta后缀适配正式包命名规范
|
||||
*/
|
||||
private void initReleaseAPKInfo() {
|
||||
LogUtils.d(TAG, "initReleaseAPKInfo() 初始化正式版APK信息");
|
||||
LogUtils.d(TAG, "initReleaseAPKInfo():开始初始化正式版APK信息");
|
||||
String szReleaseAppVersionName = "unknown";
|
||||
try {
|
||||
String szSubBetaSuffix = subBetaSuffix(mContext.getPackageName());
|
||||
szReleaseAppVersionName = mContext.getPackageManager().getPackageInfo(szSubBetaSuffix, 0).versionName;
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
LogUtils.d(TAG, "initReleaseAPKInfo() 获取正式版版本号失败", e);
|
||||
LogUtils.e(TAG, "initReleaseAPKInfo():获取正式版版本号失败", e);
|
||||
}
|
||||
mszReleaseAPKName = String.format("%s_%s.apk", mszAppAPKName, szReleaseAppVersionName);
|
||||
LogUtils.d(TAG, "initReleaseAPKInfo() 完成,正式版APK名:" + mszReleaseAPKName);
|
||||
LogUtils.d(TAG, "initReleaseAPKInfo():正式版APK信息初始化完成,APK名=" + mszReleaseAPKName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心视图组装:赋值基础信息+添加功能项
|
||||
* 核心视图组装:赋值基础信息到控件,添加通用功能项到容器
|
||||
*/
|
||||
private void initAboutPageView() {
|
||||
LogUtils.d(TAG, "initAboutPageView() 开始组装关于页视图");
|
||||
// 基础信息赋值
|
||||
LogUtils.d(TAG, "initAboutPageView():开始组装关于页视图");
|
||||
// 赋值基础信息
|
||||
ivAppIcon.setImageResource(mnAppIcon);
|
||||
tvAppNameVersion.setText(String.format("%s %s", mszAppName, mszAppVersionName));
|
||||
if (mszAppDescription.isEmpty()) {
|
||||
@@ -311,8 +294,7 @@ public class AboutView extends LinearLayout {
|
||||
tvAppDesc.setVisibility(VISIBLE);
|
||||
tvAppDesc.setText(mszAppDescription);
|
||||
}
|
||||
|
||||
// 通用功能控件:网页跳转类+邮件类,复用抽象控件
|
||||
// 添加通用功能项
|
||||
addFunctionView(new WebJumpFunctionItemView(mContext, "WinBoLL 主页", WINBOLL_OFFICIAL_HOME, R.drawable.ic_winboll));
|
||||
addFunctionView(new EmailFunctionItemView(mContext, "联系邮箱", "WinBoLLStudio<studio@winboll.cc>", R.drawable.ic_winboll));
|
||||
if (!mszHomePage.isEmpty()) {
|
||||
@@ -321,19 +303,46 @@ public class AboutView extends LinearLayout {
|
||||
if (!mszGitea.isEmpty()) {
|
||||
addFunctionView(new WebJumpFunctionItemView(mContext, "应用Git源码地址", mszGitea, R.drawable.ic_winboll));
|
||||
}
|
||||
LogUtils.d(TAG, "initAboutPageView() 视图组装完成,功能项加载完毕");
|
||||
LogUtils.d(TAG, "initAboutPageView():视图组装完成,功能项加载完毕");
|
||||
}
|
||||
|
||||
// 添加功能项到容器
|
||||
// ===================================== 内部工具/事件方法 =====================================
|
||||
/**
|
||||
* 绑定功能按钮点击事件,处理正版校验、调试地址配置弹窗唤起
|
||||
*/
|
||||
private void setBtnClickListener() {
|
||||
LogUtils.d(TAG, "setBtnClickListener():开始绑定功能按钮点击事件");
|
||||
// 正版校验弹窗
|
||||
ibSigngetDialog.setOnClickListener(new OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
LogUtils.d(TAG, "ibSigngetDialog onClick:唤起应用正版校验弹窗");
|
||||
new AppValidationDialog(mContext, mszAppGitName, mszAppVersionName).show();
|
||||
}
|
||||
});
|
||||
// 调试地址配置弹窗
|
||||
ibWinBoLLHostDialog.setOnClickListener(new OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
LogUtils.d(TAG, "ibWinBoLLHostDialog onClick:唤起调试地址配置弹窗");
|
||||
new DebugHostDialog(mContext).show();
|
||||
}
|
||||
});
|
||||
LogUtils.d(TAG, "setBtnClickListener():功能按钮点击事件绑定完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加功能项视图到容器,统一设置间距
|
||||
* @param view 功能项视图
|
||||
*/
|
||||
private void addFunctionView(View view) {
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
|
||||
params.topMargin = dp2px(PADDING_SMALL);
|
||||
llFunctionContainer.addView(view, params);
|
||||
}
|
||||
|
||||
// 工具方法区(通用工具+业务工具,静态优先,便于复用)
|
||||
/**
|
||||
* dp 转 px 工具方法,适配不同屏幕密度,保证布局一致性
|
||||
* dp转px工具方法,适配不同屏幕密度,保证布局一致性
|
||||
* @param dpValue dp单位尺寸
|
||||
* @return 转换后的px单位尺寸
|
||||
*/
|
||||
@@ -348,17 +357,20 @@ public class AboutView extends LinearLayout {
|
||||
* @return 去除beta后缀后的正式包名
|
||||
*/
|
||||
public static String subBetaSuffix(String input) {
|
||||
LogUtils.d(TAG, "subBetaSuffix() 执行包名beta后缀去除,原始包名:" + input);
|
||||
LogUtils.d(TAG, "subBetaSuffix():执行包名beta后缀去除,原始包名=" + input);
|
||||
if (input != null && input.endsWith(".beta")) {
|
||||
String result = input.substring(0, input.length() - ".beta".length());
|
||||
LogUtils.d(TAG, "subBetaSuffix() 处理成功,正式包名:" + result);
|
||||
LogUtils.d(TAG, "subBetaSuffix():处理成功,正式包名=" + result);
|
||||
return result;
|
||||
}
|
||||
LogUtils.d(TAG, "subBetaSuffix() 无需处理,包名不含beta后缀");
|
||||
LogUtils.d(TAG, "subBetaSuffix():无需处理,包名不含beta后缀");
|
||||
return input == null ? "" : input;
|
||||
}
|
||||
|
||||
// 内部抽象通用功能项基类 - 统一样式,减少冗余
|
||||
// ===================================== 内部抽象通用功能项基类 =====================================
|
||||
/**
|
||||
* 通用功能项基类,统一样式、布局、视图构建,减少冗余代码
|
||||
*/
|
||||
private abstract class BaseFunctionItemView extends LinearLayout implements OnClickListener {
|
||||
protected Context mItemContext;
|
||||
protected String mTitle;
|
||||
@@ -376,7 +388,9 @@ public class AboutView extends LinearLayout {
|
||||
setOnClickListener(this);
|
||||
}
|
||||
|
||||
// 统一布局配置
|
||||
/**
|
||||
* 统一初始化功能项布局属性
|
||||
*/
|
||||
private void initItemLayout() {
|
||||
setOrientation(HORIZONTAL);
|
||||
setGravity(Gravity.CENTER_VERTICAL);
|
||||
@@ -386,7 +400,9 @@ public class AboutView extends LinearLayout {
|
||||
setBackgroundResource(android.R.drawable.list_selector_background);
|
||||
}
|
||||
|
||||
// 统一视图构建
|
||||
/**
|
||||
* 统一构建功能项视图(左侧图标+右侧标题/内容)
|
||||
*/
|
||||
private void initItemViews() {
|
||||
// 左侧图标
|
||||
if (mIconRes != 0) {
|
||||
@@ -397,20 +413,17 @@ public class AboutView extends LinearLayout {
|
||||
ivIcon.setImageResource(mIconRes);
|
||||
addView(ivIcon);
|
||||
}
|
||||
|
||||
// 右侧文本容器
|
||||
LinearLayout llText = new LinearLayout(mItemContext);
|
||||
llText.setOrientation(VERTICAL);
|
||||
llText.setLayoutParams(new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1.0f));
|
||||
addView(llText);
|
||||
|
||||
// 标题
|
||||
TextView tvTitle = new TextView(mItemContext);
|
||||
tvTitle.setText(mTitle);
|
||||
tvTitle.setTextSize(16);
|
||||
tvTitle.setTextColor(mItemContext.getResources().getColor(R.color.gray_900));
|
||||
llText.addView(tvTitle);
|
||||
|
||||
// 内容
|
||||
TextView tvContent = new TextView(mItemContext);
|
||||
tvContent.setText(mContent);
|
||||
@@ -420,11 +433,17 @@ public class AboutView extends LinearLayout {
|
||||
llText.addView(tvContent);
|
||||
}
|
||||
|
||||
// 子类指定内容文本颜色
|
||||
/**
|
||||
* 子类抽象方法:指定内容文本颜色
|
||||
* @return 颜色值
|
||||
*/
|
||||
protected abstract int getContentTextColor();
|
||||
}
|
||||
|
||||
// 邮件类功能控件 - 专属邮件唤起逻辑
|
||||
// ===================================== 内部邮件功能项子类 =====================================
|
||||
/**
|
||||
* 邮件类功能控件,实现专属邮件唤起逻辑,双方案兼容(纯邮件客户端/通用邮件应用)
|
||||
*/
|
||||
private class EmailFunctionItemView extends BaseFunctionItemView {
|
||||
public EmailFunctionItemView(Context context, String title, String content, int iconRes) {
|
||||
super(context, title, content, iconRes);
|
||||
@@ -437,36 +456,37 @@ public class AboutView extends LinearLayout {
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
LogUtils.d(TAG, "EmailFunctionItemView onClick 触发邮件唤起");
|
||||
// 双方案邮件唤起逻辑
|
||||
LogUtils.d(TAG, "EmailFunctionItemView onClick:触发邮件唤起逻辑");
|
||||
// 方案1:纯邮件客户端唤起
|
||||
Intent emailIntent = new Intent(Intent.ACTION_SENDTO);
|
||||
emailIntent.setData(Uri.parse("mailto:" + EMAIL_ADDRESS));
|
||||
emailIntent.putExtra(Intent.EXTRA_SUBJECT, EMAIL_TITLE);
|
||||
emailIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
if (emailIntent.resolveActivity(mItemContext.getPackageManager()) != null) {
|
||||
mItemContext.startActivity(emailIntent);
|
||||
LogUtils.d(TAG, "邮件唤起成功:系统纯邮件客户端");
|
||||
LogUtils.d(TAG, "EmailFunctionItemView:纯邮件客户端唤起成功");
|
||||
return;
|
||||
}
|
||||
|
||||
// 方案2:通用邮件应用兜底
|
||||
Intent fallbackIntent = new Intent(Intent.ACTION_SEND);
|
||||
fallbackIntent.setType(EMAIL_TYPE);
|
||||
fallbackIntent.putExtra(Intent.EXTRA_EMAIL, new String[]{EMAIL_ADDRESS});
|
||||
fallbackIntent.putExtra(Intent.EXTRA_SUBJECT, EMAIL_TITLE);
|
||||
fallbackIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
if (fallbackIntent.resolveActivity(mItemContext.getPackageManager()) != null) {
|
||||
mItemContext.startActivity(fallbackIntent);
|
||||
LogUtils.d(TAG, "邮件唤起成功:通用邮件应用");
|
||||
LogUtils.d(TAG, "EmailFunctionItemView:通用邮件应用唤起成功");
|
||||
} else {
|
||||
ToastUtils.show("未找到可发送邮件的应用");
|
||||
LogUtils.w(TAG, "邮件唤起失败:无可用邮件相关应用");
|
||||
LogUtils.w(TAG, "EmailFunctionItemView:邮件唤起失败,无可用邮件应用");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 网页跳转类功能控件 - 专属网页跳转逻辑
|
||||
// ===================================== 内部网页跳转功能项子类 =====================================
|
||||
/**
|
||||
* 网页跳转类功能控件,实现专属网页唤起逻辑,包含空地址校验、异常捕获
|
||||
*/
|
||||
private class WebJumpFunctionItemView extends BaseFunctionItemView {
|
||||
public WebJumpFunctionItemView(Context context, String title, String content, int iconRes) {
|
||||
super(context, title, content, iconRes);
|
||||
@@ -479,25 +499,28 @@ public class AboutView extends LinearLayout {
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
LogUtils.d(TAG, "WebJumpFunctionItemView onClick 触发网页跳转,地址:" + mContent);
|
||||
LogUtils.d(TAG, "WebJumpFunctionItemView onClick:触发网页跳转,地址=" + mContent);
|
||||
if (mContent.isEmpty()) {
|
||||
ToastUtils.show("跳转地址为空");
|
||||
LogUtils.w(TAG, "网页跳转失败:地址为空");
|
||||
LogUtils.w(TAG, "WebJumpFunctionItemView:网页跳转失败,地址为空");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(mContent));
|
||||
browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
mItemContext.startActivity(browserIntent);
|
||||
LogUtils.d(TAG, "网页跳转成功");
|
||||
LogUtils.d(TAG, "WebJumpFunctionItemView:网页跳转成功");
|
||||
} catch (Exception e) {
|
||||
LogUtils.d(TAG, "网页跳转失败,异常捕获", e);
|
||||
LogUtils.e(TAG, "WebJumpFunctionItemView:网页跳转失败", e);
|
||||
ToastUtils.show("链接无法打开");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内部接口区(置于类末尾,逻辑闭环)
|
||||
// ===================================== 内部回调接口 =====================================
|
||||
/**
|
||||
* 调试信息自动填充回调接口
|
||||
*/
|
||||
public interface OnRequestDevUserInfoAutofillListener {
|
||||
void requestAutofill(EditText etDevUserName, EditText etDevUserPassword);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user