移除网络类库依赖,相应减少冗余功能。

This commit is contained in:
2026-05-16 02:58:13 +08:00
parent d3bc40fb12
commit f6a70519ab
8 changed files with 5 additions and 1198 deletions

View File

@@ -1,8 +1,8 @@
#Created by .winboll/winboll_app_build.gradle
#Sat May 16 02:12:52 CST 2026
#Fri May 15 18:56:10 GMT 2026
stageCount=10
libraryProject=libappbase
baseVersion=15.20
publishVersion=15.20.9
buildCount=1
buildCount=4
baseBetaVersion=15.20.10

View File

@@ -26,11 +26,4 @@ android {
dependencies {
api fileTree(dir: 'libs', include: ['*.jar'])
// JSch for SFTP
api 'com.jcraft:jsch:0.1.55'
// Gson for JSON
api 'com.google.code.gson:gson:2.8.9'
// OkHttp for HTTP
api 'com.squareup.okhttp3:okhttp:3.12.13'
}

View File

@@ -1,8 +1,8 @@
#Created by .winboll/winboll_app_build.gradle
#Sat May 16 02:12:52 CST 2026
#Fri May 15 18:56:10 GMT 2026
stageCount=10
libraryProject=libappbase
baseVersion=15.20
publishVersion=15.20.9
buildCount=1
buildCount=4
baseBetaVersion=15.20.10

View File

@@ -1,186 +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.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 appName;
private String versionName;
private String clientSign;
private String clientHash;
// ===================================== 构造方法 =====================================
public APPValidationDialog(Context context, String appName, String versionName) {
super(context, R.style.DialogStyle);
this.mContext = context;
this.appName = appName;
this.versionName = versionName;
LogUtils.d(TAG, "AppValidationDialog: 构造方法初始化,入参-> projectName=" + appName + ", 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,
appName,
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();
}
}

View File

@@ -1,199 +0,0 @@
package cc.winboll.studio.libappbase.utils;
import android.content.Context;
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 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 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 final OkHttpClient sOkHttpClient = new OkHttpClient();
// Gson解析单例全局复用提高解析效率
private static final Gson sGson = new Gson();
// ===================================== 对外核心校验方法 =====================================
/**
* 检查应用合法性(外部传入签名+哈希,拼接调试标识发起网络校验)
* @param context 上下文,用于主线程回调
* @param projectName 项目名称(服务端区分项目标识)
* @param versionName 应用版本名(服务端版本校验)
* @param clientSign 外部计算的应用签名字符串Base64
* @param clientHash 外部计算的APK SHA256哈希字符串小写16进制
* @param callback 校验结果回调(主线程调用,返回是否合法+提示信息)
*/
public void checkAPKValidation(Context context, String appName, String versionName,
String clientSign, String clientHash, final CheckResultCallback callback) {
// 方法调用+全量入参调试日志
LogUtils.d(TAG, "checkAPKValidation: 方法调用,入参-> appName=" + appName
+ ", versionName=" + versionName + ", clientSign=" + clientSign + ", clientHash=" + clientHash);
// 1. 核心入参空值校验(快速失败)
if (context == null) {
LogUtils.w(TAG, "checkAPKValidation: 入参context为空直接返回校验失败");
callCallbackOnMainThread(callback, false, "上下文对象不能为空");
return;
}
if (isStringEmpty(appName)) {
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: 入参校验通过,开始处理网络请求");
// 2. 动态参数URL编码避免特殊字符导致请求解析异常
LogUtils.d(TAG, "checkAPKValidation: 开始对动态参数进行UTF-8 URL编码");
String encodeProjectName = urlEncode(appName);
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?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请求避免阻塞主线程
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) {
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, "checkAPKValidation: 网络校验响应成功JSON=" + responseJson);
SignCheckResponse checkResponse = sGson.fromJson(responseJson, SignCheckResponse.class);
boolean isValid = checkResponse != null && checkResponse.isValid();
String msg = checkResponse != null ? checkResponse.getMessage() : "服务端响应解析失败";
LogUtils.d(TAG, "checkAPKValidation: 校验结果解析完成isValid=" + isValid + ", 提示信息=" + msg);
callCallbackOnMainThread(callback, isValid, msg);
} else {
// 响应失败,返回状态码信息
String errorMsg = "网络校验响应失败,服务端状态码=" + response.code();
LogUtils.e(TAG, "checkAPKValidation: " + errorMsg);
callCallbackOnMainThread(callback, false, errorMsg);
}
}
});
}
// ===================================== 内部工具方法 =====================================
/**
* 字符串空值/空白校验工具
* @param str 待校验字符串
* @return true=空/空白false=非空
*/
private boolean isStringEmpty(String str) {
return str == null || str.trim().isEmpty();
}
/**
* URL编码工具Java7适配UTF-8编码处理特殊字符
* @param content 待编码内容
* @return 编码后的字符串,编码失败返回原内容
*/
private String urlEncode(String content) {
try {
return URLEncoder.encode(content, "UTF-8");
} catch (Exception e) {
LogUtils.e(TAG, "urlEncode: 字符串编码失败content=" + content, e);
return content;
}
}
/**
* 主线程执行回调(统一处理,避免外部线程切换)
* @param callback 回调接口
* @param isValid 是否合法
* @param message 提示信息
*/
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);
}
});
}
}
// ===================================== 校验结果回调接口 =====================================
/**
* 应用合法性校验结果回调接口(主线程调用)
*/
public interface CheckResultCallback {
/**
* 校验结果回调方法
* @param isValid 是否合法true=校验通过false=校验失败)
* @param message 校验提示信息(失败时返回错误原因,成功时返回服务端提示)
*/
void onResult(boolean isValid, String message);
}
}

View File

@@ -1,305 +0,0 @@
package cc.winboll.studio.libappbase.utils;
import android.content.Context;
import android.os.Environment;
import android.text.TextUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import cc.winboll.studio.libappbase.LogUtils;
import cc.winboll.studio.libappbase.models.SFTPAuthModel;
/**
* 文件备份工具类(单例模式)
* 区分应用Data目录/应用专属外部文件目录双Map管理备份文件路径
* 核心功能:文件添加/移除 + ZIP打包分data/sdcard目录 + SFTP分步式上传登录→传输→登出
* 依赖FTPUtils单例、SFTPAuthModel外部实体类、Android上下文
* 兼容Java7、Android 6.0+无第三方依赖ZIP为原生实现免动态读写权限
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
* @Date 2026/01/30 20:18:00
* @LastEditTime 2026/02/01 02:05:00
*/
public class BackupUtils {
public static final String TAG = "BackupUtils";
// ZIP内部分级目录常量统一维护便于修改
private static final String ZIP_DIR_DATA = "data/";
private static final String ZIP_DIR_SDCARD = "sdcard/";
// 单例实例双重校验锁volatile保证可见性线程安全
private static volatile BackupUtils sInstance;
// 双Map分目录管理key=文件唯一标识value=对应目录下的相对路径
private final Map<String, String> mDataDirFileMap; // 基础根目录应用私有Data目录(/data/data/[包名]/files)
private final Map<String, String> mSdcardFileMap; // 基础根目录:应用专属外部文件目录(/storage/emulated/0/Android/data/[包名]/files)
// 全局上下文持有Application上下文避免Activity内存泄漏
private Context mAppContext;
// SFTP认证配置直接引用外部实体类无内部封装
private SFTPAuthModel mFtpAuthModel;
// SFTP服务器指定上传目录独立参数传入标准化后作为成员变量
private String mFtpTargetDir;
// 应用专属外部文件目录SDCard Map的基础根目录初始化时赋值避免重复创建
private File mAppExternalFilesDir;
// 私有构造器新增双Map入参空值则使用内部默认初始化非空则用入参初始化
private BackupUtils(Context context, SFTPAuthModel ftpAuthModel, String ftpTargetDir,
Map<String, String> dataDirFileMap, Map<String, String> sdcardFileMap) {
this.mAppContext = context.getApplicationContext();
this.mFtpAuthModel = ftpAuthModel;
// 初始化SDCard Map的基础根目录应用专属外部文件目录/storage/emulated/0/Android/data/[包名]/files
this.mAppExternalFilesDir = mAppContext.getExternalFilesDir(null);
// 标准化SFTP上传目录空则默认/,非空则补全结尾斜杠
this.mFtpTargetDir = TextUtils.isEmpty(ftpTargetDir) ? "/" : (ftpTargetDir.endsWith("/") ? ftpTargetDir : ftpTargetDir + "/");
// 核心修改入参Map非空且非空集合时使用入参初始化否则内部new HashMap()
this.mDataDirFileMap = (dataDirFileMap != null && !dataDirFileMap.isEmpty())
? new HashMap<String, String>(dataDirFileMap)
: new HashMap<String, String>();
this.mSdcardFileMap = (sdcardFileMap != null && !sdcardFileMap.isEmpty())
? new HashMap<String, String>(sdcardFileMap)
: new HashMap<String, String>();
LogUtils.d(TAG, "BackupUtils初始化完成 → SFTP服务器" + ftpAuthModel.getFtpServer() + ":" + ftpAuthModel.getFtpPort() + " | 上传目录:" + mFtpTargetDir);
LogUtils.d(TAG, "SDCard Map基础根目录" + (mAppExternalFilesDir == null ? "获取失败" : mAppExternalFilesDir.getAbsolutePath()));
LogUtils.d(TAG, "初始化后DataMap大小" + mDataDirFileMap.size() + " | SdcardMap大小" + mSdcardFileMap.size());
}
/**
* 单例初始化方法必须先调用否则getInstance()会抛异常)
* 新增双Map入参支持外部初始化待备份文件列表
* @param context 上下文推荐传Application避免内存泄漏
* @param ftpAuthModel 外部SFTP认证实体类含服务器/账号/端口等)
* @param ftpTargetDir SFTP服务器指定上传目录如/backup自动补全斜杠
* @param dataDirFileMap 外部传入的Data目录文件Mapnull/空则内部默认初始化
* @param sdcardFileMap 外部传入的SDCard目录文件Mapnull/空则内部默认初始化
* @return BackupUtils单例实例
*/
public static BackupUtils getInstance(Context context, SFTPAuthModel ftpAuthModel, String ftpTargetDir,
Map<String, String> dataDirFileMap, Map<String, String> sdcardFileMap) {
if (sInstance == null) {
synchronized (BackupUtils.class) {
if (sInstance == null) {
// 前置强校验:避免空参数导致后续空指针
if (context == null) {
throw new IllegalArgumentException("初始化失败Context 不能为空");
}
if (ftpAuthModel == null || TextUtils.isEmpty(ftpAuthModel.getFtpServer())) {
throw new IllegalArgumentException("初始化失败SFTPAuthModel/ftpServer 不能为空");
}
// 透传新增的双Map入参至构造器
sInstance = new BackupUtils(context, ftpAuthModel, ftpTargetDir, dataDirFileMap, sdcardFileMap);
}
}
}
return sInstance;
}
/**
* 重载默认初始化方法兼容原有调用逻辑无需传入Map内部默认初始化
* 避免修改后影响原有代码调用
*/
public static BackupUtils getInstance(Context context, SFTPAuthModel ftpAuthModel, String ftpTargetDir) {
return getInstance(context, ftpAuthModel, ftpTargetDir, null, null);
}
/**
* 获取单例实例需先调用带参getInstance初始化
* @return BackupUtils单例实例
*/
public static BackupUtils getInstance() {
if (sInstance == null) {
throw new IllegalStateException("BackupUtils未初始化请先调用getInstance(Context, SFTPAuthModel, String[, Map, Map])");
}
return sInstance;
}
// ====================================== 以下原有方法均未修改 ======================================
public void addDataDirFile(String key, String relativePath) {
if (!TextUtils.isEmpty(key) && !TextUtils.isEmpty(relativePath)) {
mDataDirFileMap.put(key, relativePath);
LogUtils.d(TAG, "添加Data目录文件" + key + "" + relativePath);
}
}
public void removeDataDirFile(String key) {
if (!TextUtils.isEmpty(key) && mDataDirFileMap.containsKey(key)) {
mDataDirFileMap.remove(key);
LogUtils.d(TAG, "移除Data目录文件" + key);
}
}
public String getDataDirFile(String key) {
return mDataDirFileMap.get(key);
}
public Map<String, String> getAllDataDirFiles() {
return new HashMap<>(mDataDirFileMap);
}
public void clearDataDirFiles() {
mDataDirFileMap.clear();
LogUtils.d(TAG, "清空Data目录所有备份文件");
}
public void addSdcardFile(String key, String relativePath) {
if (!TextUtils.isEmpty(key) && !TextUtils.isEmpty(relativePath) && mAppExternalFilesDir != null) {
mSdcardFileMap.put(key, relativePath);
LogUtils.d(TAG, "添加外部文件目录文件:" + key + "" + relativePath);
}
}
public void removeSdcardFile(String key) {
if (!TextUtils.isEmpty(key) && mSdcardFileMap.containsKey(key)) {
mSdcardFileMap.remove(key);
LogUtils.d(TAG, "移除外部文件目录文件:" + key);
}
}
public String getSdcardFile(String key) {
return mSdcardFileMap.get(key);
}
public Map<String, String> getAllSdcardFiles() {
return new HashMap<>(mSdcardFileMap);
}
public void clearSdcardFiles() {
mSdcardFileMap.clear();
LogUtils.d(TAG, "清空外部文件目录所有备份文件");
}
public boolean packAndUploadByFtp() {
if (mDataDirFileMap.isEmpty() && mSdcardFileMap.isEmpty()) {
LogUtils.e(TAG, "SFTP上传失败无待备份文件DataDir+外部文件目录均为空)");
return false;
}
if (mAppExternalFilesDir == null) {
LogUtils.e(TAG, "SFTP上传失败应用专属外部文件目录获取失败无法访问文件");
return false;
}
String zipFileName = UUID.randomUUID().toString().replace("-", "")
+ "-" + System.currentTimeMillis() + ".zip";
File tempZipFile = new File(mAppContext.getExternalCacheDir(), zipFileName);
String remoteFtpFilePath = mFtpTargetDir + zipFileName;
FTPUtils ftpUtils = FTPUtils.getInstance();
boolean isUploadSuccess = false;
try {
LogUtils.d(TAG, "开始SFTP登录" + mFtpAuthModel.getFtpServer() + ":" + mFtpAuthModel.getFtpPort());
boolean isFtpLogin = ftpUtils.login(mFtpAuthModel);
if (!isFtpLogin) {
LogUtils.e(TAG, "SFTP上传失败SFTP登录失败账号/密码/服务器/端口错误)");
return false;
}
LogUtils.i(TAG, "SFTP登录成功准备打包文件" + zipFileName);
LogUtils.d(TAG, "开始本地ZIP打包分data/sdcard目录临时文件路径" + tempZipFile.getAbsolutePath());
boolean isPackSuccess = packFilesToZip(tempZipFile);
if (!isPackSuccess || !tempZipFile.exists() || tempZipFile.length() == 0) {
LogUtils.e(TAG, "SFTP上传失败ZIP打包失败文件不存在/空文件)");
return false;
}
LogUtils.i(TAG, "ZIP打包成功文件大小" + tempZipFile.length() / 1024 + "KB");
LogUtils.d(TAG, "开始SFTP上传本地→SFTP" + remoteFtpFilePath);
isUploadSuccess = ftpUtils.uploadFile(tempZipFile.getAbsolutePath(), remoteFtpFilePath);
if (isUploadSuccess) {
LogUtils.i(TAG, "SFTP上传全流程成功" + remoteFtpFilePath);
} else {
LogUtils.e(TAG, "SFTP上传失败文件传输到服务器失败响应码异常/权限不足)");
}
} catch (Exception e) {
LogUtils.e(TAG, "SFTP上传异常" + e.getMessage(), e);
isUploadSuccess = false;
} finally {
if (ftpUtils.isConnected()) {
ftpUtils.logout();
}
ftpUtils.disconnect();
if (tempZipFile.exists()) {
boolean isDelete = tempZipFile.delete();
LogUtils.d(TAG, "本地临时ZIP文件删除" + (isDelete ? "成功" : "失败"));
}
System.gc();
}
return isUploadSuccess;
}
private boolean packFilesToZip(File zipFile) {
ZipOutputStream zos = null;
try {
zos = new ZipOutputStream(new FileOutputStream(zipFile), Charset.forName("UTF-8"));
zos.setLevel(ZipOutputStream.DEFLATED);
if (!mDataDirFileMap.isEmpty()) {
packDirFilesToZip(zos, mDataDirFileMap, mAppContext.getFilesDir(), ZIP_DIR_DATA);
LogUtils.d(TAG, "Data目录文件已打包到ZIP→" + ZIP_DIR_DATA + "子目录");
}
if (!mSdcardFileMap.isEmpty() && mAppExternalFilesDir != null) {
packDirFilesToZip(zos, mSdcardFileMap, mAppExternalFilesDir, ZIP_DIR_SDCARD);
LogUtils.d(TAG, "应用专属外部文件目录文件已打包到ZIP→" + ZIP_DIR_SDCARD + "子目录");
}
zos.flush();
return true;
} catch (IOException e) {
LogUtils.e(TAG, "ZIP打包IO异常" + e.getMessage(), e);
return false;
} finally {
if (zos != null) {
try {
zos.close();
} catch (IOException e) {
LogUtils.e(TAG, "关闭ZIP流异常" + e.getMessage(), e);
}
}
}
}
private void packDirFilesToZip(ZipOutputStream zos, Map<String, String> fileMap, File baseDir, String zipSubDir) {
for (Map.Entry<String, String> entry : fileMap.entrySet()) {
String relativePath = entry.getValue();
if (TextUtils.isEmpty(relativePath)) {
continue;
}
File localFile = new File(baseDir, relativePath);
if (!localFile.exists() || !localFile.isFile()) {
LogUtils.w(TAG, "跳过无效文件:" + localFile.getAbsolutePath());
continue;
}
String zipInnerPath = zipSubDir + relativePath;
try {
addSingleFileToZip(zos, localFile, zipInnerPath);
} catch (IOException e) {
LogUtils.e(TAG, "打包单个文件失败:" + zipInnerPath, e);
}
}
}
private void addSingleFileToZip(ZipOutputStream zos, File localFile, String zipInnerPath) throws IOException {
ZipEntry zipEntry = new ZipEntry(zipInnerPath);
zos.putNextEntry(zipEntry);
FileInputStream fis = new FileInputStream(localFile);
byte[] buffer = new byte[4096];
int len;
while ((len = fis.read(buffer)) != -1) {
zos.write(buffer, 0, len);
}
fis.close();
zos.closeEntry();
}
}

View File

@@ -1,487 +0,0 @@
package cc.winboll.studio.libappbase.utils;
import cc.winboll.studio.libappbase.LogUtils;
import cc.winboll.studio.libappbase.models.SFTPAuthModel;
import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
import com.jcraft.jsch.SftpATTRS;
import com.jcraft.jsch.SftpException;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Properties;
import java.util.Vector;
/**
* SFTP/FTP工具类单例模式- Java7兼容 · 适配FTPAuthModel实体类
* 底层严格基于JSch 0.1.54原生ChannelSftp+SftpException接口实现替换原commons-net FTP
* 核心功能:登录/登出、文件上传/下载、文件夹列举、文件/文件夹存在性判断
* 依赖com.jcraft:jsch:0.1.54
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
* @Date 2026/01/30 19:04
*/
public class FTPUtils {
// 单例实例(双重校验锁 volatile 保证可见性Java7兼容
private static volatile FTPUtils sInstance;
// JSch核心对象Session连接会话、ChannelSftpSFTP通道
private JSch mJSch;
private Session mSession;
private ChannelSftp mSftpChannel;
// 日志TAG
public static final String TAG = "FTPUtils";
// SFTP默认端口FTPAuthModel未设置时使用
private static final int DEFAULT_SFTP_PORT = 22;
// 连接超时时间 5sJava7原生Socket超时
private static final int CONNECT_TIMEOUT = 5000;
// 私有构造器:禁止外部实例化
private FTPUtils() {
initSftpClient();
}
/**
* 获取单例实例双重校验锁线程安全Java7兼容
* @return FTPUtils 单例
*/
public static FTPUtils getInstance() {
if (sInstance == null) {
synchronized (FTPUtils.class) {
if (sInstance == null) {
sInstance = new FTPUtils();
}
}
}
return sInstance;
}
/**
* 初始化SFTP客户端JSch创建核心原生对象
*/
private void initSftpClient() {
if (mJSch == null) {
mJSch = new JSch();
LogUtils.d(TAG, "SFTP客户端JSch初始化完成");
}
// 重置会话和通道,避免连接残留
mSession = null;
mSftpChannel = null;
}
/**
* 【推荐】SFTP登录基于FTPAuthModel实体类完全兼容原有参数
* @param ftpAuthModel 登录配置实体类不能为空端口默认22编码默认UTF-8
* @return 登录成功返回true失败false
*/
public boolean login(SFTPAuthModel ftpAuthModel) {
// 1. 实体类非空校验
if (ftpAuthModel == null) {
LogUtils.e(TAG, "SFTP登录失败FTPAuthModel实体类为null");
return false;
}
// 2. 核心参数校验(服务器地址不能为空)
if (isParamEmpty(ftpAuthModel.getFtpServer())) {
LogUtils.e(TAG, "SFTP登录失败服务器地址ftpServer不能为空");
return false;
}
// 3. 若已连接,先断开
if (isConnected()) {
logout();
}
// 4. 重新初始化客户端
initSftpClient();
try {
// 获取服务器地址、端口默认22、账号、密码
String host = ftpAuthModel.getFtpServer();
int port = ftpAuthModel.getFtpPort() <= 0 ? DEFAULT_SFTP_PORT : ftpAuthModel.getFtpPort();
String username = ftpAuthModel.getFtpUsername();
String password = ftpAuthModel.getFtpPassword();
// SFTP不支持匿名登录账号密码不能为空原生接口无匿名登录能力
if (isParamEmpty(username) || isParamEmpty(password)) {
LogUtils.e(TAG, "SFTP登录失败SFTP不支持匿名登录请配置有效账号密码");
return false;
}
// 1. 创建JSch会话原生接口
mSession = mJSch.getSession(username, host, port);
mSession.setPassword(password);
// 2. 设置会话属性跳过SSH密钥校验适配大部分服务器
Properties sessionProps = new Properties();
sessionProps.put("StrictHostKeyChecking", "no");
sessionProps.put("PreferredAuthentications", "password");
mSession.setConfig(sessionProps);
// 3. 设置会话连接超时原生接口底层Socket超时
mSession.setTimeout(CONNECT_TIMEOUT);
// 4. 建立会话连接(原生接口)
mSession.connect();
LogUtils.d(TAG, "SFTP会话连接成功" + host + ":" + port);
// 5. 打开SFTP通道类型sftp原生接口强转
mSftpChannel = (ChannelSftp) mSession.openChannel("sftp");
mSftpChannel.connect();
// 6. 设置文件名编码解决中文乱码ChannelSftp原生接口
String charset = isParamEmpty(ftpAuthModel.getFtpCharset()) ? "UTF-8" : ftpAuthModel.getFtpCharset();
mSftpChannel.setFilenameEncoding(charset);
LogUtils.d(TAG, "SFTP文件名编码设置成功" + charset);
LogUtils.i(TAG, "SFTP登录成功服务器" + host + ":" + port + ",用户名:" + username);
return true;
} catch (JSchException e) {
LogUtils.e(TAG, "SFTP登录JSch异常" + e.getMessage(), e);
logout();
return false;
} catch (SftpException e) {
// 匹配SftpException原生属性和方法
LogUtils.e(TAG, "SFTP通道初始化异常id=" + e.id + "msg=" + e.getMessage() + "detail=" + e.toString());
logout();
return false;
}
}
/**
* 【已废弃】原FTP多参数登录方法适配JSch后保留推荐使用login(FTPAuthModel)
* @deprecated 请使用基于FTPAuthModel的登录方法
*/
@Deprecated
public boolean login(String host, int port, String username, String password) {
SFTPAuthModel ftpAuthModel = new SFTPAuthModel();
ftpAuthModel.setFtpServer(host);
ftpAuthModel.setFtpPort(port <= 0 ? DEFAULT_SFTP_PORT : port);
ftpAuthModel.setFtpUsername(username);
ftpAuthModel.setFtpPassword(password);
return login(ftpAuthModel);
}
/**
* SFTP登出并断开连接释放所有资源严格调用原生disconnect接口
* @return 登出成功返回true失败false
*/
public boolean logout() {
boolean isSuccess = true;
// 关闭SFTP通道原生接口disconnect非空判断即可
if (mSftpChannel != null) {
try {
mSftpChannel.disconnect();
LogUtils.d(TAG, "SFTP通道已断开");
} catch (Exception e) {
LogUtils.e(TAG, "关闭SFTP通道异常" + e.getMessage(), e);
isSuccess = false;
}
}
// 关闭JSch会话原生接口disconnect非空判断即可
if (mSession != null) {
try {
mSession.disconnect();
LogUtils.d(TAG, "SFTP会话已断开");
} catch (Exception e) {
LogUtils.e(TAG, "关闭SFTP会话异常" + e.getMessage(), e);
isSuccess = false;
}
}
// 重置客户端,避免资源残留
initSftpClient();
if (isSuccess) {
LogUtils.i(TAG, "SFTP登出成功");
} else {
LogUtils.w(TAG, "SFTP登出失败部分资源未正常释放");
}
return isSuccess;
}
/**
* 强制断开连接兜底资源释放同logout方法
*/
public void disconnect() {
logout();
}
/**
* 判断SFTP是否已连接会话+通道均调用原生isConnected接口
* @return 已连接返回true否则false
*/
public boolean isConnected() {
return mSession != null && mSession.isConnected()
&& mSftpChannel != null && mSftpChannel.isConnected();
}
/**
* 上传文件到SFTP指定路径覆盖式上传调用ChannelSftp原生put接口OVERWRITE模式
* @param localFilePath 本地文件绝对路径(如/sdcard/test.apk
* @param remoteFilePath SFTP服务器目标路径如/ftp/apk/test.apk需包含文件名
* @return 上传成功返回true失败false
*/
public boolean uploadFile(String localFilePath, String remoteFilePath) {
// 前置校验
if (!isConnected()) {
LogUtils.e(TAG, "文件上传失败SFTP未连接服务器");
return false;
}
if (isParamEmpty(localFilePath) || isParamEmpty(remoteFilePath)) {
LogUtils.e(TAG, "文件上传失败:本地/远程路径不能为空");
return false;
}
File localFile = new File(localFilePath);
if (!localFile.exists() || !localFile.isFile()) {
LogUtils.e(TAG, "文件上传失败:本地文件不存在/非文件,路径:" + localFilePath);
return false;
}
InputStream fis = null;
try {
// 自动创建远程多级目录基于原生mkdir/stat接口
createRemoteDir(remoteFilePath);
// 读取本地文件上传到SFTP原生put接口OVERWRITE覆盖模式
fis = new FileInputStream(localFile);
mSftpChannel.put(fis, remoteFilePath, ChannelSftp.OVERWRITE);
LogUtils.i(TAG, "文件上传成功:本地" + localFilePath + " → 远程" + remoteFilePath);
return true;
} catch (IOException e) {
LogUtils.e(TAG, "文件上传IO异常" + e.getMessage(), e);
return false;
} catch (SftpException e) {
// 严格匹配SftpException原生属性id、getMessage()、toString()
LogUtils.e(TAG, "文件上传SFTP异常id=" + e.id + "msg=" + e.getMessage() + "detail=" + e.toString());
return false;
} finally {
// 关闭流资源,避免内存泄漏
closeStream(fis, null);
}
}
/**
* 从SFTP下载文件到本地指定路径覆盖式下载调用ChannelSftp原生get接口
* @param remoteFilePath SFTP服务器文件路径如/ftp/apk/test.apk
* @param localFilePath 本地目标路径(如/sdcard/test.apk需包含文件名
* @return 下载成功返回true失败false
*/
public boolean downloadFile(String remoteFilePath, String localFilePath) {
// 前置校验
if (!isConnected()) {
LogUtils.e(TAG, "文件下载失败SFTP未连接服务器");
return false;
}
if (isParamEmpty(remoteFilePath) || isParamEmpty(localFilePath)) {
LogUtils.e(TAG, "文件下载失败:远程/本地路径不能为空");
return false;
}
// 校验远程文件是否存在基于ChannelSftp原生stat接口
if (!isFileExists(remoteFilePath)) {
LogUtils.e(TAG, "文件下载失败:远程文件不存在,路径:" + remoteFilePath);
return false;
}
OutputStream fos = null;
try {
// 创建本地多级目录
File localFile = new File(localFilePath);
File parentDir = localFile.getParentFile();
if (!parentDir.exists() && !parentDir.mkdirs()) {
LogUtils.e(TAG, "文件下载失败:创建本地目录失败,路径:" + parentDir.getAbsolutePath());
return false;
}
// 从SFTP读取文件写入本地原生get接口
fos = new FileOutputStream(localFile);
mSftpChannel.get(remoteFilePath, fos);
LogUtils.i(TAG, "文件下载成功:远程" + remoteFilePath + " → 本地" + localFilePath);
return true;
} catch (IOException e) {
LogUtils.e(TAG, "文件下载IO异常" + e.getMessage(), e);
// 删除未下载完成的本地文件
new File(localFilePath).delete();
return false;
} catch (SftpException e) {
// 严格匹配SftpException原生属性id、getMessage()、toString()
LogUtils.e(TAG, "文件下载SFTP异常id=" + e.id + "msg=" + e.getMessage() + "detail=" + e.toString());
// 删除未下载完成的本地文件
new File(localFilePath).delete();
return false;
} finally {
// 关闭流资源,避免内存泄漏
closeStream(null, fos);
}
}
/**
* 列举SFTP指定文件夹下的所有文件/文件夹返回ChannelSftp原生Vector过滤.和..
* @param remoteDir SFTP服务器目录路径如/ftp/apk/,结尾带/或不带均可)
* @return 成功返回原生Vector<ChannelSftp.LsEntry>失败返回空Vector
*/
@SuppressWarnings("rawtypes")
public Vector listDir(String remoteDir) {
Vector fileList = new Vector();
// 前置校验
if (!isConnected()) {
LogUtils.e(TAG, "列举目录失败SFTP未连接服务器");
return fileList;
}
if (isParamEmpty(remoteDir)) {
LogUtils.e(TAG, "列举目录失败:远程目录路径不能为空");
return fileList;
}
// 校验目录是否存在基于ChannelSftp原生stat接口
if (!isDirExists(remoteDir)) {
LogUtils.e(TAG, "列举目录失败:远程目录不存在,路径:" + remoteDir);
return fileList;
}
try {
// 列举目录下所有文件/文件夹调用ChannelSftp原生ls接口返回原生Vector
Vector vector = mSftpChannel.ls(remoteDir);
if (vector != null && vector.size() > 0) {
for (Object obj : vector) {
// 过滤.和..上级目录,仅保留有效文件/目录
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
String fileName = entry.getFilename();
if (!".".equals(fileName) && !"..".equals(fileName)) {
fileList.add(obj);
}
}
}
LogUtils.i(TAG, "列举目录成功:" + remoteDir + ",共" + fileList.size() + "个文件/文件夹");
} catch (SftpException e) {
// 严格匹配SftpException原生属性id、getMessage()、toString()
LogUtils.e(TAG, "列举目录SFTP异常id=" + e.id + "msg=" + e.getMessage() + "detail=" + e.toString());
}
return fileList;
}
/**
* 判断SFTP服务器上**文件**是否存在基于ChannelSftp原生stat接口匹配SftpException原生异常
* @param remoteFilePath SFTP服务器文件路径如/ftp/apk/test.apk
* @return 存在且为文件返回true否则false
*/
public boolean isFileExists(String remoteFilePath) {
// 前置校验
if (!isConnected()) {
LogUtils.e(TAG, "判断文件存在性失败SFTP未连接服务器");
return false;
}
if (isParamEmpty(remoteFilePath)) {
LogUtils.e(TAG, "判断文件存在性失败:远程文件路径不能为空");
return false;
}
try {
// 调用ChannelSftp原生stat接口获取属性不存在会抛出SSH_FX_NO_SUCH_FILE异常
SftpATTRS attrs = mSftpChannel.stat(remoteFilePath);
// 原生isReg()判断是否为文件
return attrs.isReg();
} catch (SftpException e) {
// 仅匹配原生异常码SSH_FX_NO_SUCH_FILE(2):文件/目录不存在,不记错误日志
if (e.id != ChannelSftp.SSH_FX_NO_SUCH_FILE) {
LogUtils.e(TAG, "判断文件存在性SFTP异常id=" + e.id + "msg=" + e.getMessage() + "detail=" + e.toString());
}
return false;
}
}
/**
* 判断SFTP服务器上**文件夹**是否存在基于ChannelSftp原生stat接口匹配SftpException原生异常
* @param remoteDir SFTP服务器目录路径如/ftp/apk/,结尾带/或不带均可)
* @return 存在且为目录返回true否则false
*/
public boolean isDirExists(String remoteDir) {
// 前置校验
if (!isConnected()) {
LogUtils.e(TAG, "判断目录存在性失败SFTP未连接服务器");
return false;
}
if (isParamEmpty(remoteDir)) {
LogUtils.e(TAG, "判断目录存在性失败:远程目录路径不能为空");
return false;
}
try {
// 调用ChannelSftp原生stat接口获取属性不存在会抛出SSH_FX_NO_SUCH_FILE异常
SftpATTRS attrs = mSftpChannel.stat(remoteDir);
// 原生isDir()判断是否为目录
return attrs.isDir();
} catch (SftpException e) {
// 仅匹配原生异常码SSH_FX_NO_SUCH_FILE(2):文件/目录不存在,不记错误日志
if (e.id != ChannelSftp.SSH_FX_NO_SUCH_FILE) {
LogUtils.e(TAG, "判断目录存在性SFTP异常id=" + e.id + "msg=" + e.getMessage() + "detail=" + e.toString());
}
return false;
}
}
// ===================================== 内部工具方法(仅调用原生接口) =====================================
/**
* 递归创建SFTP远程多级目录基于ChannelSftp原生mkdir/stat接口不存在则创建
* @param remoteFilePath SFTP远程文件路径/目录路径
*/
private void createRemoteDir(String remoteFilePath) {
if (!isConnected()) {
LogUtils.e(TAG, "创建远程目录失败SFTP未连接服务器");
return;
}
try {
// 提取目录路径(文件路径→目录路径,目录路径直接使用)
String remoteDir = remoteFilePath.lastIndexOf("/") > 0
? remoteFilePath.substring(0, remoteFilePath.lastIndexOf("/"))
: remoteFilePath;
// 按/分割多级目录,递归创建(避免多级目录不存在)
String[] dirs = remoteDir.split("/");
StringBuilder currentDir = new StringBuilder();
for (String dir : dirs) {
if (isParamEmpty(dir)) {
continue;
}
currentDir.append("/").append(dir);
String dirPath = currentDir.toString();
// 目录不存在则调用ChannelSftp原生mkdir创建
if (!isDirExists(dirPath)) {
mSftpChannel.mkdir(dirPath);
LogUtils.d(TAG, "创建SFTP远程目录成功" + dirPath);
}
}
} catch (SftpException e) {
// 严格匹配SftpException原生属性id、getMessage()、toString()
LogUtils.e(TAG, "创建远程目录SFTP异常id=" + e.id + "msg=" + e.getMessage() + "detail=" + e.toString());
}
}
/**
* 关闭流资源通用工具方法Java7原生IO避免内存泄漏
* @param is 输入流可为null
* @param os 输出流可为null
*/
private void closeStream(InputStream is, OutputStream os) {
if (is != null) {
try {
is.close();
} catch (IOException e) {
LogUtils.e(TAG, "关闭输入流异常:" + e.getMessage(), e);
}
}
if (os != null) {
try {
os.close();
} catch (IOException e) {
LogUtils.e(TAG, "关闭输出流异常:" + e.getMessage(), e);
}
}
}
/**
* 判断参数是否为空null/空字符串/全空格Java7原生字符串操作
* @param param 待判断参数
* @return 为空返回true否则false
*/
private boolean isParamEmpty(String param) {
return param == null || param.trim().isEmpty();
}
}

View File

@@ -12,13 +12,11 @@ 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.APPValidationDialog;
import cc.winboll.studio.libappbase.models.APPInfo;
/**
@@ -328,14 +326,7 @@ public class AboutView extends LinearLayout {
ToastUtils.show("已取消调试状态,重启应用可生效。");
}
});
// 正版校验弹窗
ibSigngetDialog.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
LogUtils.d(TAG, "ibSigngetDialog onClick唤起应用正版校验弹窗");
new APPValidationDialog(mContext, mszAppName, mszAppVersionName).show();
}
});
// 调试地址配置弹窗
ibWinBoLLHostDialog.setOnClickListener(new OnClickListener() {
@Override