移除网络类库依赖,相应减少冗余功能。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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目录文件Map,null/空则内部默认初始化
|
||||
* @param sdcardFileMap 外部传入的SDCard目录文件Map,null/空则内部默认初始化
|
||||
* @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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(连接会话)、ChannelSftp(SFTP通道)
|
||||
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;
|
||||
// 连接超时时间 5s(Java7原生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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user