正在调试FTP应用备份功能。
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
#Created by .winboll/winboll_app_build.gradle
|
||||
#Sat Jan 24 20:32:27 HKT 2026
|
||||
#Fri Jan 30 13:37:25 GMT 2026
|
||||
stageCount=12
|
||||
libraryProject=libappbase
|
||||
baseVersion=15.15
|
||||
publishVersion=15.15.11
|
||||
buildCount=0
|
||||
buildCount=1
|
||||
baseBetaVersion=15.15.12
|
||||
|
||||
@@ -13,6 +13,7 @@ import cc.winboll.studio.appbase.R;
|
||||
import cc.winboll.studio.libappbase.LogActivity;
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.libappbase.ToastUtils;
|
||||
import cc.winboll.studio.libappbase.activities.FTPBackupsActivity;
|
||||
|
||||
/**
|
||||
* @Author ZhanGSKen<zhangsken@qq.com>
|
||||
@@ -138,10 +139,15 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
|
||||
public void onAboutActivity(View view) {
|
||||
LogUtils.d(TAG, "startAboutActivity() 调用");
|
||||
LogUtils.d(TAG, "onAboutActivity() 调用");
|
||||
Intent aboutIntent = new Intent(getApplicationContext(), AboutActivity.class);
|
||||
startActivity(aboutIntent);
|
||||
LogUtils.d(TAG, "startAboutActivity: 关于页面已启动");
|
||||
}
|
||||
|
||||
public void onFTPBackupsActivity(View view) {
|
||||
LogUtils.d(TAG, "onFTPBackupsActivity() 调用");
|
||||
Intent ftpBackupsIntent = new Intent(getApplicationContext(), FTPBackupsActivity.class);
|
||||
startActivity(ftpBackupsIntent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,18 @@
|
||||
android:onClick="onToastUtilsTest"
|
||||
android:layout_margin="10dp"/>
|
||||
|
||||
<Button
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="FTP应用备份"
|
||||
android:textSize="16sp"
|
||||
android:textColor="@android:color/white"
|
||||
android:background="#81C7F5"
|
||||
android:paddingVertical="12dp"
|
||||
android:layout_marginHorizontal="24dp"
|
||||
android:onClick="onFTPBackupsActivity"
|
||||
android:layout_margin="10dp"/>
|
||||
|
||||
<Button
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -27,5 +27,8 @@ dependencies {
|
||||
api 'com.google.code.gson:gson:2.8.9'
|
||||
// Html 解析
|
||||
api 'org.jsoup:jsoup:1.13.1'
|
||||
// 添加JSch依赖(SFTP核心,com.jcraft:jsch:0.1.54)
|
||||
api 'com.jcraft:jsch:0.1.54'
|
||||
|
||||
api fileTree(dir: 'libs', include: ['*.jar'])
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#Created by .winboll/winboll_app_build.gradle
|
||||
#Sat Jan 24 20:32:20 HKT 2026
|
||||
#Fri Jan 30 13:37:25 GMT 2026
|
||||
stageCount=12
|
||||
libraryProject=libappbase
|
||||
baseVersion=15.15
|
||||
publishVersion=15.15.11
|
||||
buildCount=0
|
||||
buildCount=1
|
||||
baseBetaVersion=15.15.12
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||
|
||||
<application
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<activity
|
||||
android:name=".CrashHandler$CrashActivity"
|
||||
@@ -44,6 +44,8 @@
|
||||
|
||||
<activity android:name="cc.winboll.studio.libappbase.activities.NfcRsaLoginActivity"/>
|
||||
|
||||
<activity android:name="cc.winboll.studio.libappbase.activities.FTPBackupsActivity"/>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,27 @@
|
||||
package cc.winboll.studio.libappbase.activities;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import cc.winboll.studio.libappbase.R;
|
||||
import cc.winboll.studio.libappbase.ToastUtils;
|
||||
|
||||
/**
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/30 20:55
|
||||
*/
|
||||
public class FTPBackupsActivity extends Activity {
|
||||
|
||||
public static final String TAG = "FTPBackupsActivity";
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_ftp_backups);
|
||||
|
||||
}
|
||||
|
||||
public void onBackups(View view) {
|
||||
ToastUtils.show("onBackups");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package cc.winboll.studio.libappbase.models;
|
||||
|
||||
/**
|
||||
* FTP登录验证信息实体类
|
||||
* 封装FTP登录所需的所有配置信息:服务端地址、端口、账号密码、秘钥信息、传输模式、编码
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/30 19:08
|
||||
*/
|
||||
public class FTPAuthModel {
|
||||
public static final String TAG = "FTPAuthModel";
|
||||
|
||||
// FTP服务器地址(必填,如192.168.1.100、ftp.xxx.com)
|
||||
private String ftpServer;
|
||||
// FTP服务器端口(必填,默认21)
|
||||
private int ftpPort = 21;
|
||||
// FTP登录用户名(匿名登录传null/空)
|
||||
private String ftpUsername;
|
||||
// FTP登录密码(匿名登录传null/空)
|
||||
private String ftpPassword;
|
||||
// FTP登录秘钥路径(秘钥登录时使用,本地绝对路径,如/sdcard/ftp/key.pem,账号密码登录传null/空)
|
||||
private String ftpKeyPath;
|
||||
// FTP登录秘钥密码(秘钥有密码时填写,无密码传null/空)
|
||||
private String ftpKeyPwd;
|
||||
// 是否为主动模式(true=主动模式,false=被动模式<默认推荐>)
|
||||
private boolean isActiveMode = false;
|
||||
// FTP编码(默认UTF-8,解决中文文件名乱码)
|
||||
private String ftpCharset = "UTF-8";
|
||||
|
||||
// 空参构造(JavaBean规范)
|
||||
public FTPAuthModel() {
|
||||
}
|
||||
|
||||
// 全参构造(快速初始化)
|
||||
public FTPAuthModel(String ftpServer, int ftpPort, String ftpUsername, String ftpPassword,
|
||||
String ftpKeyPath, String ftpKeyPwd, boolean isActiveMode, String ftpCharset) {
|
||||
this.ftpServer = ftpServer;
|
||||
this.ftpPort = ftpPort;
|
||||
this.ftpUsername = ftpUsername;
|
||||
this.ftpPassword = ftpPassword;
|
||||
this.ftpKeyPath = ftpKeyPath;
|
||||
this.ftpKeyPwd = ftpKeyPwd;
|
||||
this.isActiveMode = isActiveMode;
|
||||
this.ftpCharset = ftpCharset;
|
||||
}
|
||||
|
||||
// ==================== Get/Set 方法 ====================
|
||||
public String getFtpServer() {
|
||||
return ftpServer;
|
||||
}
|
||||
|
||||
public void setFtpServer(String ftpServer) {
|
||||
this.ftpServer = ftpServer;
|
||||
}
|
||||
|
||||
public int getFtpPort() {
|
||||
return ftpPort;
|
||||
}
|
||||
|
||||
public void setFtpPort(int ftpPort) {
|
||||
this.ftpPort = ftpPort;
|
||||
}
|
||||
|
||||
public String getFtpUsername() {
|
||||
return ftpUsername;
|
||||
}
|
||||
|
||||
public void setFtpUsername(String ftpUsername) {
|
||||
this.ftpUsername = ftpUsername;
|
||||
}
|
||||
|
||||
public String getFtpPassword() {
|
||||
return ftpPassword;
|
||||
}
|
||||
|
||||
public void setFtpPassword(String ftpPassword) {
|
||||
this.ftpPassword = ftpPassword;
|
||||
}
|
||||
|
||||
public String getFtpKeyPath() {
|
||||
return ftpKeyPath;
|
||||
}
|
||||
|
||||
public void setFtpKeyPath(String ftpKeyPath) {
|
||||
this.ftpKeyPath = ftpKeyPath;
|
||||
}
|
||||
|
||||
public String getFtpKeyPwd() {
|
||||
return ftpKeyPwd;
|
||||
}
|
||||
|
||||
public void setFtpKeyPwd(String ftpKeyPwd) {
|
||||
this.ftpKeyPwd = ftpKeyPwd;
|
||||
}
|
||||
|
||||
public boolean isActiveMode() {
|
||||
return isActiveMode;
|
||||
}
|
||||
|
||||
public void setActiveMode(boolean activeMode) {
|
||||
isActiveMode = activeMode;
|
||||
}
|
||||
|
||||
public String getFtpCharset() {
|
||||
return ftpCharset;
|
||||
}
|
||||
|
||||
public void setFtpCharset(String ftpCharset) {
|
||||
this.ftpCharset = ftpCharset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
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.FTPAuthModel;
|
||||
|
||||
/**
|
||||
* 文件备份工具类(单例模式)
|
||||
* 区分应用Data目录/SDCard目录双Map管理备份文件路径
|
||||
* 核心功能:文件添加/移除 + ZIP打包 + FTP分步式上传(登录→传输→登出)
|
||||
* 依赖:FTPUtils(单例)、FTPAuthModel(外部实体类)、Android上下文
|
||||
* 兼容:Java7、Android 6.0+,无第三方依赖(ZIP为原生实现)
|
||||
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
|
||||
* @Date 2026/01/30 20:18
|
||||
*/
|
||||
public class BackupUtils {
|
||||
public static final String TAG = "BackupUtils";
|
||||
|
||||
// 单例实例(双重校验锁,volatile保证可见性,线程安全)
|
||||
private static volatile BackupUtils sInstance;
|
||||
|
||||
// 双Map分目录管理:key=文件唯一标识,value=对应目录下的相对路径
|
||||
private final Map<String, String> mDataDirFileMap; // 基础根目录:应用Data目录
|
||||
private final Map<String, String> mSdcardFileMap; // 基础根目录:/sdcard
|
||||
|
||||
// 全局上下文(持有Application上下文,避免Activity内存泄漏)
|
||||
private Context mAppContext;
|
||||
// FTP认证配置(直接引用外部实体类,无内部封装)
|
||||
private FTPAuthModel mFtpAuthModel;
|
||||
// FTP服务器指定上传目录(独立参数传入,标准化后作为成员变量)
|
||||
private String mFtpTargetDir;
|
||||
|
||||
// 私有构造器:禁止外部实例化,初始化双Map+配置参数+标准化FTP上传目录
|
||||
private BackupUtils(Context context, FTPAuthModel ftpAuthModel, String ftpTargetDir) {
|
||||
this.mAppContext = context.getApplicationContext();
|
||||
this.mFtpAuthModel = ftpAuthModel;
|
||||
// 标准化FTP上传目录:空则默认/,非空则补全结尾斜杠
|
||||
this.mFtpTargetDir = TextUtils.isEmpty(ftpTargetDir) ? "/" : (ftpTargetDir.endsWith("/") ? ftpTargetDir : ftpTargetDir + "/");
|
||||
// 初始化双Map(HashMap兼容Java7)
|
||||
mDataDirFileMap = new HashMap<>();
|
||||
mSdcardFileMap = new HashMap<>();
|
||||
LogUtils.d(TAG, "BackupUtils初始化完成 → FTP服务器:" + ftpAuthModel.getFtpServer() + ":" + ftpAuthModel.getFtpPort() + " | 上传目录:" + mFtpTargetDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单例初始化方法(必须先调用,否则getInstance()会抛异常)
|
||||
* @param context 上下文(推荐传Application,避免内存泄漏)
|
||||
* @param ftpAuthModel 外部FTP认证实体类(含服务器/账号/端口/传输模式等)
|
||||
* @param ftpTargetDir FTP服务器指定上传目录(如/backup,自动补全斜杠)
|
||||
* @return BackupUtils单例实例
|
||||
*/
|
||||
public static BackupUtils getInstance(Context context, FTPAuthModel ftpAuthModel, String ftpTargetDir) {
|
||||
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("初始化失败:FTPAuthModel/ftpServer 不能为空");
|
||||
}
|
||||
sInstance = new BackupUtils(context, ftpAuthModel, ftpTargetDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单例实例(需先调用带参getInstance初始化)
|
||||
* @return BackupUtils单例实例
|
||||
*/
|
||||
public static BackupUtils getInstance() {
|
||||
if (sInstance == null) {
|
||||
throw new IllegalStateException("BackupUtils未初始化,请先调用getInstance(Context, FTPAuthModel, String)");
|
||||
}
|
||||
return sInstance;
|
||||
}
|
||||
|
||||
// ====================================== 应用Data目录 - Map操作方法 ======================================
|
||||
/**
|
||||
* 添加应用Data目录下的备份文件(相对路径)
|
||||
* @param key 文件唯一标识(如log_20260130,避免重复)
|
||||
* @param relativePath Data目录下的相对路径,如:files/log/app.log
|
||||
*/
|
||||
public void addDataDirFile(String key, String relativePath) {
|
||||
if (!TextUtils.isEmpty(key) && !TextUtils.isEmpty(relativePath)) {
|
||||
mDataDirFileMap.put(key, relativePath);
|
||||
LogUtils.d(TAG, "添加Data目录文件:" + key + " → " + relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除应用Data目录下的指定备份文件
|
||||
* @param key 文件唯一标识
|
||||
*/
|
||||
public void removeDataDirFile(String key) {
|
||||
if (!TextUtils.isEmpty(key) && mDataDirFileMap.containsKey(key)) {
|
||||
mDataDirFileMap.remove(key);
|
||||
LogUtils.d(TAG, "移除Data目录文件:" + key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取应用Data目录下指定标识的文件相对路径
|
||||
* @param key 文件唯一标识
|
||||
* @return 相对路径,无则返回null
|
||||
*/
|
||||
public String getDataDirFile(String key) {
|
||||
return mDataDirFileMap.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Data目录下所有备份文件(返回新Map,防止外部篡改原数据)
|
||||
* @return 只读Map副本
|
||||
*/
|
||||
public Map<String, String> getAllDataDirFiles() {
|
||||
return new HashMap<>(mDataDirFileMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空应用Data目录下的所有备份文件
|
||||
*/
|
||||
public void clearDataDirFiles() {
|
||||
mDataDirFileMap.clear();
|
||||
LogUtils.d(TAG, "清空Data目录所有备份文件");
|
||||
}
|
||||
|
||||
// ====================================== SDCard目录 - Map操作方法 ======================================
|
||||
/**
|
||||
* 添加SDCard目录下的备份文件(相对路径)
|
||||
* @param key 文件唯一标识(如crash_20260130,避免重复)
|
||||
* @param relativePath SDCard目录下的相对路径,如:winboll/backup/crash.log
|
||||
*/
|
||||
public void addSdcardFile(String key, String relativePath) {
|
||||
if (!TextUtils.isEmpty(key) && !TextUtils.isEmpty(relativePath) && isSdcardMounted()) {
|
||||
mSdcardFileMap.put(key, relativePath);
|
||||
LogUtils.d(TAG, "添加SDCard目录文件:" + key + " → " + relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除SDCard目录下的指定备份文件
|
||||
* @param key 文件唯一标识
|
||||
*/
|
||||
public void removeSdcardFile(String key) {
|
||||
if (!TextUtils.isEmpty(key) && mSdcardFileMap.containsKey(key)) {
|
||||
mSdcardFileMap.remove(key);
|
||||
LogUtils.d(TAG, "移除SDCard目录文件:" + key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取SDCard目录下指定标识的文件相对路径
|
||||
* @param key 文件唯一标识
|
||||
* @return 相对路径,无则返回null
|
||||
*/
|
||||
public String getSdcardFile(String key) {
|
||||
return mSdcardFileMap.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取SDCard目录下所有备份文件(返回新Map,防止外部篡改原数据)
|
||||
* @return 只读Map副本
|
||||
*/
|
||||
public Map<String, String> getAllSdcardFiles() {
|
||||
return new HashMap<>(mSdcardFileMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空SDCard目录下的所有备份文件
|
||||
*/
|
||||
public void clearSdcardFiles() {
|
||||
mSdcardFileMap.clear();
|
||||
LogUtils.d(TAG, "清空SDCard目录所有备份文件");
|
||||
}
|
||||
|
||||
// ====================================== 核心方法:FTP分步式打包上传(登录→传输→登出) ======================================
|
||||
/**
|
||||
* 核心方法:双Map文件打包为ZIP(UUID+时间戳)+ FTP分步上传
|
||||
* 执行流程:1.FTP登录 → 2.本地ZIP打包 → 3.FTP文件上传 → 4.FTP登出
|
||||
* 每步独立校验,异常即时返回,finally兜底释放所有资源
|
||||
* @return true=全流程成功,false=任意步骤失败
|
||||
* 注:必须在**子线程**执行(避免主线程阻塞ANR)
|
||||
*/
|
||||
public boolean packAndUploadByFtp() {
|
||||
// 前置校验:无待备份文件/SDCard未挂载,直接返回失败
|
||||
if (mDataDirFileMap.isEmpty() && mSdcardFileMap.isEmpty()) {
|
||||
LogUtils.e(TAG, "FTP上传失败:无待备份文件(DataDir+SDCard均为空)");
|
||||
return false;
|
||||
}
|
||||
if (!isSdcardMounted()) {
|
||||
LogUtils.e(TAG, "FTP上传失败:SDCard未挂载,无法创建临时ZIP文件");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. 生成ZIP文件名(UUID去横杠 + 毫秒时间戳,保证全球唯一)
|
||||
String zipFileName = UUID.randomUUID().toString().replace("-", "")
|
||||
+ "-" + System.currentTimeMillis() + ".zip";
|
||||
// 本地临时ZIP文件:应用外部缓存目录(Android 6.0+免读写权限)
|
||||
File tempZipFile = new File(mAppContext.getExternalCacheDir(), zipFileName);
|
||||
// FTP远程完整路径:标准化上传目录 + ZIP文件名(已提前补全斜杠,直接拼接)
|
||||
String remoteFtpFilePath = mFtpTargetDir + zipFileName;
|
||||
|
||||
// 2. 获取FTPUtils单例(全局唯一)
|
||||
FTPUtils ftpUtils = FTPUtils.getInstance();
|
||||
// 上传结果标记
|
||||
boolean isUploadSuccess = false;
|
||||
|
||||
try {
|
||||
// ==================== 第一步:FTP服务器登录(基于外部FTPAuthModel)====================
|
||||
LogUtils.d(TAG, "开始FTP登录:" + mFtpAuthModel.getFtpServer() + ":" + mFtpAuthModel.getFtpPort());
|
||||
boolean isFtpLogin = ftpUtils.login(mFtpAuthModel);
|
||||
if (!isFtpLogin) {
|
||||
LogUtils.e(TAG, "FTP上传失败:FTP登录失败(账号/密码/服务器/端口错误)");
|
||||
return false;
|
||||
}
|
||||
LogUtils.i(TAG, "FTP登录成功,准备打包文件:" + zipFileName);
|
||||
|
||||
// ==================== 第二步:本地打包双Map文件为ZIP(Java7原生实现)====================
|
||||
LogUtils.d(TAG, "开始本地ZIP打包,临时文件路径:" + tempZipFile.getAbsolutePath());
|
||||
boolean isPackSuccess = packFilesToZip(tempZipFile);
|
||||
if (!isPackSuccess || !tempZipFile.exists() || tempZipFile.length() == 0) {
|
||||
LogUtils.e(TAG, "FTP上传失败:ZIP打包失败(文件不存在/空文件)");
|
||||
return false;
|
||||
}
|
||||
LogUtils.i(TAG, "ZIP打包成功,文件大小:" + tempZipFile.length() / 1024 + "KB");
|
||||
|
||||
// ==================== 第三步:FTP文件上传(调用现有FTPUtils.uploadFile)====================
|
||||
LogUtils.d(TAG, "开始FTP上传:本地→FTP" + remoteFtpFilePath);
|
||||
isUploadSuccess = ftpUtils.uploadFile(tempZipFile.getAbsolutePath(), remoteFtpFilePath);
|
||||
if (isUploadSuccess) {
|
||||
LogUtils.i(TAG, "FTP上传全流程成功:" + remoteFtpFilePath);
|
||||
} else {
|
||||
LogUtils.e(TAG, "FTP上传失败:文件传输到服务器失败(响应码异常/权限不足)");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
// 捕获所有运行时异常,避免崩溃
|
||||
LogUtils.e(TAG, "FTP上传异常:" + e.getMessage(), e);
|
||||
isUploadSuccess = false;
|
||||
} finally {
|
||||
// ==================== 最终兜底:无论成功/失败,释放所有资源 ====================
|
||||
// 1. FTP登出+断开连接(避免服务端连接数耗尽)
|
||||
if (ftpUtils.isConnected()) {
|
||||
ftpUtils.logout();
|
||||
}
|
||||
ftpUtils.disconnect();
|
||||
// 2. 删除本地临时ZIP文件(释放存储空间,避免缓存堆积)
|
||||
if (tempZipFile.exists()) {
|
||||
boolean isDelete = tempZipFile.delete();
|
||||
LogUtils.d(TAG, "本地临时ZIP文件删除:" + (isDelete ? "成功" : "失败"));
|
||||
}
|
||||
// 3. 清空临时变量,协助GC
|
||||
System.gc();
|
||||
}
|
||||
|
||||
return isUploadSuccess;
|
||||
}
|
||||
|
||||
// ====================================== 私有工具方法:ZIP打包/SDCard校验 ======================================
|
||||
/**
|
||||
* Java7原生ZIP打包:遍历双Map,拼接绝对路径,写入临时ZIP文件
|
||||
* 自动跳过不存在的文件,ZIP内保留原相对路径(避免文件混乱)
|
||||
* @param zipFile 生成的临时ZIP文件
|
||||
* @return true=打包成功,false=打包失败
|
||||
*/
|
||||
private boolean packFilesToZip(File zipFile) {
|
||||
ZipOutputStream zos = null;
|
||||
try {
|
||||
// 初始化ZIP输出流,UTF-8编码解决中文文件名乱码
|
||||
zos = new ZipOutputStream(new FileOutputStream(zipFile), Charset.forName("UTF-8"));
|
||||
zos.setLevel(ZipOutputStream.DEFLATED); // 开启压缩,减小文件体积
|
||||
|
||||
// 1. 打包应用Data目录下的文件
|
||||
packDirFilesToZip(zos, mDataDirFileMap, mAppContext.getFilesDir());
|
||||
// 2. 打包SDCard目录下的文件
|
||||
packDirFilesToZip(zos, mSdcardFileMap, Environment.getExternalStorageDirectory());
|
||||
|
||||
zos.flush();
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
LogUtils.e(TAG, "ZIP打包IO异常:" + e.getMessage(), e);
|
||||
return false;
|
||||
} finally {
|
||||
// 关闭ZIP流,释放资源
|
||||
if (zos != null) {
|
||||
try {
|
||||
zos.close();
|
||||
} catch (IOException e) {
|
||||
LogUtils.e(TAG, "关闭ZIP流异常:" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量打包指定目录下的文件到ZIP流
|
||||
* @param zos ZIP输出流
|
||||
* @param fileMap 待打包文件Map(key=标识,value=相对路径)
|
||||
* @param baseDir 基础根目录(Data/SDCard)
|
||||
*/
|
||||
private void packDirFilesToZip(ZipOutputStream zos, Map<String, String> fileMap, File baseDir) {
|
||||
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;
|
||||
}
|
||||
// 将单个文件写入ZIP流
|
||||
try {
|
||||
addSingleFileToZip(zos, localFile, relativePath);
|
||||
} catch (IOException e) {
|
||||
LogUtils.e(TAG, "打包单个文件失败:" + relativePath, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单个文件写入ZIP流,保留相对路径作为ZIP内路径
|
||||
* @param zos ZIP输出流
|
||||
* @param localFile 本地待打包文件
|
||||
* @param zipInnerPath ZIP内的相对路径
|
||||
*/
|
||||
private void addSingleFileToZip(ZipOutputStream zos, File localFile, String zipInnerPath) throws IOException {
|
||||
ZipEntry zipEntry = new ZipEntry(zipInnerPath);
|
||||
zos.putNextEntry(zipEntry);
|
||||
// 字节流读取文件,4096缓冲区适配Java7
|
||||
FileInputStream fis = new FileInputStream(localFile);
|
||||
byte[] buffer = new byte[4096];
|
||||
int len;
|
||||
while ((len = fis.read(buffer)) != -1) {
|
||||
zos.write(buffer, 0, len);
|
||||
}
|
||||
// 关闭流和Entry,避免文件粘连/流泄漏
|
||||
fis.close();
|
||||
zos.closeEntry();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断SDCard是否挂载且可读写
|
||||
* @return true=可用,false=不可用
|
||||
*/
|
||||
private boolean isSdcardMounted() {
|
||||
return Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
package cc.winboll.studio.libappbase.utils;
|
||||
|
||||
import cc.winboll.studio.libappbase.LogUtils;
|
||||
import cc.winboll.studio.libappbase.models.FTPAuthModel;
|
||||
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(FTPAuthModel 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) {
|
||||
FTPAuthModel ftpAuthModel = new FTPAuthModel();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
23
libappbase/src/main/res/layout/activity_ftp_backups.xml
Normal file
23
libappbase/src/main/res/layout/activity_ftp_backups.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<Button
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Backups"
|
||||
android:onClick="onBackups"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
Reference in New Issue
Block a user