Compare commits
16 Commits
originmast
...
appbase-v1
| Author | SHA1 | Date | |
|---|---|---|---|
| 35527374da | |||
| 2751ce4a39 | |||
| 730022a9f0 | |||
| a3bc90d9b8 | |||
| 32ee7c8845 | |||
| 6e34ee73e9 | |||
| 7eed7357f0 | |||
| d20192cb36 | |||
| 5846784940 | |||
| ef64d6a317 | |||
| 8b2a8328eb | |||
| 88a20d9a85 | |||
| aeaea253cb | |||
| 4890ca42cc | |||
| 2896b6401b | |||
| 1aa270482e |
@@ -1,8 +1,8 @@
|
||||
#Created by .winboll/winboll_app_build.gradle
|
||||
#Tue Jan 13 03:23:34 HKT 2026
|
||||
stageCount=5
|
||||
#Sat Jan 24 11:26:28 HKT 2026
|
||||
stageCount=9
|
||||
libraryProject=libappbase
|
||||
baseVersion=15.15
|
||||
publishVersion=15.15.4
|
||||
publishVersion=15.15.8
|
||||
buildCount=0
|
||||
baseBetaVersion=15.15.5
|
||||
baseBetaVersion=15.15.9
|
||||
|
||||
@@ -21,5 +21,11 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// 网络连接类库
|
||||
api 'com.squareup.okhttp3:okhttp:4.4.1'
|
||||
// Gson
|
||||
api 'com.google.code.gson:gson:2.8.9'
|
||||
// Html 解析
|
||||
api 'org.jsoup:jsoup:1.13.1'
|
||||
api fileTree(dir: 'libs', include: ['*.jar'])
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#Created by .winboll/winboll_app_build.gradle
|
||||
#Tue Jan 13 03:23:17 HKT 2026
|
||||
stageCount=5
|
||||
#Sat Jan 24 11:26:28 HKT 2026
|
||||
stageCount=9
|
||||
libraryProject=libappbase
|
||||
baseVersion=15.15
|
||||
publishVersion=15.15.4
|
||||
publishVersion=15.15.8
|
||||
buildCount=0
|
||||
baseBetaVersion=15.15.5
|
||||
baseBetaVersion=15.15.9
|
||||
|
||||
@@ -3,7 +3,21 @@
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="cc.winboll.studio.libappbase">
|
||||
|
||||
<application>
|
||||
<!-- 拥有完全的网络访问权限 -->
|
||||
<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
|
||||
android:name=".CrashHandler$CrashActivity"
|
||||
|
||||
@@ -2,6 +2,7 @@ package cc.winboll.studio.libappbase;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.PackageManager.NameNotFoundException;
|
||||
@@ -26,6 +27,12 @@ public class GlobalApplication extends Application {
|
||||
*/
|
||||
private static volatile boolean isDebugging = false;
|
||||
|
||||
// 新增:WinBoLL 服务器主机地址(volatile 保证多线程可见性)
|
||||
private static volatile String winbollHost = null;
|
||||
// 新增:SP 存储相关常量(私有存储,仅当前应用可访问)
|
||||
private static final String SP_NAME = "WinBoLL_SP_CONFIG";
|
||||
private static final String SP_KEY_WINBOLL_HOST = "winboll_host";
|
||||
|
||||
/**
|
||||
* 获取全局 Application 单例实例(外部可通过此方法获取上下文)
|
||||
* @return GlobalApplication 单例(未初始化时返回 null,需确保配置 AndroidManifest)
|
||||
@@ -53,7 +60,7 @@ public class GlobalApplication extends Application {
|
||||
}
|
||||
// 将调试状态封装为 APPModel 并保存到文件
|
||||
APPModel.saveBeanToFile(
|
||||
getAppModelFilePath(application),
|
||||
getAppModelFilePath(application),
|
||||
new APPModel(isDebugging)
|
||||
);
|
||||
}
|
||||
@@ -75,6 +82,42 @@ public class GlobalApplication extends Application {
|
||||
public static boolean isDebugging() {
|
||||
return isDebugging;
|
||||
}
|
||||
|
||||
// 新增:设置 WinBoLL 服务器主机地址(同时保存到 SP 持久化)
|
||||
public static void setWinbollHost(String host) {
|
||||
if (sInstance == null) {
|
||||
LogUtils.e(TAG, "setWinbollHost: 应用未初始化,设置失败");
|
||||
return;
|
||||
}
|
||||
// 检查并补全末尾 / 核心改动
|
||||
if (host != null && !host.isEmpty() && !host.endsWith("/")) {
|
||||
host += "/";
|
||||
}
|
||||
// 更新内存中的字段
|
||||
winbollHost = host;
|
||||
// 保存到 SP 持久化(私有模式,安全)
|
||||
SharedPreferences sp = sInstance.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE);
|
||||
sp.edit().putString(SP_KEY_WINBOLL_HOST, host).apply();
|
||||
LogUtils.d(TAG, "setWinbollHost: 服务器地址已设置并持久化,host=" + host);
|
||||
}
|
||||
|
||||
|
||||
// 新增:获取 WinBoLL 服务器主机地址(优先内存,内存为空则从 SP 读取)
|
||||
public static String getWinbollHost() {
|
||||
if (winbollHost != null) {
|
||||
// 内存中存在,直接返回(提高效率)
|
||||
return winbollHost;
|
||||
}
|
||||
if (sInstance == null) {
|
||||
LogUtils.e(TAG, "getWinbollHost: 应用未初始化,获取失败");
|
||||
return null;
|
||||
}
|
||||
// 内存中不存在,从 SP 读取并更新到内存
|
||||
SharedPreferences sp = sInstance.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE);
|
||||
winbollHost = sp.getString(SP_KEY_WINBOLL_HOST, "https://console.winboll.cc/");
|
||||
LogUtils.d(TAG, "getWinbollHost: 从 SP 读取服务器地址,host=" + winbollHost);
|
||||
return winbollHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用启动时初始化(仅执行一次)
|
||||
@@ -85,12 +128,13 @@ public class GlobalApplication extends Application {
|
||||
super.onCreate();
|
||||
// 初始化单例实例(确保在所有初始化操作前完成)
|
||||
sInstance = this;
|
||||
|
||||
|
||||
// 初始化基础组件(日志、崩溃处理、Toast)
|
||||
initCoreComponents();
|
||||
// 恢复/初始化调试模式状态(从本地文件读取,无文件则默认关闭调试)
|
||||
restoreDebugStatus();
|
||||
// 新增:初始化服务器地址(从 SP 读取到内存,提高后续访问效率)
|
||||
initWinbollHost();
|
||||
|
||||
LogUtils.d(TAG, "GlobalApplication 初始化完成,单例实例已创建");
|
||||
}
|
||||
@@ -115,7 +159,7 @@ public class GlobalApplication extends Application {
|
||||
private void restoreDebugStatus() {
|
||||
// 从文件加载 APPModel 实例(存储调试状态的模型类)
|
||||
APPModel appModel = APPModel.loadBeanFromFile(
|
||||
getAppModelFilePath(this),
|
||||
getAppModelFilePath(this),
|
||||
APPModel.class
|
||||
);
|
||||
|
||||
@@ -131,6 +175,11 @@ public class GlobalApplication extends Application {
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:初始化服务器地址(应用启动时从 SP 读取到内存)
|
||||
private void initWinbollHost() {
|
||||
getWinbollHost(); // 触发从 SP 读取并更新内存
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取应用名称(从 AndroidManifest.xml 的 android:label 读取)
|
||||
* @param context 上下文(建议传入 Application 上下文,避免内存泄漏)
|
||||
@@ -154,7 +203,7 @@ public class GlobalApplication extends Application {
|
||||
return appName;
|
||||
} catch (NameNotFoundException e) {
|
||||
// 包名不存在(理论上不会发生,捕获异常避免崩溃)
|
||||
LogUtils.d(TAG, e, Thread.currentThread().getStackTrace());
|
||||
LogUtils.d(TAG, e, Thread.currentThread().getStackTrace());
|
||||
//LogUtils.e(TAG, "获取应用名称失败:包名不存在", e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
@@ -170,7 +219,6 @@ public class GlobalApplication extends Application {
|
||||
// 释放单例引用(可选,避免内存泄漏风险)
|
||||
sInstance = null;
|
||||
LogUtils.d(TAG, "GlobalApplication 终止,单例实例已释放");
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package cc.winboll.studio.libappbase.dialogs;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.Toast;
|
||||
import cc.winboll.studio.libappbase.GlobalApplication;
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.libappbase.R;
|
||||
import cc.winboll.studio.libappbase.ToastUtils;
|
||||
|
||||
/**
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/22 20:59
|
||||
* @Describe WinBoLL服务器地址设置对话框(调试模式专用)
|
||||
*/
|
||||
public class DebugHostDialog extends Dialog implements View.OnClickListener {
|
||||
public static final String TAG = "DebugHostDialog";
|
||||
|
||||
private Context mContext;
|
||||
private EditText etHostInput;
|
||||
private Button btnConfirm;
|
||||
private Button btnCancel;
|
||||
|
||||
// 构造方法(适配默认样式)
|
||||
public DebugHostDialog(Context context) {
|
||||
super(context, R.style.DialogStyle);
|
||||
this.mContext = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.dialog_winboll_host); // 绑定XML布局
|
||||
setCancelable(true); // 点击外部可关闭
|
||||
initView();
|
||||
initData();
|
||||
LogUtils.d(TAG, "DebugHostDialog 初始化完成");
|
||||
}
|
||||
|
||||
// 初始化视图
|
||||
private void initView() {
|
||||
etHostInput = findViewById(R.id.et_host_input);
|
||||
btnConfirm = findViewById(R.id.btn_confirm);
|
||||
btnCancel = findViewById(R.id.btn_cancel);
|
||||
|
||||
// 绑定点击事件
|
||||
btnConfirm.setOnClickListener(this);
|
||||
btnCancel.setOnClickListener(this);
|
||||
}
|
||||
|
||||
// 初始化数据(显示当前已保存的地址)
|
||||
private void initData() {
|
||||
String currentHost = GlobalApplication.getWinbollHost();
|
||||
if (!TextUtils.isEmpty(currentHost)) {
|
||||
etHostInput.setText(currentHost);
|
||||
etHostInput.setSelection(currentHost.length()); // 光标定位到末尾
|
||||
LogUtils.d(TAG, "当前已保存的服务器地址:" + currentHost);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
int id = v.getId();
|
||||
if (id == R.id.btn_confirm) {
|
||||
handleConfirm(); // 确认设置
|
||||
} else if (id == R.id.btn_cancel) {
|
||||
dismiss(); // 取消对话框
|
||||
}
|
||||
}
|
||||
|
||||
// 处理确认设置逻辑
|
||||
private void handleConfirm() {
|
||||
String inputHost = etHostInput.getText().toString().trim();
|
||||
if (TextUtils.isEmpty(inputHost)) {
|
||||
ToastUtils.show("服务器地址不能为空");
|
||||
LogUtils.w(TAG, "设置失败:地址为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 简单校验URL格式(避免明显错误)
|
||||
if (!inputHost.startsWith("http://") && !inputHost.startsWith("https://")) {
|
||||
ToastUtils.show("地址需以http://或https://开头");
|
||||
LogUtils.w(TAG, "设置失败:地址格式错误,input=" + inputHost);
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存地址到SP+内存
|
||||
GlobalApplication.setWinbollHost(inputHost);
|
||||
ToastUtils.show("服务器地址设置成功");
|
||||
LogUtils.d(TAG, "服务器地址设置成功:" + inputHost);
|
||||
dismiss(); // 关闭对话框
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
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;
|
||||
String projectName;
|
||||
String versionName;
|
||||
|
||||
public SignGetDialog(Context context, String projectName, String versionName) {
|
||||
super(context, R.style.DialogStyle); // 适配默认对话框样式
|
||||
this.mContext = context;
|
||||
this.projectName = projectName;
|
||||
this.versionName = versionName;
|
||||
}
|
||||
|
||||
@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() {
|
||||
// 2. 正版校验+显示结果
|
||||
// 调用处直接删除base64SignFingerprint参数即可
|
||||
new APPUtils().checkAPKValidation(
|
||||
mContext,
|
||||
this.projectName,
|
||||
this.versionName,
|
||||
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,39 @@
|
||||
package cc.winboll.studio.libappbase.models;
|
||||
|
||||
/**
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/22 20:37
|
||||
*/
|
||||
// ==================== JSON响应模型(与后端返回字段完全匹配)====================
|
||||
public class SignCheckResponse {
|
||||
private int code; // 根节点code(后端返回)
|
||||
private String msg; // 根节点提示信息(后端返回,替换原message)
|
||||
private DataBean data; // 根节点data对象(后端返回)
|
||||
|
||||
// 内部DataBean:对应后端返回的data字段内容
|
||||
public static class DataBean {
|
||||
private boolean valid; // 实际是否合法的标识(后端data.valid)
|
||||
private String signature; // 加密后的签名
|
||||
private String decryptedSign;// 解密后的原始签名
|
||||
private long validTime; // 时间戳
|
||||
}
|
||||
|
||||
// Getter/Setter(关键:获取data中的valid字段)
|
||||
public boolean isValid() {
|
||||
return data != null && data.valid; // 从data中获取valid值
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return msg; // 对应后端根节点的msg字段
|
||||
}
|
||||
|
||||
// 其他必要的Getter/Setter(用于后续扩展)
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public DataBean getData() {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
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.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.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URLEncoder;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
|
||||
import okhttp3.Call;
|
||||
import okhttp3.Callback;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
/**
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @CreateTime 2026-01-20 19:17:00
|
||||
* @LastEditTime 2026-01-24 02:18:00
|
||||
* @Describe APPUtils 应用包名、签名校验工具类(OKHTTP网络校验版,兼容Java7,含URL编码+APK包签名+SHA256哈希校验)
|
||||
*/
|
||||
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();
|
||||
|
||||
// ===================================== 对外核心方法 =====================================
|
||||
/**
|
||||
* 检查应用合法性(签名校验+APK哈希校验+网络接口校验)
|
||||
* @param context 上下文
|
||||
* @param projectName 项目名称
|
||||
* @param versionName 应用版本名
|
||||
* @param callback 校验结果回调(主线程回调)
|
||||
*/
|
||||
public void checkAPKValidation(Context context, String projectName, String versionName, final CheckResultCallback callback) {
|
||||
// 入参调试日志
|
||||
LogUtils.d(TAG, "checkAPKValidation: 入参 projectName=" + projectName + ", versionName=" + versionName);
|
||||
// 空参校验
|
||||
if (context == null) {
|
||||
LogUtils.w(TAG, "checkAPKValidation: 入参context为空,跳过校验");
|
||||
if (callback != null) {
|
||||
callback.onResult(false, "context为空");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (projectName == null || projectName.trim().isEmpty()) {
|
||||
LogUtils.w(TAG, "checkAPKValidation: 入参projectName为空,跳过校验");
|
||||
if (callback != null) {
|
||||
callback.onResult(false, "projectName为空");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (versionName == null || versionName.trim().isEmpty()) {
|
||||
LogUtils.w(TAG, "checkAPKValidation: 入参versionName为空,跳过校验");
|
||||
if (callback != null) {
|
||||
callback.onResult(false, "versionName为空");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用签名/哈希获取方法
|
||||
LogUtils.d(TAG, "checkAPKValidation: 开始获取应用官方签名与APK SHA256哈希");
|
||||
// String clientSign = getOfficialSignBase64(context);
|
||||
// String clientHash = getApkSHA256Hash(context);
|
||||
// 获取与服务端对齐的签名
|
||||
String clientSign = ApkSignUtils.getApkSignAlignedWithServer(context);
|
||||
// 获取哈希(不变)
|
||||
String clientHash = ApkSignUtils.getApkSHA256Hash(context);
|
||||
// 传服务端校验
|
||||
|
||||
// 签名/哈希结果校验
|
||||
if (clientSign == null) {
|
||||
LogUtils.e(TAG, "checkAPKValidation: 获取应用官方签名失败");
|
||||
if (callback != null) {
|
||||
callback.onResult(false, "获取应用签名失败");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (clientHash == null) {
|
||||
LogUtils.e(TAG, "checkAPKValidation: 获取APK SHA256哈希失败");
|
||||
if (callback != null) {
|
||||
callback.onResult(false, "获取APK哈希失败");
|
||||
}
|
||||
return;
|
||||
}
|
||||
LogUtils.d(TAG, "checkAPKValidation: 签名获取成功,哈希获取成功");
|
||||
|
||||
// URL编码动态参数
|
||||
LogUtils.d(TAG, "checkAPKValidation: 开始对动态参数进行URL编码");
|
||||
String encodeProjectName = urlEncode(projectName);
|
||||
String encodeVersionName = urlEncode(versionName);
|
||||
String encodeClientSign = urlEncode(clientSign);
|
||||
String encodeClientHash = urlEncode(clientHash);
|
||||
|
||||
// 构建请求URL
|
||||
String requestUrl = String.format("%s?isDebug=%s&projectName=%s&versionName=%s&clientSign=%s&clientHash=%s",
|
||||
GlobalApplication.getWinbollHost() + CHECK_API_URI,
|
||||
String.format("%s", GlobalApplication.isDebugging()),
|
||||
encodeProjectName,
|
||||
encodeVersionName,
|
||||
encodeClientSign,
|
||||
encodeClientHash);
|
||||
LogUtils.d(TAG, "checkAPKValidation: 构建校验请求URL=" + requestUrl);
|
||||
|
||||
// 发起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, "checkAPKValidation: " + errorMsg, e);
|
||||
if (callback != null) {
|
||||
new Handler(Looper.getMainLooper()).post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
callback.onResult(false, errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResponse(Call call, Response response) throws IOException {
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
String responseJson = response.body().string();
|
||||
LogUtils.d(TAG, "checkAPKValidation: 网络校验响应JSON=" + responseJson);
|
||||
// 解析响应结果
|
||||
final SignCheckResponse checkResponse = sGson.fromJson(responseJson, SignCheckResponse.class);
|
||||
final boolean isValid = checkResponse != null && checkResponse.isValid();
|
||||
final String msg = checkResponse != null ? checkResponse.getMessage() : "响应解析失败";
|
||||
LogUtils.d(TAG, "checkAPKValidation: 校验结果解析完成,isValid=" + isValid + ", msg=" + msg);
|
||||
if (callback != null) {
|
||||
new Handler(Looper.getMainLooper()).post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
callback.onResult(isValid, msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
final String errorMsg = "网络校验响应失败,code=" + response.code();
|
||||
LogUtils.e(TAG, "checkAPKValidation: " + errorMsg);
|
||||
if (callback != null) {
|
||||
new Handler(Looper.getMainLooper()).post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
callback.onResult(false, errorMsg);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ===================================== 内部工具方法 =====================================
|
||||
/**
|
||||
* 获取当前应用的APK包文件对象
|
||||
* @param context 上下文
|
||||
* @return APK文件File,失败返回null
|
||||
*/
|
||||
private File getCurrentAppApkFile(Context context) {
|
||||
try {
|
||||
ApplicationInfo appInfo = context.getPackageManager().getApplicationInfo(
|
||||
context.getPackageName(), 0
|
||||
);
|
||||
String apkPath = appInfo.sourceDir;
|
||||
File apkFile = new File(apkPath);
|
||||
return apkFile.exists() && apkFile.isFile() ? apkFile : null;
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
LogUtils.e(TAG, "getCurrentAppApkFile: 获取应用APK路径失败", e);
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "getCurrentAppApkFile: 未知异常", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复刻Android系统Signature的DER编码解析逻辑
|
||||
* 从CERT.RSA的DER字节中提取与signatures[0].toByteArray()一致的签名字节
|
||||
* @param derBytes CERT.RSA的DER编码字节
|
||||
* @return 标准签名字节,失败返回null
|
||||
*/
|
||||
private byte[] parseDerForAndroidSignature(byte[] derBytes) {
|
||||
try {
|
||||
int offset = 0;
|
||||
if (derBytes == null || derBytes.length < 2 || derBytes[offset++] != 0x30) {
|
||||
LogUtils.w(TAG, "parseDerForAndroidSignature: DER编码非标准SEQUENCE格式");
|
||||
return null;
|
||||
}
|
||||
// 跳过长度字段
|
||||
if (derBytes[offset] > 0x80) {
|
||||
int lenLen = derBytes[offset++] & 0x7F;
|
||||
offset += lenLen;
|
||||
} else {
|
||||
offset++;
|
||||
}
|
||||
// 提取签名块字节
|
||||
while (offset < derBytes.length) {
|
||||
if (derBytes[offset] == 0x30) {
|
||||
byte[] sigBytes = new byte[derBytes.length - offset];
|
||||
System.arraycopy(derBytes, offset, sigBytes, 0, sigBytes.length);
|
||||
return sigBytes;
|
||||
}
|
||||
offset++;
|
||||
}
|
||||
LogUtils.w(TAG, "parseDerForAndroidSignature: DER编码中未找到签名块SEQUENCE");
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "parseDerForAndroidSignature: 解析DER编码失败", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入流转字节数组(Java7适配,无第三方依赖)
|
||||
* @param is 输入流
|
||||
* @return 字节数组,失败返回空数组
|
||||
* @throws IOException 流读取异常
|
||||
*/
|
||||
private byte[] readStreamToBytes(InputStream is) throws IOException {
|
||||
if (is == null) return new byte[0];
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[1024];
|
||||
int len;
|
||||
while ((len = is.read(buffer)) != -1) {
|
||||
bos.write(buffer, 0, len);
|
||||
}
|
||||
byte[] result = bos.toByteArray();
|
||||
bos.close();
|
||||
is.close();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL编码工具(Java7适配,UTF-8编码,处理特殊字符)
|
||||
* @param content 待编码内容
|
||||
* @return 编码后的字符串,失败返回原内容
|
||||
*/
|
||||
private static String urlEncode(String content) {
|
||||
try {
|
||||
return URLEncoder.encode(content, "UTF-8");
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "urlEncode: 编码失败,content=" + content, e);
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从PackageManager获取应用签名SHA1指纹(BASE64编码,快速获取)
|
||||
* @param context 上下文
|
||||
* @return 签名Base64字符串,失败返回null
|
||||
*/
|
||||
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 e) {
|
||||
LogUtils.e(TAG, "getAppSignFingerprint: 获取包信息异常", e);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
LogUtils.e(TAG, "getAppSignFingerprint: 获取SHA1算法异常", e);
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "getAppSignFingerprint: 未知异常", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===================================== 回调接口 =====================================
|
||||
/**
|
||||
* 校验结果回调接口(主线程调用)
|
||||
*/
|
||||
public interface CheckResultCallback {
|
||||
/**
|
||||
* 校验结果回调
|
||||
* @param isValid 是否合法
|
||||
* @param message 校验信息/错误信息
|
||||
*/
|
||||
void onResult(boolean isValid, String message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package cc.winboll.studio.libappbase.utils;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.ApplicationInfo;
|
||||
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.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
|
||||
/**
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @CreateTime 2026-01-24 10:00:00
|
||||
* @LastEditTime 2026-01-24 16:45:00
|
||||
* @Describe 客户端签名工具类:与服务端APKFileUtils签名/哈希校验逻辑严格对齐,兼容Java7
|
||||
*/
|
||||
public class ApkSignUtils {
|
||||
// ===================================== 全局常量 =====================================
|
||||
private static final String TAG = "ApkSignUtils";
|
||||
private static final String CERT_RSA_UPPER = "META-INF/CERT.RSA";
|
||||
private static final String CERT_RSA_LOWER = "META-INF/cert.rsa";
|
||||
private static final String SIGN_ALGORITHM_SHA1 = "SHA1";
|
||||
private static final String SIGN_ALGORITHM_SHA256 = "SHA-256";
|
||||
private static final int BUFFER_SIZE_4K = 4096;
|
||||
private static final int BUFFER_SIZE_8K = 8192;
|
||||
|
||||
// ===================================== 对外核心方法 =====================================
|
||||
/**
|
||||
* 获取与服务端对齐的签名Base64(核心方法)
|
||||
* 直接读取APK内CERT.RSA原始字节 → SHA1摘要 → Base64.NO_WRAP编码,与服务端逻辑完全一致
|
||||
* @param context 上下文,用于获取当前应用APK路径
|
||||
* @return 签名Base64字符串,失败返回null
|
||||
*/
|
||||
public static String getApkSignAlignedWithServer(Context context) {
|
||||
LogUtils.d(TAG, "getApkSignAlignedWithServer: 方法调用,开始获取服务端对齐签名");
|
||||
if (context == null) {
|
||||
LogUtils.w(TAG, "getApkSignAlignedWithServer: 入参context为空,直接返回null");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 获取当前应用APK的真实安装路径
|
||||
ApplicationInfo appInfo = context.getApplicationContext().getApplicationInfo();
|
||||
String apkPath = appInfo.sourceDir;
|
||||
LogUtils.d(TAG, "getApkSignAlignedWithServer: 获取到当前应用APK路径=" + apkPath);
|
||||
if (apkPath == null || apkPath.trim().isEmpty()) {
|
||||
LogUtils.e(TAG, "getApkSignAlignedWithServer: 获取APK路径为空,获取签名失败");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 读取APK内的CERT.RSA原始字节流(兼容大小写命名)
|
||||
byte[] certRawBytes = readCertRsaRawBytes(apkPath);
|
||||
if (certRawBytes == null || certRawBytes.length == 0) {
|
||||
LogUtils.e(TAG, "getApkSignAlignedWithServer: 读取CERT.RSA原始字节失败,字节数组为空");
|
||||
return null;
|
||||
}
|
||||
LogUtils.d(TAG, "getApkSignAlignedWithServer: 成功读取CERT.RSA原始字节,长度=" + certRawBytes.length);
|
||||
|
||||
// 3. SHA1摘要计算 + Base64.NO_WRAP编码(与服务端完全对齐)
|
||||
MessageDigest md = MessageDigest.getInstance(SIGN_ALGORITHM_SHA1);
|
||||
byte[] signDigest = md.digest(certRawBytes);
|
||||
String signBase64 = Base64.encodeToString(signDigest, Base64.NO_WRAP);
|
||||
LogUtils.d(TAG, "getApkSignAlignedWithServer: 成功计算服务端对齐签名Base64,完成方法执行");
|
||||
return signBase64;
|
||||
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
LogUtils.e(TAG, "getApkSignAlignedWithServer: 获取SHA1算法实例失败,获取签名失败", e);
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "getApkSignAlignedWithServer: 获取服务端对齐签名发生未知异常", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前运行APK的SHA256哈希值
|
||||
* 读取APK文件完整字节流计算SHA256,转小写64位16进制字符串,与服务端校验逻辑一致
|
||||
* @param context 上下文,用于获取当前应用APK路径
|
||||
* @return SHA256哈希小写字符串,失败返回null
|
||||
*/
|
||||
public static String getApkSHA256Hash(Context context) {
|
||||
LogUtils.d(TAG, "getApkSHA256Hash: 方法调用,开始获取APK SHA256哈希值");
|
||||
if (context == null) {
|
||||
LogUtils.w(TAG, "getApkSHA256Hash: 入参context为空,直接返回null");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 获取当前应用APK的真实安装路径
|
||||
ApplicationInfo appInfo = context.getApplicationContext().getApplicationInfo();
|
||||
String apkPath = appInfo.sourceDir;
|
||||
LogUtils.d(TAG, "getApkSHA256Hash: 获取到当前应用APK路径=" + 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(SIGN_ALGORITHM_SHA256);
|
||||
FileInputStream fis = new FileInputStream(apkFile);
|
||||
byte[] buffer = new byte[BUFFER_SIZE_8K];
|
||||
int len;
|
||||
while ((len = fis.read(buffer)) != -1) {
|
||||
md.update(buffer, 0, len);
|
||||
}
|
||||
fis.close();
|
||||
|
||||
// 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);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===================================== 内部工具方法 =====================================
|
||||
/**
|
||||
* 读取APK内CERT.RSA文件的原始字节流(兼容大小写命名:CERT.RSA/cert.rsa)
|
||||
* @param apkPath 当前应用APK的完整安装路径
|
||||
* @return CERT.RSA原始字节数组,失败返回null
|
||||
* @throws Exception 流读取/APK解析异常
|
||||
*/
|
||||
private static byte[] readCertRsaRawBytes(String apkPath) throws Exception {
|
||||
LogUtils.d(TAG, "readCertRsaRawBytes: 方法调用,APK路径=" + apkPath);
|
||||
JarFile jarFile = new JarFile(apkPath);
|
||||
JarEntry certEntry = null;
|
||||
|
||||
// 优先读取大写命名,找不到则读取小写命名
|
||||
certEntry = jarFile.getJarEntry(CERT_RSA_UPPER);
|
||||
if (certEntry == null) {
|
||||
LogUtils.d(TAG, "readCertRsaRawBytes: 未找到META-INF/CERT.RSA,尝试读取META-INF/cert.rsa");
|
||||
certEntry = jarFile.getJarEntry(CERT_RSA_LOWER);
|
||||
}
|
||||
|
||||
// 未找到有效CERT.RSA文件
|
||||
if (certEntry == null) {
|
||||
LogUtils.e(TAG, "readCertRsaRawBytes: APK内未找到CERT.RSA/cert.rsa签名文件");
|
||||
jarFile.close();
|
||||
return null;
|
||||
}
|
||||
|
||||
// 读取文件原始字节流并关闭流
|
||||
InputStream is = jarFile.getInputStream(certEntry);
|
||||
byte[] bytes = readStreamToBytes(is);
|
||||
is.close();
|
||||
jarFile.close();
|
||||
LogUtils.d(TAG, "readCertRsaRawBytes: 成功读取CERT.RSA字节,完成方法执行");
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入流转字节数组(与服务端工具方法逻辑完全一致,4K缓冲区)
|
||||
* @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_SIZE_4K];
|
||||
int len;
|
||||
while ((len = is.read(buffer)) != -1) {
|
||||
bos.write(buffer, 0, len);
|
||||
}
|
||||
byte[] result = bos.toByteArray();
|
||||
// 关闭流资源,避免泄漏
|
||||
is.close();
|
||||
bos.close();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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.util.Base64;
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
/**
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/20 19:50
|
||||
* @Describe 获取应用签名指纹(SHA1+Base64,直接复制用)
|
||||
*/
|
||||
public class SignGetUtils {
|
||||
private static final String TAG = "SignGetUtils";
|
||||
|
||||
/**
|
||||
* 一键获取当前应用签名指纹(直接调用,看日志复制结果)
|
||||
*/
|
||||
public static void getCurrentAppSign(Context context) {
|
||||
if (context == null) {
|
||||
LogUtils.e(TAG, "context不能为空");
|
||||
return;
|
||||
}
|
||||
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.e(TAG, "未获取到应用签名");
|
||||
return;
|
||||
}
|
||||
// 和APPUtils校验格式完全一致(SHA1+Base64 NO_WRAP)
|
||||
MessageDigest md = MessageDigest.getInstance("SHA1");
|
||||
md.update(signatures[0].toByteArray());
|
||||
String signBase64 = Base64.encodeToString(md.digest(), Base64.NO_WRAP);
|
||||
|
||||
// 关键日志:复制【】里的内容到APPUtils的TARGET_SIGN_FINGERPRINT
|
||||
LogUtils.d(TAG, "当前应用包名:" + context.getPackageName());
|
||||
LogUtils.d(TAG, "当前应用签名指纹(直接复制):【" + signBase64 + "】");
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
LogUtils.e(TAG, "获取签名失败:包名不存在", e);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
LogUtils.e(TAG, "获取签名失败:不支持SHA1", e);
|
||||
} catch (Exception e) {
|
||||
LogUtils.e(TAG, "获取签名失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:直接返回签名字符串,供对话框调用
|
||||
// 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;
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -8,21 +8,23 @@ import android.util.AttributeSet;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.widget.EditText;
|
||||
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.models.APPInfo;
|
||||
|
||||
/**
|
||||
* @Describe AboutView 原生实现关于页面,无第三方依赖,适配API30,抽象通用功能控件(邮件/网页跳转)
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/11 12:23:00
|
||||
* @LastEditTime 2026/01/12 01:05:30
|
||||
* @LastEditTime 2026/01/20 20:45:00
|
||||
*/
|
||||
public class AboutView extends LinearLayout {
|
||||
// 全局常量区(标识、回调标识)
|
||||
@@ -41,7 +43,6 @@ public class AboutView extends LinearLayout {
|
||||
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 LINE_HEIGHT = 1;
|
||||
private static final int ITEM_ICON_SIZE = 24;
|
||||
|
||||
// 成员属性区(按 核心依赖→业务配置→视图相关 归类排序,注释清晰)
|
||||
@@ -66,12 +67,19 @@ public class AboutView extends LinearLayout {
|
||||
private EditText metDevUserName; // 调试用户名输入框
|
||||
private EditText metDevUserPassword; // 调试密码输入框
|
||||
|
||||
// 视图绑定
|
||||
private ImageView ivAppIcon;
|
||||
private TextView tvAppNameVersion;
|
||||
private TextView tvAppDesc;
|
||||
private LinearLayout llFunctionContainer;
|
||||
|
||||
// 构造方法区(按 参数从少到多 排序,适配 代码创建+XML引用 场景)
|
||||
public AboutView(Context context) {
|
||||
super(context);
|
||||
LogUtils.d(TAG, "AboutView(Context) 构造方法调用,代码创建视图场景");
|
||||
this.mContext = context;
|
||||
initDefaultParams();
|
||||
initViewFromXml();
|
||||
}
|
||||
|
||||
public AboutView(Context context, APPInfo appInfo) {
|
||||
@@ -79,6 +87,7 @@ public class AboutView extends LinearLayout {
|
||||
LogUtils.d(TAG, "AboutView(Context,APPInfo) 构造调用,入参APPInfo:" + (appInfo == null ? "null" : appInfo.getAppName()));
|
||||
this.mContext = context;
|
||||
this.mAPPInfo = appInfo;
|
||||
initViewFromXml();
|
||||
initAll();
|
||||
}
|
||||
|
||||
@@ -87,6 +96,7 @@ public class AboutView extends LinearLayout {
|
||||
LogUtils.d(TAG, "AboutView(Context,AttributeSet) 构造调用,XML布局引用场景");
|
||||
this.mContext = context;
|
||||
initDefaultParams();
|
||||
initViewFromXml();
|
||||
}
|
||||
|
||||
public AboutView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
@@ -94,8 +104,55 @@ public class AboutView extends LinearLayout {
|
||||
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, mszAppGitName, mszAppVersionName).show(); // 弹出对话框
|
||||
}
|
||||
});
|
||||
ibWinBoLLHostDialog.setOnClickListener(new OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
LogUtils.d(TAG, "签名获取按钮点击,弹出SignGetDialog");
|
||||
new DebugHostDialog(mContext).show(); // 弹出对话框
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 对外公开方法区(供外部调用,职责单一,注释明确)
|
||||
/**
|
||||
* 一站式初始化所有关于页逻辑,包含参数、信息、视图全流程初始化
|
||||
@@ -106,10 +163,6 @@ public class AboutView extends LinearLayout {
|
||||
LogUtils.w(TAG, "initAll() 初始化终止:APPInfo 为 null,无法获取应用核心信息");
|
||||
return;
|
||||
}
|
||||
// 基础布局配置
|
||||
setOrientation(VERTICAL);
|
||||
setPadding(dp2px(PADDING_MID), dp2px(PADDING_LARGE), dp2px(PADDING_MID), dp2px(PADDING_LARGE));
|
||||
setGravity(Gravity.CENTER_HORIZONTAL);
|
||||
|
||||
// 按初始化流程执行,有序无冗余
|
||||
initDefaultParams();
|
||||
@@ -129,7 +182,7 @@ public class AboutView extends LinearLayout {
|
||||
public void setAPPInfoAndInit(APPInfo appInfo) {
|
||||
LogUtils.d(TAG, "setAPPInfoAndInit() 调用,传入新APPInfo:" + (appInfo == null ? "null" : appInfo.getAppName()));
|
||||
this.mAPPInfo = appInfo;
|
||||
removeAllViews();
|
||||
llFunctionContainer.removeAllViews();
|
||||
initAll();
|
||||
LogUtils.d(TAG, "setAPPInfoAndInit() 应用信息重置+页面重构完成");
|
||||
}
|
||||
@@ -141,7 +194,7 @@ public class AboutView extends LinearLayout {
|
||||
public void setAPPInfo(APPInfo appInfo) {
|
||||
LogUtils.d(TAG, "setAPPInfo() 调用,传入APPInfo:" + (appInfo == null ? "null" : appInfo.getAppName()));
|
||||
this.mAPPInfo = appInfo;
|
||||
removeAllViews();
|
||||
llFunctionContainer.removeAllViews();
|
||||
initAll();
|
||||
}
|
||||
|
||||
@@ -196,7 +249,7 @@ public class AboutView extends LinearLayout {
|
||||
LogUtils.d(TAG, "initAppVersionInfo() 获取版本号失败,默认赋值unknown", e);
|
||||
mszAppVersionName = "unknown";
|
||||
}
|
||||
mszCurrentAppPackageName = String.format("%s_%s.apk", mszAppAPKName, mszAppVersionName);
|
||||
mszCurrentAppPackageName = String.format("%s_%s.apk", mszAppVersionName, mszAppVersionName);
|
||||
LogUtils.d(TAG, "initAppVersionInfo() 完成,版本号:" + mszAppVersionName + ",当前APK名:" + mszCurrentAppPackageName);
|
||||
}
|
||||
|
||||
@@ -245,77 +298,37 @@ public class AboutView extends LinearLayout {
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心视图组装:按 图标→应用信息→分割线→通用功能控件 顺序构建页面
|
||||
* 核心视图组装:赋值基础信息+添加功能项
|
||||
*/
|
||||
private void initAboutPageView() {
|
||||
LogUtils.d(TAG, "initAboutPageView() 开始组装关于页视图");
|
||||
addAppIcon();
|
||||
addAppInfoDesc();
|
||||
addLineSeparator();
|
||||
// 基础信息赋值
|
||||
ivAppIcon.setImageResource(mnAppIcon);
|
||||
tvAppNameVersion.setText(String.format("%s %s", mszAppName, mszAppVersionName));
|
||||
if (mszAppDescription.isEmpty()) {
|
||||
tvAppDesc.setVisibility(GONE);
|
||||
} else {
|
||||
tvAppDesc.setVisibility(VISIBLE);
|
||||
tvAppDesc.setText(mszAppDescription);
|
||||
}
|
||||
|
||||
// 通用功能控件:网页跳转类+邮件类,复用抽象控件
|
||||
addView(new WebJumpFunctionItemView(mContext, "WinBoLL 主页", WINBOLL_OFFICIAL_HOME, R.drawable.ic_winboll));
|
||||
addView(new EmailFunctionItemView(mContext, "联系邮箱", "WinBoLLStudio<studio@winboll.cc>", R.drawable.ic_winboll));
|
||||
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()) {
|
||||
addView(new WebJumpFunctionItemView(mContext, "应用APK下载地址", mszHomePage, R.drawable.ic_winboll));
|
||||
addFunctionView(new WebJumpFunctionItemView(mContext, "应用APK下载地址", mszHomePage, R.drawable.ic_winboll));
|
||||
}
|
||||
if (!mszGitea.isEmpty()) {
|
||||
addView(new WebJumpFunctionItemView(mContext, "应用Git源码地址", mszGitea, R.drawable.ic_winboll));
|
||||
addFunctionView(new WebJumpFunctionItemView(mContext, "应用Git源码地址", mszGitea, R.drawable.ic_winboll));
|
||||
}
|
||||
LogUtils.d(TAG, "initAboutPageView() 视图组装完成,功能项加载完毕");
|
||||
}
|
||||
|
||||
// 视图构建辅助方法区(基础视图组件)
|
||||
/**
|
||||
* 添加应用图标组件,居中展示
|
||||
*/
|
||||
private void addAppIcon() {
|
||||
ImageView ivIcon = new ImageView(mContext);
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(dp2px(ICON_SIZE), dp2px(ICON_SIZE));
|
||||
params.bottomMargin = dp2px(PADDING_MID);
|
||||
ivIcon.setLayoutParams(params);
|
||||
ivIcon.setImageResource(mnAppIcon);
|
||||
ivIcon.setScaleType(ImageView.ScaleType.CENTER_CROP);
|
||||
addView(ivIcon);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加应用名称+版本号+描述信息组件,垂直居中展示
|
||||
*/
|
||||
private void addAppInfoDesc() {
|
||||
LinearLayout llDesc = new LinearLayout(mContext);
|
||||
llDesc.setOrientation(VERTICAL);
|
||||
llDesc.setGravity(Gravity.CENTER);
|
||||
llDesc.setPadding(0, 0, 0, dp2px(PADDING_MID));
|
||||
|
||||
TextView tvAppName = new TextView(mContext);
|
||||
tvAppName.setText(String.format("%s %s", mszAppName, mszAppVersionName));
|
||||
tvAppName.setTextSize(18);
|
||||
tvAppName.setTextColor(mContext.getResources().getColor(R.color.gray_900));
|
||||
llDesc.addView(tvAppName);
|
||||
|
||||
if (!mszAppDescription.isEmpty()) {
|
||||
TextView tvDesc = new TextView(mContext);
|
||||
tvDesc.setText(mszAppDescription);
|
||||
tvDesc.setTextSize(14);
|
||||
tvDesc.setTextColor(mContext.getResources().getColor(R.color.gray_500));
|
||||
tvDesc.setPadding(0, dp2px(PADDING_SMALL), 0, 0);
|
||||
llDesc.addView(tvDesc);
|
||||
}
|
||||
addView(llDesc);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加视图分割线,区分不同功能模块
|
||||
*/
|
||||
private void addLineSeparator() {
|
||||
View line = new View(mContext);
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp2px(LINE_HEIGHT));
|
||||
// 添加功能项到容器
|
||||
private void addFunctionView(View view) {
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
|
||||
params.topMargin = dp2px(PADDING_SMALL);
|
||||
params.bottomMargin = dp2px(PADDING_MID);
|
||||
line.setLayoutParams(params);
|
||||
line.setBackgroundColor(mContext.getResources().getColor(R.color.gray_200));
|
||||
addView(line);
|
||||
llFunctionContainer.addView(view, params);
|
||||
}
|
||||
|
||||
// 工具方法区(通用工具+业务工具,静态优先,便于复用)
|
||||
|
||||
11
libappbase/src/main/res/drawable/ic_bug.xml
Normal file
11
libappbase/src/main/res/drawable/ic_bug.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24"
|
||||
android:viewportWidth="24">
|
||||
<path
|
||||
android:fillColor="#ff000000"
|
||||
android:pathData="M14,12H10V10H14M14,16H10V14H14M20,8H17.19C16.74,7.22 16.12,6.55 15.37,6.04L17,4.41L15.59,3L13.42,5.17C12.96,5.06 12.5,5 12,5C11.5,5 11.04,5.06 10.59,5.17L8.41,3L7,4.41L8.62,6.04C7.88,6.55 7.26,7.22 6.81,8H4V10H6.09C6.04,10.33 6,10.66 6,11V12H4V14H6V15C6,15.34 6.04,15.67 6.09,16H4V18H6.81C7.85,19.79 9.78,21 12,21C14.22,21 16.15,19.79 17.19,18H20V16H17.91C17.96,15.67 18,15.34 18,15V14H20V12H18V11C18,10.66 17.96,10.33 17.91,10H20V8Z"/>
|
||||
|
||||
</vector>
|
||||
11
libappbase/src/main/res/drawable/ic_key.xml
Normal file
11
libappbase/src/main/res/drawable/ic_key.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24"
|
||||
android:viewportWidth="24">
|
||||
<path
|
||||
android:fillColor="#ff000000"
|
||||
android:pathData="M7,14C5.9,14 5,13.1 5,12S5.9,10 7,10 9,10.9 9,12 8.1,14 7,14M12.6,10C11.8,7.7 9.6,6 7,6C3.7,6 1,8.7 1,12S3.7,18 7,18C9.6,18 11.8,16.3 12.6,14H16V18H20V14H23V10H12.6Z"/>
|
||||
|
||||
</vector>
|
||||
52
libappbase/src/main/res/layout/dialog_sign_get.xml
Normal file
52
libappbase/src/main/res/layout/dialog_sign_get.xml
Normal file
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp"
|
||||
android:gravity="center"
|
||||
android:background="#FFDCDCDC">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="应用指纹校验"
|
||||
android:textSize="16sp"
|
||||
android:textColor="@color/gray_900"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="12dp"/>
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_sign_fingerprint"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@android:drawable/edit_text"
|
||||
android:textSize="12sp"
|
||||
android:gravity="top"
|
||||
android:hint="签名获取中..."
|
||||
android:singleLine="false"
|
||||
android:scrollHorizontally="false"
|
||||
android:scrollbars="vertical"
|
||||
android:overScrollMode="always"
|
||||
android:typeface="monospace"
|
||||
android:paddingLeft="10dp"
|
||||
android:paddingRight="10dp"/>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_auth_result"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:textSize="11sp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/gray_900"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
60
libappbase/src/main/res/layout/dialog_winboll_host.xml
Normal file
60
libappbase/src/main/res/layout/dialog_winboll_host.xml
Normal file
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp"
|
||||
android:background="#FFFFFF">
|
||||
|
||||
<!-- 标题 -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="设置服务器地址"
|
||||
android:textSize="16sp"
|
||||
android:textColor="#212121"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="16dp"/>
|
||||
|
||||
<!-- 地址输入框 -->
|
||||
<EditText
|
||||
android:id="@+id/et_host_input"
|
||||
android:layout_width="300dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="请输入服务器地址(如http://localhost:8080)"
|
||||
android:textSize="14sp"
|
||||
android:inputType="textUri"
|
||||
android:padding="8dp"
|
||||
android:background="@android:drawable/edit_text"
|
||||
android:layout_marginBottom="16dp"/>
|
||||
|
||||
<!-- 按钮容器 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="end">
|
||||
|
||||
<!-- 取消按钮 -->
|
||||
<Button
|
||||
android:id="@+id/btn_cancel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="取消"
|
||||
android:textSize="14sp"
|
||||
android:layout_marginRight="8dp"/>
|
||||
|
||||
<!-- 确认按钮 -->
|
||||
<Button
|
||||
android:id="@+id/btn_confirm"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="确认"
|
||||
android:textSize="14sp"
|
||||
android:backgroundTint="#2196F3"
|
||||
android:textColor="#FFFFFF"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
83
libappbase/src/main/res/layout/layout_about_view.xml
Normal file
83
libappbase/src/main/res/layout/layout_about_view.xml
Normal file
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center_horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="32dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="32dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/iv_app_icon"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:scaleType="centerCrop"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_app_name_version"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="18sp"
|
||||
android:textColor="@color/gray_900"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_app_desc"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@color/gray_500"/>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1px"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:background="@color/gray_200"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/ll_function_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"/>
|
||||
|
||||
<LinearLayout
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="16dp"
|
||||
android:spacing="20dp">
|
||||
|
||||
<ImageButton
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:src="@drawable/ic_winboll"
|
||||
android:id="@+id/ib_winbollhostdialog"
|
||||
android:scaleType="fitCenter"
|
||||
android:adjustViewBounds="true"
|
||||
android:background="@null"/>
|
||||
|
||||
<ImageButton
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:src="@drawable/ic_key"
|
||||
android:id="@+id/ib_signgetdialog"
|
||||
android:scaleType="fitCenter"
|
||||
android:adjustViewBounds="true"
|
||||
android:background="@null"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
@@ -11,5 +11,10 @@
|
||||
<item name="colorText">#FF00B322</item>
|
||||
<item name="colorTextBackgound">#FF000000</item>
|
||||
</style>
|
||||
|
||||
<style name="DialogStyle" parent="@android:style/Theme.Dialog">
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
|
||||
35
libappbase/src/main/res/xml/network_security_config.xml
Normal file
35
libappbase/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<!-- 原有配置 保留 -->
|
||||
<domain includeSubdomains="true">winboll.cc</domain>
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
|
||||
<!-- 精准配置10.8.0.0/24 前20个IP(10.8.0.0~10.8.0.19)-->
|
||||
<domain includeSubdomains="false">10.8.0.0</domain>
|
||||
<domain includeSubdomains="false">10.8.0.1</domain>
|
||||
<domain includeSubdomains="false">10.8.0.2</domain>
|
||||
<domain includeSubdomains="false">10.8.0.3</domain>
|
||||
<domain includeSubdomains="false">10.8.0.4</domain>
|
||||
<domain includeSubdomains="false">10.8.0.5</domain>
|
||||
<domain includeSubdomains="false">10.8.0.6</domain>
|
||||
<domain includeSubdomains="false">10.8.0.7</domain>
|
||||
<domain includeSubdomains="false">10.8.0.8</domain>
|
||||
<domain includeSubdomains="false">10.8.0.9</domain>
|
||||
<domain includeSubdomains="false">10.8.0.10</domain>
|
||||
<domain includeSubdomains="false">10.8.0.11</domain>
|
||||
<domain includeSubdomains="false">10.8.0.12</domain>
|
||||
<domain includeSubdomains="false">10.8.0.13</domain>
|
||||
<domain includeSubdomains="false">10.8.0.14</domain>
|
||||
<domain includeSubdomains="false">10.8.0.15</domain>
|
||||
<domain includeSubdomains="false">10.8.0.16</domain>
|
||||
<domain includeSubdomains="false">10.8.0.17</domain>
|
||||
<domain includeSubdomains="false">10.8.0.18</domain>
|
||||
<domain includeSubdomains="false">10.8.0.19</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user