Light-Speed File Transfer for Android
A production-ready FTP server and client in your pocket — built with Kotlin, Jetpack Compose, and a chunked parallel transfer engine that pushes your network to the limit.
- What is MobileFTP?
- Highlights
- Three-Tab Dashboard
- Architecture
- Performance Engine — 10 Laws
- Tech Stack
- Permissions
- Requirements
- Getting Started
- Build & Run
- Wireless ADB Setup
- CI / CD Pipeline
- Project Structure
- Configuration Reference
- Troubleshooting
- Contributing
- License
MobileFTP turns your Android phone into a two-way file portal:
- Run an FTP server — phone becomes reachable from any FTP client (Windows Explorer, FileZilla, Cyberduck, your Mac's Finder). Tap Start and your photos, downloads, and documents are instantly browsable over the LAN.
- Connect as an FTP client — paste a host, get a fast file browser with multi-select downloads, parallel transfers, and resumable jobs.
Designed around three principles:
- Speed first. Every file >1 MB is split into N parallel chunks (default 8), each on its own FTP data connection. Throughput scales with network capacity, not protocol overhead.
- Surgical UI. Raycast-inspired Obsidian-dark surfaces, hairline borders, command-palette density. Auto light/dark mode with a manual override.
- Resilient transfers. Per-chunk MD5 verification, 2-second checkpoints to Room, automatic resume from the last verified offset on failure.
| Capability | Detail |
|---|---|
| 🔁 Bi-directional | Server and client modes in a single app |
| ⚡ Parallel chunks | Files split into N=2–32 streams, transferred concurrently |
| 📊 Live throughput | 250 ms sampling, 2 s sliding window, gradient area chart |
| 💾 Resumable | Per-chunk MD5 + 2 s checkpoints, restart only failed chunks |
| 🔌 Connection pool | Pre-warmed FTP clients, no teardown between files |
| 🧠 Adaptive buffers | 200 ms bandwidth probe + live recalc every 5 s |
| 📦 LZ4 compression | Probe first 64 KB; skip already-compressed bytes |
| 📡 Network failover | Scores WiFi Direct / 5 GHz / 2.4 GHz / Ethernet / hotspot |
| 🔒 Secure creds | Passwords encrypted via Android Keystore + EncryptedSharedPreferences |
| 🔐 FTPS-ready | TLS 1.2+ control & data channels (toggle in config) |
| 🌙 Auto theme | System dark/light detection + manual override |
| 📱 QR pairing | Tap to copy ftp://user:pass@host:port connection URL |
| 🔋 Foreground service | Persistent notification while server runs |
| 🚀 Background transfers | WorkManager with progress notifications |
Spin up an FTP server with one tap.
- Status hero card — running/stopped pill, LAN + public IP, bound port, active root path, network interface badges.
- All Files Access banner — surfaces missing
MANAGE_EXTERNAL_STORAGEwith a one-tap shortcut to system Settings. - QR code — tap to copy the full connection URL with haptic feedback.
- Connections card — live count of connected clients with IP and connect time.
- Throughput graph — 60-sample area chart with current / peak / average chips.
- Configuration — port, credentials, PASV range, FTPS toggle, anonymous toggle, shared directory picker (SAF), max connections slider, chunk count slider.
Watch every byte move.
- Active / Pending / Completed sections with section counts.
- Per-job cards that expand to per-chunk detail (offset, transferred, speed, state).
- Action buttons per row: cancel, retry, remove.
- Empty state with a flame icon when nothing's queued.
Browse remote FTP servers like a local file manager.
- Saved profiles — name, host, port, last connected timestamp.
- Connection sheet — modal with host, port, credentials, PASV/FTPS toggles, chunk count override.
- File browser — sticky breadcrumbs, sortable name/size/date, multi-select,
long-press context menu (download, rename, delete),
+button for new folder.
Strict MVVM + Repository with one-way data flow:
┌─────────────────────────────────────────────────────────────┐
│ UI Layer (Compose) │
│ ServerScreen · ClientScreen · TransferQueueScreen │
└──────────────┬──────────────────────────────────────────────┘
│ collectAsState()
▼
┌─────────────────────────────────────────────────────────────┐
│ ViewModels (Hilt) │
│ ServerVM · ClientVM · FileBrowserVM · TransferQueueVM │
└──────────────┬──────────────────────────────────────────────┘
│ suspend fun / Flow<>
▼
┌─────────────────────────────────────────────────────────────┐
│ UseCases (domain/usecase/) │
│ StartServer · ConnectClient · Download · Upload · Resume │
└──────────────┬──────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Repositories (data/repository/) │
│ FtpServerRepo · FtpClientRepo · TransferRepo · … │
└──────┬─────────────────────────────────────────┬────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌─────────────────────────┐
│ Network Engines │ │ Local Persistence │
│ ChunkTransfer │ │ Room (jobs, chunks, │
│ ConnectionPool │ │ profiles) │
│ ThroughputMonitor│ │ DataStore (settings) │
│ AdaptiveBuffer │ │ EncryptedShared (creds)│
│ Lz4Compression │ └─────────────────────────┘
│ NetworkInterface │
│ SocketTuner │
└──────────────────┘
These ten laws govern every byte that moves through MobileFTP. Each is independently verifiable in code.
| # | Law | Module |
|---|---|---|
| P1 | Parallel multi-stream chunk engine — files >1 MB split into N (2–32) concurrent streams | network/ChunkTransferEngine.kt |
| P2 | Zero-copy I/O — FileChannel.transferTo + memory-mapped buffers ≥64 MB |
ChunkTransferEngine |
| P3 | Adaptive buffer sizing — 200 ms probe, recalc every 5 s, clamp 64 KB–4 MB | network/AdaptiveBufferEngine.kt |
| P4 | TCP socket tuning — 4 MB SND/RCV, no Nagle, keep-alive | network/SocketTuner.kt |
| P5 | Work-stealing scheduler — Dispatchers.IO.limitedParallelism(32) |
ChunkTransferEngine |
| P6 | Adaptive LZ4 compression — skip if first-64 KB ratio ≥ 0.95 | network/Lz4CompressionEngine.kt |
| P7 | Resumable transfers — per-chunk MD5 + 2 s Room checkpoints | worker/FtpTransferWorker.kt |
| P8 | Connection pool — borrow/return/warm with ArrayDeque + Mutex |
network/FtpConnectionPool.kt |
| P9 | Network interface scoring — WiFi Direct > 5 GHz > Ethernet > 2.4 GHz > Hotspot > Cellular | network/NetworkInterfaceSelector.kt |
| P10 | Real-time throughput monitor — 250 ms sample, 2 s sliding window, ETA | network/ThroughputMonitor.kt |
| Layer | Choice |
|---|---|
| Language | Kotlin 1.9.23 (100% — zero Java sources) |
| UI | Jetpack Compose 1.6.7 + Material 3 1.2.1 |
| DI | Hilt 2.51.1 |
| Async | Coroutines 1.8.0 + Flow |
| Persistence | Room 2.6.1 + DataStore 1.1.1 |
| Background | WorkManager 2.9.0 (foreground transfer worker) |
| Security | EncryptedSharedPreferences (Android Keystore-backed) |
| FTP server | Apache FtpServer 1.2.0 |
| FTP client | Apache Commons Net 3.10.0 |
| Compression | LZ4-Java 1.8.0 |
| HTTP | OkHttp 4.12.0 (public-IP probe only) |
| QR codes | ZXing core 3.5.3 (writer only — no camera scanner) |
| Build | AGP 8.4.0 + Gradle 8.7 (JDK 17) |
| Min SDK | 26 (Android 8.0 Oreo) |
| Target SDK | 34 (Android 14) |
Trimmed to the bare minimum the app actually uses:
| Permission | Why | When |
|---|---|---|
INTERNET |
FTP transport | always |
ACCESS_NETWORK_STATE |
network change detection (P9 failover) | always |
ACCESS_WIFI_STATE |
WiFi LAN IP detection | always |
CHANGE_WIFI_MULTICAST_STATE |
mDNS / network discovery | always |
MANAGE_EXTERNAL_STORAGE |
serve real phone files (DCIM, Downloads…) | runtime, user-granted |
READ_EXTERNAL_STORAGE |
legacy fallback for Android ≤ 12L | runtime, max SDK 32 |
FOREGROUND_SERVICE + FOREGROUND_SERVICE_DATA_SYNC |
keep server alive while phone is locked | always (declared) |
POST_NOTIFICATIONS |
live status notification on Android 13+ | runtime prompt |
No Camera, Location, Nearby Devices, Photos & Videos, Music & Audio, Contacts, Phone, Microphone, or any other permission you'd typically associate with a sketchy "free" app. The QR feature uses a writer-only library; nothing in the app activates the camera.
| Tool | Required | Recommended |
|---|---|---|
| Android Studio | Hedgehog (2023.1.1)+ | latest stable |
| JDK | 17 | Temurin 17 LTS |
| Gradle | 8.7 (via wrapper) | included in repo |
| Node.js | 14+ (for setup.js) |
18 LTS |
| ADB | included in platform-tools | latest |
| Spec | Required |
|---|---|
| Android version | 8.0+ (API 26+) |
| Storage | < 20 MB installed |
| Architecture | any (no NDK) |
git clone https://github.com/adityabhalsod/mobile-ftp.git
cd mobile-ftpPlug in your phone with USB debugging enabled, then:
node setup.js deployThat single command runs init → build → install → launch. The script:
- auto-detects your Android SDK from
ANDROID_HOMEor default Windows / macOS / Linux paths - auto-downloads the Gradle 8.7 wrapper jar if missing
- discovers Android Studio's bundled JBR if
JAVA_HOMEisn't set - picks the first online ADB device (USB or wireless)
node setup.js adb-setup # interactive pairing wizard
node setup.js deployadb-setup walks you through Settings → Developer Options → Wireless Debugging
→ "Pair device with pairing code", saves the IP/port to device.ini, and
future deploy calls auto-reconnect.
node setup.js # show all commands
node setup.js init # write local.properties from ANDROID_HOME
node setup.js build # gradlew assembleDebug
node setup.js install # install pre-built APK
node setup.js run # install + launch
node setup.js deploy # full pipeline: init + build + install + launch
node setup.js uninstall # remove app from device
node setup.js logcat # tail logcat filtered to com.mobileftp
node setup.js adb-setup # interactive wireless pairing
node setup.js reconnect # recover an offline wireless ADB session# Debug build + install
./gradlew installDebug
# Debug APK only
./gradlew assembleDebug
# → app/build/outputs/apk/debug/app-debug.apk
# Release APK (signed if KEYSTORE_FILE env vars are set; unsigned otherwise)
./gradlew assembleRelease
# → app/build/outputs/apk/release/app-release.apk
# Launch the app
adb shell am start -n com.mobileftp/.MainActivityexport KEYSTORE_FILE="/path/to/release.keystore"
export KEYSTORE_PASSWORD="..."
export KEY_ALIAS="release"
export KEY_PASSWORD="..."
./gradlew assembleReleaseWhen these env vars are present, the build wires them into the release
signing config (see app/build.gradle.kts).
# Only MobileFTP logs
adb logcat -s FtpServerService:V FtpServerRepository:V
# All app process logs
adb logcat --pid=$(adb shell pidof com.mobileftp)Skip the cable. From setup.js:
node setup.js adb-setupThe wizard asks for:
- Pairing IP / port / code — from "Pair device with pairing code" on the phone
- Connect IP / port — from the main "Wireless Debugging" page (these often differ from the pairing port!)
Both pairs are saved to device.ini (git-ignored). On every later run,
setup.js reconnects automatically and verifies the device state — if it's
gone offline (port rotation, sleep), it kills and restarts the local ADB
server, then reconnects.
If the session goes truly stale, run node setup.js reconnect for a
forced recovery without re-pairing.
The repo ships a GitHub Actions workflow at
.github/workflows/release.yml that publishes
signed APKs to GitHub Releases on every push to main, beta, or alpha.
Push to main / beta / alpha
│
▼
┌─ GitHub Actions ─────────────────────────────────┐
│ 1. Checkout (full history for changelog) │
│ 2. Set up JDK 17 (Temurin) │
│ 3. Set up Android SDK │
│ 4. Cache Gradle dependencies │
│ 5. Determine version + build number │
│ 6. Inject versionCode = commits-since-last-tag │
│ 7. Generate categorized changelog from commits │
│ 8. Decode keystore (or auto-generate temp one) │
│ 9. ./gradlew assembleRelease (-x lint -x test) │
│ 10. Rename APK → mobileftp-vX.Y.Z.apk │
│ 11. Upload as workflow artifact (30 days) │
│ 12. Create + push Git tag │
│ 13. Create GitHub Release with APK + changelog │
└──────────────────────────────────────────────────┘
| Branch | Channel | Version pattern | Pre-release |
|---|---|---|---|
main |
stable | v1.0.0 |
no |
beta |
beta | v1.0.0-beta.N |
yes |
alpha |
alpha | v1.0.0-alpha.N |
yes |
Commits are categorized by their conventional-commit prefix:
| Prefix | Section |
|---|---|
feat: |
✨ Features |
fix: |
🐛 Bug Fixes |
perf: |
⚡ Performance |
refactor: |
🧹 Refactor |
| (other) | 📦 Other Changes |
All of these are optional. Without them, CI auto-generates a temporary keystore so every build is at least installable.
| Secret | Description |
|---|---|
KEYSTORE_BASE64 |
Base64-encoded .keystore file |
KEYSTORE_PASSWORD |
Keystore password |
KEY_ALIAS |
Signing key alias |
KEY_PASSWORD |
Signing key password |
You can also trigger a release manually from the Actions tab via
workflow_dispatch, choosing the alpha / beta / main channel.
mobile-ftp/
├── app/
│ ├── build.gradle.kts # AGP + signing config (env-driven)
│ ├── proguard-rules.pro # R8 keep rules for FtpServer / Commons Net / LZ4
│ └── src/main/
│ ├── AndroidManifest.xml # 8 permissions, foreground service, WorkManager
│ ├── kotlin/com/mobileftp/
│ │ ├── MainActivity.kt # entry point, system bar config, permission prompt
│ │ ├── MobileFtpApp.kt # @HiltAndroidApp + WorkManager.Configuration
│ │ ├── di/ # Hilt modules (App, Database, Network, Worker)
│ │ ├── data/
│ │ │ ├── local/ # Room DB, DataStore, EncryptedSharedPrefs
│ │ │ └── repository/ # 4 repositories
│ │ ├── domain/
│ │ │ ├── model/ # 7 immutable data classes
│ │ │ └── usecase/ # 7 use cases
│ │ ├── network/ # 7 perf engines (P1–P10)
│ │ ├── service/ # FtpServerService (foreground)
│ │ ├── worker/ # FtpTransferWorker (background transfers)
│ │ ├── ui/
│ │ │ ├── components/ # 11 reusable Compose components
│ │ │ ├── theme/ # Raycast tokens (light + dark)
│ │ │ ├── server/ · client/ · transfers/ # ViewModels + Screens
│ │ │ └── MobileFtpApp.kt # Bottom-nav scaffold
│ │ └── util/ # NetworkUtils, StorageUtils, ChecksumUtils, …
│ └── res/ # icons, themes, strings, font descriptors
├── .github/
│ └── workflows/release.yml # signed APK release pipeline
├── docs/
│ └── fonts.md # optional Inter / JetBrains Mono setup
├── gradle/wrapper/ # auto-provisioned by setup.js or Studio
├── build.gradle.kts # plugins (AGP, Kotlin, Hilt, kapt)
├── settings.gradle.kts # repos + module list
├── gradle.properties # JVM args, parallel build flags
├── setup.js # Node automation (build/install/deploy/logcat)
├── device.ini.example # ADB wireless config template
└── README.md # this file
Local-only ADB device config (git-ignored). Generated by node setup.js adb-setup:
device.ip=192.168.1.100
device.port=5555
sdk.dir=C:\Users\you\AppData\Local\Android\SdkGenerated by node setup.js init. Tells Gradle where the Android SDK is.
Persisted via DataStore (non-sensitive) + EncryptedSharedPreferences (credentials):
| Key | Default | Range / Notes |
|---|---|---|
| Port | 2121 | 1–65535 |
| Username | mobile |
EncryptedSharedPreferences |
| Password | ftp |
EncryptedSharedPreferences |
| Shared directory | shared external storage when granted, else app-private | SAF picker |
| PASV port range | 50000–51000 | both inclusive |
| Max connections | 10 | 1–32 |
| Max connections per IP | 4 | informational |
| Anonymous access | off | requires explicit toggle |
| Chunk count (N) | 8 | 2–32 |
| FTPS (TLS) | off | TLS 1.2+ |
| Symptom | Likely cause / fix |
|---|---|
| "All Files Access required" banner persists after granting | The resume hook re-checks Environment.isExternalStorageManager() on ON_RESUME. Pull the screen down and back up if it lingers. |
| FTP client shows empty folder | Default root is /storage/emulated/0/Android/data/com.mobileftp/files/ftp_root without All Files Access. Grant the permission to expose the real /storage/emulated/0. |
| Server starts then immediately stops | Port already in use. Check node setup.js logcat for FtpServerRepository: FTP server failed to start: Address already in use. Change the port in Configuration. |
| Wireless ADB shows "device offline" | node setup.js reconnect resets the ADB server and re-pairs from device.ini. |
| Gradle build "Couldn't delete R.jar" | Daemon holding a Windows file lock. Run gradlew --stop then build again. |
gradle-wrapper.jar not found |
setup.js build auto-downloads it from the official Gradle GitHub tag. If offline, open the project once in Android Studio. |
| "Multiple Kotlin daemon sessions" warning | Run gradlew --stop once to clean up daemons from prior failed builds. |
Pull requests welcome — please follow these guidelines.
- Fork & clone
git checkout -b feat/your-feature(orfix/your-bug)- Make focused commits using
Conventional Commits:
feat:,fix:,perf:,refactor:,docs:,chore:,test: ./gradlew assembleDebugmust succeed with zero warnings- Test on a physical device (some
TrafficStats-style behaviors don't reproduce on emulators) - Open a PR against
alphafor unstable / beta features,mainfor stable changes;betais reserved for the maintainer's release prep
- Kotlin only — no Java
- Compose only — no XML layouts
- All colors must come from
LocalRaycastColors.current— never hardcode - Inputs/buttons use the existing
RaycastInput/RaycastButtoncomponents - Repositories return
Result<T>for fallible operations - ViewModels expose
StateFlowonly — neverLiveData
- 🌍 i18n — translate
strings.xml - 🧪 Unit tests for
ChunkTransferEnginechunk math + resume logic - 📈 Speed history graph on the Transfers tab
- 🔔 Configurable speed-drop alerts
- 🪟 Tablet-optimized two-pane layout
Released under the MIT License.
MIT License — Copyright (c) 2026 Aditya Bhalsod
Built on the shoulders of:
- Apache FtpServer — the embedded server
- Apache Commons Net — the FTP client protocol stack
- LZ4-Java — the fast lossless compressor
- ZXing — the QR code writer
- Raycast — design inspiration
Made with ❤️ by Aditya · Kotlin · Jetpack Compose · MVVM + Hilt