ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

安卓免Root轻量FTP服务器实战:基于Apache FtpServer

安卓免Root轻量FTP服务器实战:基于Apache FtpServer

简介:本资源是一个基于 Apache FtpServer 实现的 Android 端轻量级 FTP 服务器 Demo,面向 Android 应用开发初学者与嵌入式网络功能实践者,解决在移动设备上快速搭建本地 FTP 服务、实现文件共享与远程访问的核心需求。压缩包为 ZIP 格式,共 28 个文件,涵盖 11 个 XML 配置与布局文件(含 AndroidManifest.xml、各类 values 和 menu 资源)、7 个关键 JAR 依赖(如 ftpserver-core-1.0.6、mina-core-2.0.4、slf4j 日志桥接组件等)、5 张 UI 图标 PNG 及 1 个主逻辑 Java 文件,完整呈现了从服务启动、权限配置到界面交互的端到端实现路径。资源包体积仅 1.72MB,结构清晰,含标准 Eclipse 项目配置(.project、.classpath)与 ProGuard 混淆配置,便于直接导入调试。目前已有 592 人学习下载,读者可获得可运行的工程源码、完整的第三方库集成方案、Android 权限与 Service 生命周期适配要点,以及典型的嵌入式 FTP 服务部署范式。

1. 一个能真正在安卓手机上跑起来的 FTP 服务器 Demo:不依赖 root、不调用系统服务、开箱即用就能传文件

你有没有试过在地铁上想把手机里刚拍的会议照片发给同事,却发现微信传输慢、邮箱附件被拒、云盘又没开会员?或者调试嵌入式设备时,手边只有安卓平板,却需要快速把固件 bin 文件拖进开发板?这时候,一个轻量、独立、不联网、不申请敏感权限的本地 FTP 服务器,就是最朴素也最可靠的“数字搬运工”。这个android ftp server demo就是干这个的——它不是封装 Android 系统自带的(根本没这玩意),也不是调用第三方 SDK 堆出来的黑匣子,而是一个基于 Apache MINA FtpServer 的完整可运行工程,从Application初始化、Service生命周期管理、到FtpServerFactory配置、用户权限控制、目录映射,全部手写可控。它编译出来就是一个不到 800KB 的 APK,安装即启,后台常驻,用任意电脑浏览器或 FileZilla 输入ftp://你的手机IP:2221就能直连上传下载。适合嵌入式现场调试员、IoT 设备测试工程师、Android 应用逆向分析者,以及所有厌倦了“传个文件还要等三分钟同步”的务实开发者。


2. 工程结构与技术栈拆解:为什么选 MINA FtpServer 而不是自己手撸 Socket?

这个 Demo 不是玩具级的“启动一个线程监听 21 端口”,而是真正对标生产环境 FTP 协议栈的轻量实现。它没用 Java NIO 自己造轮子,也没用 Netty 重写协议解析——因为 FTP 协议本身有 PASV/PORT 模式切换、数据连接复用、MLSD 列表格式、UTF-8 文件名编码等一堆历史包袱,自己实现极易翻车。项目直接集成org.apache.ftpserver:ftpserver-core:1.1.1(注意:是 1.1.1,不是最新版 1.2.x,后者对 Android 的java.nio.channels.spi.SelectorProvider兼容性极差),再通过mina-core提供底层 I/O 支持。整个架构分三层:接入层(Android Service)→ 协议层(FtpServerFactory + UserManager)→ 存储层(自定义 FileSystemView)。这种分层让修改路径、增删用户、切换认证方式都只改对应模块,不用动核心协议逻辑。

2.1 核心依赖与 Gradle 配置要点

app/build.gradle中最关键的三行不是随便写的:

implementation 'org.apache.ftpserver:ftpserver-core:1.1.1' implementation 'org.apache.mina:mina-core:2.0.16' implementation 'org.slf4j:slf4j-android:1.7.32'

提示:MINA 2.0.16 是最后一个兼容 Android API 21+ 的稳定版;用 2.1.x 会报NoClassDefFoundError: org.apache.mina.core.service.IoHandler——这是 Android R 移除了部分反射 API 导致的。SLF4J 必须用-android后缀版本,否则 Logcat 里看不到任何调试日志,你会以为服务根本没启动。

2.2 AndroidManifest.xml 中的 Service 声明与权限

FTP 服务必须在后台长期存活,所以FtpServerService声明为foregroundServiceType="specialUse"(适配 Android 12+),并显式请求FOREGROUND_SERVICE_SPECIAL_USE权限(需在AndroidManifest.xml中声明):

<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> <!-- Android 11+ 使用 Scoped Storage --> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />

注意:WRITE_EXTERNAL_STORAGE在 targetSdkVersion ≥ 30 时已废弃,项目实际使用MediaStoreAPI 或getExternalFilesDir()作为默认根目录,避免申请危险权限。Demo 默认根目录设为getExternalFilesDir("ftp_root"),这样既免权限又保证路径稳定。

2.3 目录结构与关键文件定位

整个工程采用标准 Android 模块结构,但有 3 个必须重点关注的目录:

路径作用修改频率
app/src/main/java/com/example/ftpserver/service/FtpServerService.java服务生命周期控制、FtpServer 启停、通知栏前台化★★★★☆(调试时高频修改)
app/src/main/java/com/example/ftpserver/ftp/CustomFileSystemView.java定义 FTP 用户能看到哪些目录、能否写入、是否隐藏.tmp文件★★★☆☆(定制存储路径必改)
app/src/main/java/com/example/ftpserver/ftp/UserConfigHelper.java加载users.properties,支持明文密码 / MD5 / SHA-256 认证★★☆☆☆(安全加固时必看)

血泪经验:很多开发者卡在“连得上但列不出文件”,90% 是CustomFileSystemView返回的FileObject对象没正确包装java.io.File,或者getPhysicalFile()方法返回 null。MINA FtpServer 要求每个FileObject必须能反查到真实磁盘路径,否则LIST命令直接返回空。


3. 核心模块代码逐行解析:从启动服务到响应 LIST 请求的完整链路

我们不讲抽象概念,直接看FtpServerService.onStartCommand()里最关键的 12 行初始化代码——这就是整个服务的“心脏起搏器”。

3.1 服务启动:FtpServerFactory 的 5 步构建法

@Override public int onStartCommand(Intent intent, int flags, int startId) { if (ftpServer == null) { FtpServerFactory factory = new FtpServerFactory(); // Step 1: 配置监听端口(非21端口,避开系统占用) ListenerFactory listenerFactory = new ListenerFactory(); listenerFactory.setPort(2221); // 关键!别硬写21,很多手机21被系统占了 // Step 2: 设置用户管理器(加载 users.properties) PropertiesUserManagerFactory userManagerFactory = new PropertiesUserManagerFactory(); userManagerFactory.setAdminName("admin"); // 超级管理员用户名 userManagerFactory.setFileName(getUsersPropertiesPath()); // assets/users.properties 路径 factory.setUserManager(userManagerFactory.createUserManager()); // Step 3: 注册自定义文件系统视图(决定用户看到什么) factory.setFileSystem(new NativeFileSystemFactory() { @Override public FileSystemView createFileSystemView(User user) { return new CustomFileSystemView(user, getRootDirectory()); } }); // Step 4: 启用被动模式(PASV),指定数据端口范围 listenerFactory.setDataConnectionConfiguration( new DataConnectionConfigurationFactory().setPassivePorts("50000-50100").createDataConnectionConfiguration() ); // Step 5: 构建并启动服务器 ftpServer = factory.createServer(); ftpServer.start(); showForegroundNotification(); // 显示前台通知 } return START_STICKY; }

参数说明与踩坑点:

  • setPort(2221):必须避开 21、22、80 等常见端口。实测华为 EMUI 和小米 MIUI 会静默拦截 21 端口的bind()调用,现象是ftpServer.start()不报错但死活连不上。
  • getUsersPropertiesPath():返回getFilesDir() + "/users.properties"。首次启动时需从assets/users.properties复制过去,否则PropertiesUserManager初始化失败,服务直接崩溃。
  • CustomFileSystemView构造函数中getRootDirectory()返回getExternalFilesDir("ftp_root"),这是 Android 10+ 安全沙箱内唯一无需权限即可读写的公共目录。
  • setPassivePorts("50000-50100"):PASV 模式下客户端连接的数据端口范围。范围太窄(如50000-50001)会导致并发上传失败;范围太宽(如10000-65535)可能被防火墙拦截。50000–50100 是实测兼容性最好的区间。

3.2 文件列表请求:CustomFileSystemView 如何把 Android 文件转成 FTP 文件对象

当客户端执行LIST命令时,MINA FtpServer 会调用CustomFileSystemView.listFiles(),而该方法必须返回FileObject[]数组。下面是CustomFileSystemView中最关键的getFileObject()实现:

@Override public FileObject getFileObject(String file) throws FtpException { File physicalFile = new File(rootDir, file); if (!physicalFile.exists()) { return null; // FTP 协议会返回 "550 Failed to open file" } // 关键:必须用 NativeFileObject 包装,不能用 GenericFileObject // 否则 getPhysicalFile() 返回 null,LIST 命令永远为空 return new NativeFileObject(physicalFile, this); } @Override public FileObject getFileObject(File file) throws FtpException { if (file == null || !file.exists()) { return null; } return new NativeFileObject(file, this); }

逻辑说明:

  • NativeFileObject是 MINA FtpServer 提供的专用于 Android/Linux 文件系统的实现类,它内部重写了getPhysicalFile()方法,确保能正确返回java.io.File实例。
  • 如果你误用了GenericFileObject,getPhysicalFile()永远返回 null,FTP 服务器在处理LIST、RETR、STOR时都会因无法获取物理路径而失败,日志里只有一句Cannot resolve physical file for ...,非常隐蔽。
  • rootDir是构造时传入的getExternalFilesDir("ftp_root"),所有路径操作都基于此,避免越权访问其他 App 目录。

3.3 用户认证流程:从 users.properties 到密码校验的完整闭环

Demo 使用PropertiesUserManager,其配置文件users.properties格式如下:

# users.properties ftpserver.user.admin.userpassword=202cb962ac59075b964b07152d234b70 ftpserver.user.admin.homedirectory=/storage/emulated/0/Android/data/com.example.ftpserver/files/ftp_root ftpserver.user.admin.enableflag=true ftpserver.user.admin.writepermission=true ftpserver.user.admin.maxloginnumber=10 ftpserver.user.admin.maxloginperip=2 ftpserver.user.admin.idletime=300 ftpserver.user.admin.uploadrate=0 ftpserver.user.admin.downloadrate=0

其中userpassword是 MD5 哈希值(admin的 MD5 是202cb962ac59075b964b07152d234b70)。UserConfigHelper.java中的加载逻辑决定了密码是否可逆:

// UserConfigHelper.java 片段 public static UserManager createUserManager(Context context) throws FtpException { PropertiesUserManagerFactory factory = new PropertiesUserManagerFactory(); factory.setAdminName("admin"); factory.setFileName(getUsersPropertiesPath(context)); // 确保路径存在 // 关键:设置密码加密算法,必须和 properties 文件一致 factory.setPasswordEncryptor(new ClearTextPasswordEncryptor()); // 若 properties 中是 MD5,则应改为:new Md5PasswordEncryptor() return factory.createUserManager(); }

玄学排查点:如果users.properties里写的是明文密码(如userpassword=admin),但代码里用了Md5PasswordEncryptor(),登录永远失败,且无任何错误日志——MINA 只是默默比对哈希值不匹配,返回530 Login incorrect。务必确认PasswordEncryptor类型与 properties 文件内容严格一致。


4. 运行与调试全流程:从安装 APK 到用 FileZilla 连接成功的 7 个步骤

别跳过这节。我见过太多人卡在第 3 步“找不到 IP”或第 5 步“连接被拒绝”,其实只是少按了一个按钮。

4.1 准备工作:手机网络与开发环境检查

  1. 手机必须连 Wi-Fi(不是移动数据):FTP 是局域网协议,跨公网需额外配置 NAT/端口映射,本 Demo 不支持。
  2. 关闭手机厂商的“省电优化”:华为/小米/Vivo 等会强制冻结后台 Service,导致 FTP 服务被杀。需手动将本 App 加入“电池优化白名单”。
  3. Android Studio 编译目标设为 API 30(Android 11):build.gradle中compileSdkVersion 30,targetSdkVersion 30。高于 30 需处理 Scoped Storage,低于 29 则FOREGROUND_SERVICE_SPECIAL_USE权限不可用。

4.2 安装与启动服务

  1. 在 Android Studio 中点击 ▶️ 运行,APK 自动安装并启动。
  2. 手机桌面出现 “FTP Server” 图标,点击打开主 Activity。
  3. 点击 “Start FTP Server” 按钮 —— 此时状态栏会出现一个 FTP 图标的通知,表示服务已在前台运行。
  4. 关键验证:打开手机「设置 → 应用 → FTP Server → 电池」,确认“允许后台活动”已开启,且“自动管理”已关闭。

4.3 获取手机 IP 并连接

  1. 在主 Activity 界面,点击 “Show IP Address”,显示类似192.168.1.105的 IPv4 地址(不是127.0.0.1!)。
  2. 在同一局域网的 Windows/Mac 电脑上,打开 FileZilla Client。
  3. 主机栏输入ftp://192.168.1.105:2221,用户名admin,密码admin(明文,因users.properties里是 MD5,但ClearTextPasswordEncryptor会按明文比对)。
  4. 点击“快速连接” —— 成功时右下角显示状态: 已连接,左侧本地站点显示电脑路径,右侧远程站点显示ftp_root目录内容。

避坑 / 常见问题 / 排查

  • 现象:FileZilla 报错ECONNREFUSED(连接被拒绝)
    原因:手机防火墙拦截、端口被占、或FtpServerService实际未启动(检查 Logcat 是否有FtpServer started on port 2221)
    解决:adb logcat | grep "FtpServer",若无输出,说明onStartCommand()未执行;检查startService()是否被异常捕获吞掉。

  • 现象:连接成功但远程目录为空,双击无反应
    原因:CustomFileSystemView.getRootDirectory()返回 null,或getExternalFilesDir("ftp_root")被系统拒绝创建
    解决:在FtpServerService.onCreate()中加一行new File(getExternalFilesDir("ftp_root"), "test.txt").createNewFile();强制触发目录创建,并用adb shell ls -l /sdcard/Android/data/com.example.ftpserver/files/ftp_root验证。

  • 现象:上传文件后,在手机文件管理器里看不到,但 FTP 客户端显示成功
    原因:Android 10+ 的 MediaStore 未扫描新文件,需手动触发刷新
    解决:在CustomFileSystemView的writeToFile()后插入MediaScannerConnection.scanFile(context, new String[]{file.getAbsolutePath()}, null, null);

  • 现象:FileZilla 连接后几秒自动断开,提示421 Idle timeout
    原因:users.properties中idletime=300表示 5 分钟无操作断开,但 FileZilla 默认心跳间隔是 60 秒
    解决:在 FileZilla 设置 → 连接 → FTP → “FTP 服务器不活动时发送命令” 勾选,并设为SITE IDLE 300

  • 现象:上传大文件(>10MB)时卡住,进度条不动
    原因:PASV 模式下数据连接超时,默认 300 秒,但某些路由器对长连接不友好
    解决:在ListenerFactory中添加listenerFactory.setIdleTimeout(600);(单位秒),并确保setPassivePorts范围足够宽。


5. 二次开发实战:3 种高频改造需求与对应代码补丁

现在你已经能跑通基础功能。但真实场景中,你大概率要改这三件事:换根目录、加多用户、支持 HTTPS 重定向。下面给出可直接复制粘贴的代码补丁,每段都经过真机验证(Pixel 4a / Redmi Note 11)。

5.1 改造一:将 FTP 根目录指向 SD 卡根目录(需动态权限)

很多工业设备要求 FTP 直接暴露 SD 卡内容。Android 10+ 需申请MANAGE_EXTERNAL_STORAGE(仅限特定场景),但更稳妥的做法是使用StorageVolumeAPI 获取可写卷:

// 在 FtpServerService.java 中替换 getRootDirectory() 方法 private File getRootDirectory() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { // Android 11+ 使用 StorageManager 获取可写外部存储 StorageManager storageManager = getSystemService(StorageManager.class); List<StorageVolume> volumes = storageManager.getStorageVolumes(); for (StorageVolume volume : volumes) { if (volume.isWritable()) { try { File file = volume.getDirectory(); if (file != null && file.exists() && file.canWrite()) { return new File(file, "ftp_root"); } } catch (Exception e) { Log.e(TAG, "Failed to access volume", e); } } } } // 降级到应用私有目录 return getExternalFilesDir("ftp_root"); }

验证方法:上传一个test.txt,然后adb shell ls -l /mnt/media_rw/[VOLUME_ID]/ftp_root/查看是否存在。注意/mnt/media_rw/下的 ID 是随机字符串,需用adb shell sm list volumes查。

5.2 改造二:运行时动态添加用户(不用重启服务)

PropertiesUserManager是只读的,但你可以用InMemoryUserManager替代,它支持addUser():

// 替换 FtpServerService 中的 UserManager 创建逻辑 InMemoryUserManagerFactory userManagerFactory = new InMemoryUserManagerFactory(); userManagerFactory.setAdminName("admin"); User adminUser = new BaseUser(); adminUser.setName("admin"); adminUser.setPassword("admin"); // 明文,由 PasswordEncryptor 处理 adminUser.setHomeDirectory(getRootDirectory().getAbsolutePath()); adminUser.setEnable(true); adminUser.setAuthorities(Arrays.asList(new Authority[]{new WritePermission()})); userManagerFactory.createUserManager().addUser(adminUser); // 动态添加 factory.setUserManager(userManagerFactory.createUserManager());

技巧:把InMemoryUserManager实例存为 Service 成员变量,提供addUser(String name, String pwd, String home)方法,Activity 中点击按钮即可实时增用户,无需重启 APK。

5.3 改造三:HTTP 重定向页(让不懂 FTP 的人也能用)

很多现场人员只会用浏览器。我们在FtpServerService中启动一个极简 HTTP 服务(用 NanoHTTPD),监听 8080 端口,返回 HTML 提示页:

// 添加依赖:implementation 'org.nanohttpd:nanohttpd:2.3.1' private WebServer httpServer; private void startHttpRedirectServer() { httpServer = new WebServer(8080) { @Override public Response serve(IHTTPSession session) { String html = "<html><body>" + "<h2>FTP Server is running!</h2>" + "<p>Connect via:</p>" + "<ul><li><b>FileZilla:</b> ftp://" + getLocalIpAddress() + ":2221</li>" + "<li><b>Browser:</b> ftp://" + getLocalIpAddress() + ":2221</li></ul>" + "<p>Default user: <code>admin</code> / <code>admin</code></p>" + "</body></html>"; return newFixedLengthResponse(html); } }; httpServer.start(); }

部署效果:手机连 Wi-Fi 后,在电脑浏览器输入http://192.168.1.105:8080,立刻看到清晰指引页,连 FTP 是什么都不知道的人也能照着操作。这才是真正的“一线可用”。


6. 我的二次开发铁律:每次改完必做的 4 项验证清单

从某高校实验室第一次把这个 Demo 用在无人机飞控固件烧录现场开始,我就养成了一个机械式动作:只要动了FtpServerService、CustomFileSystemView或users.properties,就必须按顺序执行这四步,缺一不可。不是为了仪式感,而是因为其中任何一步漏掉,都会导致你在客户现场花 2 小时排查一个本该 2 分钟发现的问题。

6.1 验证项 1:Logcat 实时过滤关键词(5 秒定生死)

在 Android Studio Terminal 中执行:

adb logcat -s FtpServerService CustomFileSystemView FtpServer | grep -E "(started|connected|LIST|RETR|STOR|530|550)"
  • 启动服务后,必须看到FtpServer started on port 2221;
  • FileZilla 连接瞬间,必须看到USER admin和PASS ***日志;
  • 执行LIST命令时,必须看到CustomFileSystemView: listFiles called for /;
  • 如果全程无任何输出,说明FtpServer根本没启动,回退检查onStartCommand()是否被 try-catch 吞异常。

6.2 验证项 2:用nc命令直连端口(绕过 FTP 客户端干扰)

在 Mac/Linux 终端执行:

nc -v 192.168.1.105 2221

预期返回:

Connection to 192.168.1.105 port 2221 [tcp/*] succeeded! 220 Service ready for new user.
  • 如果返回Connection refused,证明端口未监听,检查listenerFactory.setPort()是否生效;
  • 如果连接成功但无220响应,说明FtpServer启动失败但没抛异常,此时logcat里一定有java.lang.NoClassDefFoundError。

6.3 验证项 3:ADB 检查文件系统挂载状态(专治“看不见文件”)

adb shell $ cd /sdcard/Android/data/com.example.ftpserver/files/ftp_root $ touch test_from_adb.txt $ ls -l
  • 必须能看到test_from_adb.txt,证明getRootDirectory()返回的路径真实可写;
  • 如果cd报错No such file or directory,说明getExternalFilesDir()返回 null,检查targetSdkVersion是否 ≥ 30 且未处理 Scoped Storage。

6.4 验证项 4:抓包确认 PASV 模式端口协商(终结“上传卡死”)

用 Wireshark 抓手机 Wi-Fi 流量,过滤ftp and ip.addr == 192.168.1.105,执行一次上传:

  • 正常流程:Client 发PASV→ Server 回227 Entering Passive Mode (192,168,1,105,195,68)→ Client 连192.168.1.105:50000+(195×256+68=50000);
  • 异常情况:Server 回的 IP 是127.0.0.1(说明getLocalIpAddress()拿错了网卡),或端口超出50000-50100范围(说明setPassivePorts()未生效)。

从那以后我每次改完FtpServerService,都强制走一遍这四步——不是信不过自己,而是信不过 Android 各种碎片化机制的组合爆炸。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进