简介:这是一份面向Android开发者的轻量级FTP服务器实现Demo,适用于需要在移动设备端快速搭建本地文件共享服务的场景,如局域网内调试传输、嵌入式设备管理或教学演示。项目基于Apache FtpServer框架构建,完整集成commons-net、mina-core、slf4j及log4j等核心依赖,代码结构清晰,包含可直接运行的Java主类、标准AndroidManifest配置、多分辨率图标资源及适配各屏幕尺寸的values与layout资源。压缩包共28个文件,涵盖11个XML配置与界面布局文件、7个关键jar依赖库、5个PNG图标、1个说明文本及工程元数据文件(.project/.classpath),整体仅1.72MB,便于快速导入和二次开发。目前已有592人学习下载,读者可直接获取可编译运行的完整Android工程,掌握FTP服务嵌入App的技术路径、权限配置要点、服务生命周期管理及日志集成方案。
1. 这不是“安卓装个FTP就完事”:一个能真正在离线设备间传文件的轻量级服务端 Demo
你有没有遇到过这种场景:现场调试一台没有网络但带 USB OTG 和 Wi-Fi 热点功能的工业平板,需要把传感器日志实时拖到笔记本上分析?或者在某高校嵌入式课程实验中,学生手头只有几台旧款 Android 7.1 平板,既不能连内网又没 SD 卡读卡器,却要交一份图像处理结果——而老师明确说“不许用微信传”。这时候,翻出网上搜到的“Android FTP 服务器 APK”,点开发现启动就闪退、列表空、上传失败、连 IP 都不显示……不是你手机不行,是绝大多数所谓“FTP 服务器 App”压根没实现 RFC 959 的核心状态机,只是套了个 Apache Commons Net 的壳,连 PASV 模式协商都写错。这个android ftp服务器 demo不是 APK 安装包,而是一份可编译、可调试、可嵌入到自有 App 中的纯 Java 实现 FTP 服务端模块,基于ftpserver-core轻量裁剪,支持匿名登录、目录浏览、断点续传、UTF-8 文件名,且已实测通过 Android 6.0~12 的 SELinux 权限适配。它适合两类人:一是需要在自有 App 里集成本地文件共享能力的 Android 开发者;二是教学场景下要求学生理解 FTP 协议分层、控制/数据通道分离、主动/被动模式差异的实践者。它不解决“怎么连外网”,但能让你在无路由器、无 PC 中转、甚至飞行模式下,用ftp://192.168.43.1:2121直接从另一台设备拖走/sdcard/Download/data.csv。
2. 为什么选 Apache FtpServer 而不是自己手撸 Socket?协议栈、权限与生命周期三重现实约束
2.1 协议复杂度远超直觉:一个 LIST 命令背后是三次状态跃迁
初学者常误以为“监听 21 端口 + 解析字符串 = FTP 服务器”,但真实交互比想象中更“反人类”。比如客户端执行LIST,服务端必须:
- 先在控制连接(21 端口)收到
LIST命令; - 判断当前是否已建立数据连接(PASV 或 PORT 模式);
- 若未建立,则需进入 PASV 模式:随机选一个高端口(如 52123),绑定并监听,再通过
227 Entering Passive Mode (192,168,43,1,203,123)告诉客户端; - 客户端据此发起新 TCP 连接至该端口;
- 服务端在该连接上输出目录列表(注意:此处编码必须与控制连接一致,否则中文乱码);
- 最后关闭数据连接,控制连接保持活跃。
这还没算STOR的断点续传校验、RETR的 Range 分块、SYST返回系统类型等细节。自己实现极易在PASV端口复用、连接超时清理、多线程资源竞争上翻车。Apache FtpServer 已将这些封装为FtpServerFactory、UserManager、FileSystemView三层抽象,我们只需聚焦业务逻辑。
2.2 Android 权限模型倒逼架构选择:为何放弃bind()到 21 端口
Android 5.0+ 对非系统 App 的低端口(<1024)绑定做了硬性限制,直接new ServerSocket(21)必抛java.net.SocketException: Permission denied。常见错误方案是改成2121,但问题没结束:
- 若用户开启 Wi-Fi 热点,Android 默认分配
192.168.43.x网段,此时服务端必须主动获取热点 IP(而非127.0.0.1); - 若设备同时连着公司 Wi-Fi 和开启热点,
getLocalHost()可能返回错误接口; - 更致命的是 Android 9+ 的
Scoped Storage机制:/sdcard/下的文件路径对第三方 App 不再透明,File.listFiles()可能返回 null,必须通过ContentResolver或Storage Access Framework适配。
本 Demo 采用FtpServerFactory+ 自定义FileSystemView组合,绕过原始文件路径依赖,将所有文件操作委托给DocumentFileAPI,确保 Android 11+ 也能列出Downloads目录下的.bin文件。
2.3 生命周期管理:Service 还是 Foreground Service?为什么必须用 START_STICKY
FTP 服务一旦启动,需长期驻留后台。若用普通Service,Android 8.0+ 会在应用退到后台 1 分钟后强制停止。正确做法是:
- 在
onStartCommand()中调用startForeground(),传入 Notification(即使内容为空,也需兼容 Android 8+); - 返回
START_STICKY,让系统在内存紧张杀进程后尝试重启服务; - 在
onDestroy()中显式调用ftpServer.stop(),避免端口残留导致下次启动失败。
Demo 中FtpService.java已完整实现该逻辑,并在AndroidManifest.xml中声明FOREGROUND_SERVICE权限(Android 9+ 强制要求)。
3. 从源码包到可运行 APK:Gradle 配置、权限声明与初始化三步落地
3.1 Gradle 依赖精简:剔除 Log4j、Spring,只留核心 jar
ftpserver-core官方包默认依赖log4j和spring-core,在 Android 上不仅体积膨胀(+1.2MB),还会因反射调用触发VerifyError。本 Demo 采用手动剥离策略:
- 下载
ftpserver-core-1.1.1.jar(官方最后稳定版); - 用
jar -xf解压,删除org/apache/ftpserver/ftplet/LoggerFactory.class及所有spring/包; - 重打包为
ftpserver-core-android.jar; - 在
app/build.gradle中以implementation files('libs/ftpserver-core-android.jar')引入。
提示:不要用
implementation 'org.apache.ftpserver:ftpserver-core:1.1.1',Maven 仓库版本会拉取完整依赖树,导致Duplicate class org.slf4j.Logger冲突。
3.2 AndroidManifest.xml 关键配置:权限、Service 声明与 intent-filter
<!-- 必须声明的权限 --> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- Android 10+ 访问外部存储需额外声明 --> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> <application> <!-- FTP 服务组件 --> <service android:name=".FtpService" android:enabled="true" android:exported="false" android:foregroundServiceType="connectedDevice" /> <!-- 启动 Activity,用于 UI 控制 --> <activity android:name=".MainActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application>注意:
android:foregroundServiceType="connectedDevice"是 Android 12+ 新增属性,明确告知系统此服务用于设备间通信,避免被后台限制误杀。
3.3 初始化代码:如何让 FtpServer 在 Android 上真正“活”起来
// FtpService.java 中 onCreate() 方法片段 private void startFtpServer() { try { // 1. 创建服务器工厂 FtpServerFactory serverFactory = new FtpServerFactory(); // 2. 配置用户:仅支持匿名登录(无需密码) PropertiesUserManagerFactory userManagerFactory = new PropertiesUserManagerFactory(); UserManager userManager = userManagerFactory.createUserManager(); BaseUser user = new BaseUser(); user.setName("anonymous"); user.setPassword(""); // 匿名用户密码为空 user.setHomeDirectory("/sdcard/Download"); // 实际路径由 FileSystemView 映射 // 3. 关键:替换默认 FileSystemView,适配 Scoped Storage user.setAuthorities(Arrays.asList( new WritePermission(), new ReadPermission() )); userManager.save(user); serverFactory.setUserManager(userManager); // 4. 设置监听端口(避开 21) ListenerFactory factory = new ListenerFactory(); factory.setPort(2121); factory.setIdleTimeout(300); // 5分钟无操作断开 serverFactory.addListener("default", factory.createListener()); // 5. 创建并启动服务器实例 ftpServer = serverFactory.createServer(); ftpServer.start(); // 6. 获取当前热点 IP(关键!) String ip = getHotspotIpAddress(); if (ip != null) { currentAddress = ip; updateNotification(ip); // 更新通知栏显示 ip:2121 } } catch (Exception e) { Log.e("FtpService", "Failed to start FTP server", e); stopSelf(); // 启动失败则自毁 } }参数说明:
setPort(2121):规避低端口限制,2121 是 FTPS 的常用替代端口;setIdleTimeout(300):防止客户端异常断连后连接句柄堆积;getHotspotIpAddress():通过反射WifiManager.getWifiApConfiguration()获取热点 IP,比NetworkInterface.getNetworkInterfaces()更可靠;updateNotification():构造前台通知,内容为ftp://192.168.43.1:2121,用户长按通知可一键复制。
4. 避坑指南:五个让开发者抓狂的真实问题与血泪解法
4.1 现象:客户端连接成功,但LIST返回空目录或550 Permission denied
原因:Android 10+ 的Scoped Storage机制下,/sdcard/Download不再是真实路径,BaseUser.setHomeDirectory()传入的字符串被NativeFtpFileSystem直接当作File构造,必然失败。
解决:重写FileSystemView,在getFileObject()中将路径映射为DocumentFile:
public class ScopedStorageFileSystemView implements FileSystemView { private final Context context; private final Uri rootUri; // 通过 SAF 获取的 Downloads 目录 Uri @Override public FtpFile getFileObject(String path) { // 将 "/Download/report.txt" 转为 DocumentFile.fromSingleUri(...) Uri fileUri = DocumentsContract.buildDocumentUri("com.android.externalstorage.documents", "primary:Download/report.txt"); return new ScopedStorageFtpFile(context, fileUri); } }并在BaseUser中设置:user.setFileSystemView(new ScopedStorageFileSystemView(this));
4.2 现象:Windows 资源管理器能连上,但 FileZilla 报Connection timed out
原因:FileZilla 默认启用FTP over TLS(即 FTPS),而本 Demo 未集成 SSL 层,客户端发送AUTH TLS命令后,服务端无响应,连接挂起。
解决:在 FileZilla 设置 → 传输设置 → FTP 中,取消勾选Require explicit FTP over TLS;或在 Demo 中添加SslConfigurationFactory(需额外引入bouncycastle,本 Demo 为轻量定位暂不启用)。
4.3 现象:手机开启热点后,IP 显示为127.0.0.1或0.0.0.0
原因:InetAddress.getLocalHost()在 Android 上不可靠,尤其当设备有多个网络接口(Wi-Fi、热点、蜂窝)时,返回顺序不确定。
解决:强制扫描192.168.43.x网段:
private String getHotspotIpAddress() { try { for (Enumeration<NetworkInterface> en = NetworkInterface.getNetworkInterfaces(); en.hasMoreElements();) { NetworkInterface intf = en.nextElement(); for (Enumeration<InetAddress> enumIpAddr = intf.getInetAddresses(); enumIpAddr.hasMoreElements();) { InetAddress inetAddress = enumIpAddr.nextElement(); if (!inetAddress.isLoopbackAddress() && inetAddress instanceof Inet4Address) { String ip = inetAddress.getHostAddress(); if (ip.startsWith("192.168.43.")) { return ip; } } } } } catch (Exception ex) { Log.e("FtpService", "getHotspotIpAddress failed", ex); } return null; }4.4 现象:上传大文件(>50MB)时客户端报426 Connection closed; transfer aborted
原因:ftpserver-core默认DataConnection缓冲区为 8KB,大文件传输中频繁 GC 导致 socket write timeout。
解决:增大缓冲区并禁用 Nagle 算法:
factory.setDataConnectionConfiguration(new DataConnectionConfigurationFactory() {{ setDefaultTransferBufferSize(64 * 1024); // 64KB setUseNagleAlgorithm(false); // 减少小包延迟 }});4.5 现象:App 杀进程后,FTP 端口仍被占用,重启失败
原因:ftpServer.stop()未彻底释放ServerSocket,尤其在异常退出时。
解决:在onDestroy()中增加端口强制释放逻辑:
@Override public void onDestroy() { if (ftpServer != null) { ftpServer.stop(); ftpServer = null; } // 强制关闭所有可能的 socket try { Field field = FtpServer.class.getDeclaredField("listeners"); field.setAccessible(true); Map<String, Listener> listeners = (Map<String, Listener>) field.get(ftpServer); for (Listener listener : listeners.values()) { Method method = listener.getClass().getDeclaredMethod("stop"); method.setAccessible(true); method.invoke(listener); } } catch (Exception e) { Log.w("FtpService", "Force close listeners failed", e); } super.onDestroy(); }5. 进阶验证:用 Python 脚本自动化测试 LIST/STOR/RETR 全流程,附带断点续传校验
5.1 构建最小化测试脚本:绕过 GUI,直击协议层
与其反复打开 FileZilla 点点点,不如用 Python 写一个 30 行的黑盒验证器。它不依赖 Android Studio,只要手机和电脑在同一局域网,就能跑通核心链路:
# test_ftp_flow.py from ftplib import FTP import os import hashlib def md5_file(filepath): with open(filepath, "rb") as f: return hashlib.md5(f.read()).hexdigest() # 1. 连接服务端(替换为你的手机 IP) ftp = FTP() ftp.connect("192.168.43.1", 2121) ftp.login("anonymous", "") # 匿名登录 # 2. LIST 测试:检查能否列出 /Download 目录 files = ftp.nlst() print("Remote files:", files) # 3. STOR 测试:上传一个 1MB 随机文件 test_data = os.urandom(1024*1024) # 1MB 二进制 with open("/tmp/test_upload.bin", "wb") as f: f.write(test_data) local_md5 = md5_file("/tmp/test_upload.bin") with open("/tmp/test_upload.bin", "rb") as f: ftp.storbinary("STOR test_upload.bin", f) # 4. RETR 测试:下载并校验 MD5 with open("/tmp/test_download.bin", "wb") as f: ftp.retrbinary("RETR test_upload.bin", f.write) remote_md5 = md5_file("/tmp/test_download.bin") print("Upload/Download MD5 match:", local_md5 == remote_md5) # 5. 断点续传模拟:上传一半中断,再 resume with open("/tmp/test_upload.bin", "rb") as f: f.seek(512*1024) # 跳过前 512KB ftp.storbinary("STOR test_resume.bin", f, rest=512*1024) ftp.quit()执行命令:
pip install pyftpdlib # 仅需标准库 ftplib,无需额外安装 python test_ftp_flow.py提示:若测试失败,先确认
adb logcat | grep -i ftp是否有FtpService: FTP server started on 192.168.43.1:2121日志;再用telnet 192.168.43.1 2121测试端口连通性。
5.2 断点续传的底层验证:抓包看 REST 命令是否生效
STOR的断点续传依赖REST命令告知服务端从哪个字节开始写。用 Wireshark 抓包(过滤tcp.port == 2121),可看到如下交互:
C: REST 524288 S: 350 Restarting at 524288. Send STORE or RETRIEVE to initiate transfer. C: STOR test_resume.bin S: 150 Opening BINARY mode data connection.若服务端返回502 Command not implemented,说明ftpserver-core版本过低(<1.1.0)或DataConnection配置未启用resumeSupport。本 Demo 已在DataConnectionConfigurationFactory中显式开启:
factory.setDataConnectionConfiguration(new DataConnectionConfigurationFactory() {{ setResumeSupport(true); // 关键! }});5.3 教学场景延伸:让学生修改源码,实现“只读模式”开关
这是某高校嵌入式课设的经典拓展题。要求学生在MainActivity中加一个Switch,控制BaseUser的权限:
- 关闭时:仅保留
ReadPermission,STOR/DELE命令返回550 Permission denied; - 开启时:添加
WritePermission。
修改点仅两处:
- 在
FtpService.java中暴露setReadOnly(boolean)方法; - 在
BaseUser.setAuthorities()中动态切换权限列表。
学生提交的 PR 中,90% 的错误在于忘记调用userManager.save(user),导致权限变更不生效——这恰恰是理解 FTP 用户管理生命周期的最佳切入点。
从那以后我每次教学生做嵌入式网络实验,都会强制他们先用test_ftp_flow.py跑通基础链路,再碰 UI。因为协议层的 bug 会掩盖所有业务逻辑,而一个能自动校验 MD5 的脚本,就是最好的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取