正在调试FTP应用备份功能。

This commit is contained in:
2026-01-30 21:38:04 +08:00
parent e21bb9058d
commit 9d97d6ed94
11 changed files with 1051 additions and 11 deletions

View File

@@ -1,8 +1,8 @@
#Created by .winboll/winboll_app_build.gradle #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 stageCount=12
libraryProject=libappbase libraryProject=libappbase
baseVersion=15.15 baseVersion=15.15
publishVersion=15.15.11 publishVersion=15.15.11
buildCount=0 buildCount=1
baseBetaVersion=15.15.12 baseBetaVersion=15.15.12

View File

@@ -13,6 +13,7 @@ import cc.winboll.studio.appbase.R;
import cc.winboll.studio.libappbase.LogActivity; import cc.winboll.studio.libappbase.LogActivity;
import cc.winboll.studio.libappbase.LogUtils; import cc.winboll.studio.libappbase.LogUtils;
import cc.winboll.studio.libappbase.ToastUtils; import cc.winboll.studio.libappbase.ToastUtils;
import cc.winboll.studio.libappbase.activities.FTPBackupsActivity;
/** /**
* @Author ZhanGSKen<zhangsken@qq.com> * @Author ZhanGSKen<zhangsken@qq.com>
@@ -138,10 +139,15 @@ public class MainActivity extends Activity {
} }
public void onAboutActivity(View view) { public void onAboutActivity(View view) {
LogUtils.d(TAG, "startAboutActivity() 调用"); LogUtils.d(TAG, "onAboutActivity() 调用");
Intent aboutIntent = new Intent(getApplicationContext(), AboutActivity.class); Intent aboutIntent = new Intent(getApplicationContext(), AboutActivity.class);
startActivity(aboutIntent); startActivity(aboutIntent);
LogUtils.d(TAG, "startAboutActivity: 关于页面已启动"); }
public void onFTPBackupsActivity(View view) {
LogUtils.d(TAG, "onFTPBackupsActivity() 调用");
Intent ftpBackupsIntent = new Intent(getApplicationContext(), FTPBackupsActivity.class);
startActivity(ftpBackupsIntent);
} }
} }

View File

@@ -59,6 +59,18 @@
android:onClick="onToastUtilsTest" android:onClick="onToastUtilsTest"
android:layout_margin="10dp"/> 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 <Button
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@@ -27,5 +27,8 @@ dependencies {
api 'com.google.code.gson:gson:2.8.9' api 'com.google.code.gson:gson:2.8.9'
// Html 解析 // Html 解析
api 'org.jsoup:jsoup:1.13.1' 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']) api fileTree(dir: 'libs', include: ['*.jar'])
} }

View File

@@ -1,8 +1,8 @@
#Created by .winboll/winboll_app_build.gradle #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 stageCount=12
libraryProject=libappbase libraryProject=libappbase
baseVersion=15.15 baseVersion=15.15
publishVersion=15.15.11 publishVersion=15.15.11
buildCount=0 buildCount=1
baseBetaVersion=15.15.12 baseBetaVersion=15.15.12

View File

@@ -16,8 +16,8 @@
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<application <application
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
android:networkSecurityConfig="@xml/network_security_config"> android:networkSecurityConfig="@xml/network_security_config">
<activity <activity
android:name=".CrashHandler$CrashActivity" 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.NfcRsaLoginActivity"/>
<activity android:name="cc.winboll.studio.libappbase.activities.FTPBackupsActivity"/>
</application> </application>
</manifest> </manifest>

View File

@@ -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");
}
}

View File

@@ -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;
}
}

View File

@@ -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 + "/");
// 初始化双MapHashMap兼容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文件打包为ZIPUUID+时间戳)+ 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文件为ZIPJava7原生实现====================
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 待打包文件Mapkey=标识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());
}
}

View File

@@ -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连接会话、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(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();
}
}

View 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>