DEV Community

Cover image for I got tired of Android MTP hanging, so I bypassed it with a C++ daemon and Rust.
Vishnu Srivatsava
Vishnu Srivatsava

Posted on

I got tired of Android MTP hanging, so I bypassed it with a C++ daemon and Rust.

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

GitHub logo 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 Logo

SocketSweep

A high-performance Android storage analyzer built to completely bypass the agonizingly slow USB MTP.

License: MIT Tauri v2 React 19 C++17 Rust

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


🏗 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           │
└─────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Two things worth noting:

  • Symlinks are skipped both at the dirent level (DT_LNK check) and at the lstat level, because Android's /sdcard is 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
Enter fullscreen mode Exit fullscreen mode

The Bridge: Rust + Tauri

The desktop side is a Tauri v2 app. Rust handles the full daemon lifecycle:

  1. Kill any zombie daemon from a previous session (pkill -f socketsweep_daemon)
  2. Push the binary to the device (adb push)
  3. chmod +x it
  4. Launch it with nohup in the background
  5. Set up the TCP tunnel (adb forward tcp:5050 tcp:5050)
  6. 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())
}
Enter fullscreen mode Exit fullscreen mode

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)) }
}
Enter fullscreen mode Exit fullscreen mode

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-strip to 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)