DEV Community

Cover image for Building Phone Cleaner: A Desktop App to Manage WhatsApp Media via ADB
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on

Building Phone Cleaner: A Desktop App to Manage WhatsApp Media via ADB

WhatsApp groups are a black hole of phone storage. Family chats, office groups, neighborhood watch — everyone sends photos and videos, and they pile up silently in your phone's hidden WhatsApp directories. Before you know it, you've got 5,000+ files eating up 15 GB of space, and there's no good way to triage them on the phone itself.

Scrolling through a grid of thumbnails on a 6-inch screen is painful. So I built Phone Cleaner — a desktop Electron app that connects to your Android phone via ADB, scans your WhatsApp media, and lets you review everything on your Mac in a proper grid with filtering, sorting, and one-click deletion.

This post walks through the architecture, the key engineering decisions, and the lessons learned.


The Architecture

The app has three layers:

┌─────────────────────────────────────┐
│        Renderer (Chromium)          │
│   Vanilla JS + CSS + Intersection   │
│   Observer for lazy thumbnails      │
├─────────────────────────────────────┤
│     IPC Bridge (preload.js)         │
│     contextBridge for security      │
├─────────────────────────────────────┤
│      Main Process (Node.js)         │
│   ADB subprocess, file cache,       │
│   persistent history (JSON)         │
└──────────────────┬──────────────────┘
                   │ adb (USB)
            ┌──────┴──────┐
            │   Phone     │
            │  Android    │
            └─────────────┘
Enter fullscreen mode Exit fullscreen mode

The renderer is vanilla HTML/CSS/JS — no framework. The main process runs Node.js and spawns ADB subprocesses. Communication happens through Electron's IPC with contextIsolation: true for security.

Why Electron?

I chose Electron over Tauri for one reason: ADB is already a CLI tool. Electron's Node.js main process can call execFile on adb just like any other system command. Tauri would've required wrapping ADB in Rust bindings, which was overkill for a utility app.

The trade-off is bundle size (~200MB with Electron), but for a developer tool that runs on a Mac with a phone connected via USB, that's acceptable.

Scanning WhatsApp Media

The most interesting engineering challenge was scanning. WhatsApp stores media at one of two paths depending on Android version:

  • Android 10 and below: /sdcard/WhatsApp/Media/WhatsApp Images/
  • Android 11+ (scoped storage): /sdcard/Android/media/com.whatsapp/WhatsApp/Media/WhatsApp Images/

My app probes both paths, then for each directory runs a single ls -laR (recursive list) via ADB. This is a single round-trip per top-level directory, regardless of how many files are inside:

const out = await adb(["-s", deviceId, "shell",
  `ls -laR "${dir}" 2>/dev/null || true`,
]);
Enter fullscreen mode Exit fullscreen mode

The output looks like this:

/sdcard/WhatsApp/Media/WhatsApp Images:
-rw-rw---- 1 u0_a147 media_rw 123456 2024-01-15 10:30 IMG-20240115-WA0001.jpg
-rw-rw---- 1 u0_a147 media_rw 654321 2024-01-14 09:20 IMG-20240114-WA0002.jpg

/sdcard/WhatsApp/Media/WhatsApp Images/Sent:
-rw-rw---- 1 u0_a147 media_rw 98765 2024-01-13 08:00 IMG-20240113-WA0003.jpg
Enter fullscreen mode Exit fullscreen mode

Parsing this is straightforward: lines starting with / are directory headers, lines starting with total are summaries, and everything else is a file entry. The parseLsLine() function handles two date formats (modern YYYY-MM-DD HH:MM and legacy Mon DD YYYY) since Android's toybox ls varies across versions.

I initially tried using find + per-file stat calls, but that meant one ADB round-trip per file — completely unusable at scale. With ls -laR, it's just 2-4 ADB calls total, regardless of file count.

Lazy-Loaded Thumbnails

Pulling 1,000+ images from a phone over USB is slow. Pulling them all upfront would make the UI unusable. The solution: IntersectionObserver with a 300px root margin.

Only images that scroll into view (or are about to) get pulled from the phone:

const thumbObs = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    loadThumb(entry.target);
    thumbObs.unobserve(entry.target);
  }
}, { rootMargin: '300px' });
Enter fullscreen mode Exit fullscreen mode

When a thumbnail is needed, the app runs adb pull to copy the file to a local cache directory, then sets it as the <img> source. Subsequent visits load instantly from the cache.

Video files never pull a thumbnail — they render a play-button icon over a dark placeholder. Full video files are only pulled on double-click (preview).

The Keep/Delete Workflow

A key UX insight: users don't know what they want to delete until they know what they want to keep. So the app has two distinct actions:

  1. Ctrl+Click → marks a file as Keep (green border + badge)
  2. Regular Click → selects a file for Deletion (red border)

This lets users quickly protect important files, then invert the selection to delete everything else.

Deletion happens via adb shell rm -f — one command per batch. I tried batching all paths into one rm call, but shell argument length limits on Android can be an issue with 1000+ files, so there's a per-file fallback.

Delete History

Every deletion session is logged to a local JSON file at:

~/Library/Application Support/phone-cleaner/delete-history.json
Enter fullscreen mode Exit fullscreen mode

Each entry tracks:

  • Timestamp
  • Number of files deleted
  • Total bytes recovered
  • Image/video breakdown

This gives users a sense of accomplishment and a record of what was cleaned.

Design Decisions

No Build Step

The renderer is vanilla JS with no framework, bundler, or transpiler. This keeps the dev loop instant: edit file → reload Electron. No webpack, no vite, no babel.

No Semicolons

Personal preference. The codebase is consistent — no semicolons, 2-space indent, single quotes for strings.

Dark Theme Only

A utility tool for developers and power users. Dark mode reduces eye strain during long scanning sessions. A theme toggle would be a good first PR.

Possible Improvements

If you want to contribute, here's what's next:

  1. Video thumbnails — use ffmpeg to extract a frame from videos for proper thumbnail previews
  2. Duplicate detection — hash-based grouping to find identical files
  3. Wireless ADB — support adb connect over the network
  4. Export before delete — pull selected files to a local folder as a backup
  5. Undo/trash — soft-delete to a trash directory instead of permanent removal

Try It

The complete source code is on GitHub: github.com/harishkotra/phone-cleaner

git clone https://github.com/harishkotra/phone-cleaner.git
cd phone-cleaner
npm install
npm start
Enter fullscreen mode Exit fullscreen mode

All you need is Node.js, a USB cable, and an Android phone with USB Debugging enabled.

Screenshots

Phone Cleaner 1

Phone Cleaner 2

Phone Cleaner 3

Phone Cleaner 4

Code & more: https://www.dailybuild.xyz/project/178-phone-cleaner

Built with OpenCode

Consumed Context: 136,163 tokens (68% used)
Total Spent: $0.00 spent
_Model: _ DeepSeek V4 Flash

Top comments (0)