If you've ever plugged a 256GB Android phone into a Mac and tried to browse files, you know the pain. Android File Transfer hangs. OpenMTP crashes. MacDroid takes five minutes to list /DCIM.
The root cause is MTP. Media Transfer Protocol is a synchronous, single-threaded protocol from the USB-IF spec, designed when phones had 4GB of storage and 200 photos. It was never built for a device with 400,000 files across nested directory trees. Every single folder listing is a blocking round-trip over USB, and the protocol has no concept of batch reads or parallel traversal.
I got tired of it, so I built something that bypasses MTP entirely.
The Idea
What if I could run native code directly on the phone's filesystem, perform a POSIX-level recursive traversal (the same way du or find works on Linux), and pipe the results back to my Mac over a raw TCP socket?
No MTP. No MediaStore queries. No Scoped Storage restrictions. Just opendir(), readdir(), lstat(), streamed over a socket.
That's SocketSweep
VishnuSrivatsava
/
SocketSweep
A high-performance Android storage analyzer that bypasses MTP using a native C++ daemon, a Rust TCP bridge, and a React Treemap UI.
SocketSweep
A high-performance Android storage analyzer built to completely bypass the agonizingly slow USB MTP.
By pushing a custom C++ daemon directly to your Android device via ADB and communicating over a local TCP tunnel, SocketSweep achieves near-instantaneous filesystem traversals and deletions. If you have ever waited minutes just to see the contents of your Android's /sdcard directory over a USB cable, SocketSweep is the ultimate, blazing-fast alternative.
📥 Downloads
Download SocketSweep v1.0.0 for macOS
- 📱 MacOS (.dmg)
- 🪟 Windows (.exe) (Coming Soon)
- 🐧 Linux (.AppImage) (Coming Soon)
🏗 System Architecture
SocketSweep operates across a three-layer stack: The Glass (Frontend), The Bridge (Rust Backend), and The Engine (C++ Android Daemon).
flowchart TB
subgraph Host["Host Desktop"]
UI["React + Recharts<br>Interactive Dashboard"]
Bridge["Rust / Tauri Backend<br>Command Orchestrator"]
UI <--> Bridge
end
subgraph Transport["ADB Protocol"]
Tunnel["ADB Port Forwarding<br>TCP:5050 -> TCP:5050"]
end
subgraph Device["Android Device"]
Daemon["C++17 Daemon<br>Headless Socket Server"]
Storage[("POSIX Filesystem<br>/sdcard")]
Daemon <--> Storage
end…Architecture
Three layers:
┌─────────────────────────────────────────────┐
│ React + Recharts (Treemap Visualization) │ ← The Glass
├─────────────────────────────────────────────┤
│ Rust / Tauri (ADB orchestration, TCP I/O) │ ← The Bridge
├──────────── adb forward tcp:5050 ───────────┤
│ C++17 Daemon (POSIX filesystem walker) │ ← The Engine
│ Running on Android via adb shell │
└─────────────────────────────────────────────┘
The Engine: A Headless C++ Daemon
I wrote a ~350-line C++ program, cross-compiled for aarch64-linux-android using the NDK's Clang toolchain (API level 31). The build script finds your local NDK installation, picks the right host tag, and spits out a statically-linked binary:
$CC -std=c++17 -O2 -Wall -Wextra -Wpedantic \
-fno-exceptions -fno-rtti -DNDEBUG \
-static-libstdc++ \
-o daemon daemon.cpp
No exceptions, no RTTI, statically linked against libc++. The output is about 1.1MB.
The daemon binds to 127.0.0.1:5050 on the device and speaks a dead-simple text protocol:
→ PING\n ← {"status":"ok","message":"pong"}
→ SCAN /sdcard\n ← {"status":"ok","scan_time_ms":...,"tree":{...}}
→ DELETE /path\n ← {"status":"ok","message":"Deleted 42 items"}
→ SHUTDOWN\n ← {"status":"ok","message":"shutting down"}
One command per TCP connection. The daemon accepts, reads one line, does the work, writes the full JSON response, closes the socket, and loops back to accept(). No HTTP. No framing. No content-length headers. Just newline-delimited commands and EOF-delimited responses.
The scanner itself is a straightforward recursive walk using opendir() / readdir() / lstat(). It builds the entire file tree as an in-memory struct, sorts children by size (largest first), then serializes the whole thing as a single JSON blob through a 64KB write buffer to avoid hammering send() with tiny writes:
FileNode scan(const std::string& path, const std::string& name,
ScanStats& st, int depth) {
// ...
DIR* dir = ::opendir(path.c_str());
struct dirent* ent;
while ((ent = ::readdir(dir)) != nullptr) {
// skip . and .., skip symlinks
FileNode child = scan(child_path, ent->d_name, st, depth + 1);
node.size += child.size;
node.children.push_back(std::move(child));
}
::closedir(dir);
// sort children descending by size
return node;
}
Two things worth noting:
- Symlinks are skipped both at the
direntlevel (DT_LNKcheck) and at thelstatlevel, because Android's/sdcardis a FUSE mount with symlinks pointing into/storage/emulated/0, and following them would cause infinite loops. - Depth is capped at 64 to prevent stack overflow on pathological directory structures.
Why shell UID Is the Cheat Code
The critical insight is how the daemon gets filesystem access. When you push a binary to /data/local/tmp and run it via adb shell, it executes under the shell user (UID 2000). On Android, this user has read access to /sdcard — it completely sidesteps the Scoped Storage restrictions that would block a normal Android app from reading arbitrary files without user-granted URI permissions.
To handle Android 11+'s additional MANAGE_EXTERNAL_STORAGE restriction, the Rust bridge runs this before starting the daemon:
adb shell appops set com.android.shell MANAGE_EXTERNAL_STORAGE allow
The Bridge: Rust + Tauri
The desktop side is a Tauri v2 app. Rust handles the full daemon lifecycle:
- Kill any zombie daemon from a previous session (
pkill -f socketsweep_daemon) - Push the binary to the device (
adb push) -
chmod +xit - Launch it with
nohupin the background - Set up the TCP tunnel (
adb forward tcp:5050 tcp:5050) - Ping-retry loop: attempt to connect every 150ms, up to 15 times, until the daemon responds
The TCP communication is simple. Rust opens a connection to 127.0.0.1:5050 (which ADB transparently tunnels to the device), writes a command, and calls read_to_end() to consume the entire response:
fn daemon_command(cmd: &str) -> Result<String, String> {
let mut stream = TcpStream::connect_timeout(
&DAEMON_ADDR.parse().unwrap(),
TCP_CONNECT_TIMEOUT,
)?;
stream.set_read_timeout(Some(TCP_READ_TIMEOUT))?;
stream.write_all(format!("{cmd}\n").as_bytes())?;
let mut response_bytes = Vec::with_capacity(1024 * 1024);
stream.read_to_end(&mut response_bytes)?;
Ok(String::from_utf8_lossy(&response_bytes).trim().to_string())
}
The response is passed as a raw JSON string directly to the React frontend through Tauri's IPC. No intermediate parsing on the Rust side.
On shutdown, the bridge sends SHUTDOWN to the daemon, removes the ADB port forward, and deletes the binary from the device. No trace left.
The Bug That Took the Longest
TCP is a stream protocol. When the daemon was walking a large filesystem and blasting back a multi-megabyte JSON response, there was no guarantee that a single read() call on the Rust side would return the complete payload. Early versions would read a partial buffer, try to hand a truncated JSON string like {"name":"vid_ to the frontend, and everything would break.
The fix was read_to_end() — it keeps reading until the daemon closes its end of the socket (EOF). This works cleanly because of the one-command-per-connection protocol: the daemon processes the command, writes the full response, and the implicit close(client_fd) in the C++ code signals EOF to the Rust reader. No content-length framing needed.
The Frontend
Once the JSON tree arrives in React, I render it as an interactive Recharts Treemap. You can click into directories to zoom, see size proportions visually, and hit a "Nuke" button to delete files directly. The delete command goes back through the same path: React → Tauri IPC → Rust TCP → C++ daemon → std::filesystem::remove_all.
Zero-Dependency Distribution
The final .dmg bundles everything — including the adb binary itself as a Tauri resource. Users don't need Android platform-tools, Node, Rust, or the NDK installed. The Rust code resolves the bundled binary paths at runtime through Tauri's AppHandle:
fn get_bundled_binary(app: &tauri::AppHandle, name: &str) -> Result<PathBuf, String> {
let resource_dir = app.path().resource_dir()?;
let path = resource_dir.join("bin").join(name);
if path.exists() { Ok(path) }
else { Err(format!("Bundled binary '{}' not found", name)) }
}
Every Command::new() call in the codebase points to the bundled binary path. No $PATH lookups.
Try It
The code is up on GitHub. If you want to poke at the cross-compilation setup, the socket protocol, or just see what's eating your phone's storage:
→ github.com/VishnuSrivatsava/SocketSweep
Things I'm still thinking about:
- Thread-pooling the C++ walker for 512GB+ devices where single-threaded traversal gets slow
- Stripping the binary with
aarch64-linux-android-stripto get it under 500KB - Better error recovery if the USB cable gets yanked mid-scan
If you've solved the USB-disconnect-mid-scan problem cleanly, or have thoughts on thread-pooling a recursive POSIX walker, I'd genuinely like to know.

Top comments (0)