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 │
└─────────────┘
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`,
]);
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
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' });
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:
- Ctrl+Click → marks a file as Keep (green border + badge)
- 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
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:
- Video thumbnails — use ffmpeg to extract a frame from videos for proper thumbnail previews
- Duplicate detection — hash-based grouping to find identical files
-
Wireless ADB — support
adb connectover the network - Export before delete — pull selected files to a local folder as a backup
- 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
All you need is Node.js, a USB cable, and an Android phone with USB Debugging enabled.
Screenshots
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)