DEV Community

Cover image for I Spent 80 Hours Building a Production-Ready Ad Blocker for Android (Here's How It Works)
Cahyanudien Aziz Saputra
Cahyanudien Aziz Saputra

Posted on

I Spent 80 Hours Building a Production-Ready Ad Blocker for Android (Here's How It Works)

Last week, I went from WakaTime global rank #135 to #2. Not by building some flashy new framework or jumping on the latest AI hype train. I rebuilt Lens Browser's ad blocking system from scratch because the old one sucked. 😤

Let me show you what 78 hours and 49 minutes of focused development looks like. ⚡

💥 The Problem

Lens Browser is a privacy-first mobile browser. Think "open and go"—no accounts, no sync, no tracking. But here's the thing: if you claim to be privacy-first and your ad blocker barely works, you've failed. 😬

My old implementation:

  • ❌ ~30-40% blocking rate
  • ❌ False positives breaking legitimate content
  • ❌ Noticeable performance impact
  • ❌ No user control

That's not acceptable. ❌

🏗️ The New Architecture

Three-Layer Defense System 🛡️

// Pseudo-code showing the decision flow
fun shouldBlockRequest(url: String): Boolean {
    // Layer 1: Check whitelist
    if (whitelistManager.isWhitelisted(url)) return false

    // Layer 2: Check manual blocks
    if (blockedDomainManager.isBlocked(url)) return true

    // Layer 3: Check against 80k rules
    if (adBlockerEngine.matches(url)) return true

    // Layer 4: JS observer handles DOM-level blocking
    return false
}
Enter fullscreen mode Exit fullscreen mode

Layer 1: Connection Interceptor 🔌
Catches requests before they hit the WebView. This is where the magic happens:

override fun shouldInterceptRequest(
    view: WebView?, 
    request: WebResourceRequest?
): WebResourceResponse? {
    val url = request?.url?.toString() ?: return null

    if (shouldBlockRequest(url)) {
        return WebResourceResponse(
            "text/plain", 
            "utf-8", 
            ByteArrayInputStream("".toByteArray())
        )
    }
    return super.shouldInterceptRequest(view, request)
}
Enter fullscreen mode Exit fullscreen mode

Layer 2: JavaScript Sanitizer 🧹
Even if something slips through (dynamic content, inline scripts), the JS layer catches it:

const observer = new MutationObserver(mutations => {
    mutations.forEach(mutation => {
        mutation.addedNodes.forEach(node => {
            if (isAdElement(node)) {
                window.LensBridge.shouldBlock(node.id, result => {
                    if (result) node.remove();
                });
            }
        });
    });
});

observer.observe(document.body, { 
    childList: true, 
    subtree: true 
});
Enter fullscreen mode Exit fullscreen mode

🔍 The 80,000 Rule Problem

You can't iterate through 80,000 rules for every request. That's O(n) per request—death by a thousand cuts. 💀

My Solution:

class AdBlockerEngine {
    private val domainTrie = Trie()
    private val urlPatternMap = HashMap<String, Pattern>()
    private val bloomFilter = BloomFilter(expectedElements = 80000)

    fun matches(url: String): Boolean {
        // Fast negative check
        if (!bloomFilter.mightContain(url)) return false

        // Trie-based domain lookup: O(m) where m = domain length
        val domain = extractDomain(url)
        if (domainTrie.search(domain)) return true

        // Pattern matching for complex rules
        return urlPatternMap.values.any { it.matches(url) }
    }
}
Enter fullscreen mode Exit fullscreen mode

Performance Results: 📊

  • Average request processing: <5ms ⚡
  • Memory footprint: ~30MB for 80k rules 💾
  • False positive rate: <1% ✅

📊 Real-Time Statistics

Users want to see what's being blocked. I built a live counter: 👀

data class BlockedContent(
    val domain: String,
    val url: String,
    val timestamp: Long,
    val type: BlockType
)

object BlockStatistics {
    private val blockedItems = mutableListOf<BlockedContent>()

    fun addBlocked(item: BlockedContent) {
        blockedItems.add(item)
        notifyObservers()
    }

    fun getStats(): Stats {
        return Stats(
            totalBlocked = blockedItems.size,
            uniqueDomains = blockedItems.map { it.domain }.distinct().size,
            blocksByType = blockedItems.groupBy { it.type }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Users can now see:

  • Total blocked count (updates in real-time)
  • List of blocked domains
  • Full URLs that were blocked
  • When they were blocked

🎯 Key Features Shipped

1. Privacy Mode 🔒

Hides User-Agent, screen resolution, timezone, and other fingerprinting vectors:

webView.settings.apply {
    userAgentString = "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36"
    // More obfuscation here
}
Enter fullscreen mode Exit fullscreen mode

2. Trusted Domain Management ✅

Whitelist system because not everything is an ad:

class WhitelistManager {
    fun addDomain(domain: String) {
        whitelist.add(domain.lowercase())
        persistToStorage()
    }

    fun isWhitelisted(url: String): Boolean {
        return whitelist.any { url.contains(it) }
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Security Modal Before Load 🚨

Old way: Load page → detect threat → warn user (too late) ❌
New way: Detect threat → show modal → user decides ✅

override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
    if (threatDetector.isDangerous(url)) {
        showSecurityModal(url, threatLevel)
        view?.stopLoading()
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Blocklist Updates 🔄

Users can refresh the 80k rules without reinstalling:

suspend fun refreshBlocklist() {
    val newRules = api.fetchLatestRules()
    adBlockerEngine.updateRules(newRules)
    notifyUser("Blocklist updated!")
}
Enter fullscreen mode Exit fullscreen mode

🧪 Testing & Results

SuperAdBlockTest.com Results: 📈

  • Before: ~35% blocked ❌
  • After: 65%+ blocked

Real-World Testing: 🌐

  • CNN.com: 47 trackers blocked 🚫
  • Reddit.com: 23 ad domains blocked 🚫
  • Random blog: 15 tracking scripts blocked 🚫

Performance:

  • Page load time: -15% (faster with ads blocked) 🚀
  • Memory usage: +8% (acceptable for 80k rules) 💾
  • Battery impact: Negligible 🔋

💡 Lessons Learned

1. Premature Optimization Is Real 🎯

I spent 2 days building a "perfect" rule matching algorithm. Scrapped it. The current one is 95% as good and shipped in 4 hours.

2. Test on Real Sites 🌍

SuperAdBlockTest is great for benchmarking, but real sites (news, e-commerce, social media) show where your blocker actually fails.

3. Users Want Control 🎛️

The #1 feature request: "Let me whitelist this site." Privacy users want agency, not just defaults.

4. Performance Matters More Than Features ⚡

I could add 50 more features, but if the browser is janky, nobody will use it. Every millisecond counts.

🔮 What's Next

This update proves you can build genuinely private software without VC money or user tracking. But I'm not done: 💪

  • [ ] Custom blocklist imports
  • [ ] Enhanced fingerprint resistance
  • [ ] Per-site settings
  • [ ] Optional tab management
  • [ ] Sync (optional, encrypted, self-hosted)

📲 Try It

Lens Browser is free and will never have ads or tracking. 🙌

📱 Download on Google Play

🧪 Test it on SuperAdBlockTest.com

📊 The Stats

  • WakaTime rank: #135 → #2 globally 🔥
  • Hours coded: 78h 49m ⏱️
  • Daily average: 11h 15m 💪
  • Lines changed: Several thousand 💻
  • Coffee consumed: Yes ☕

👨‍💻 For Other Developers

If you're building privacy tools:

  1. Start with threat models 🎯: What attacks are you preventing?
  2. Measure everything 📊: Can't improve what you don't measure
  3. Ship imperfect code 🚀: Iterate fast, fail fast
  4. Test with real users 🧪: They'll break your assumptions immediately

Building privacy tech is hard. Building usable privacy tech is harder. But it's worth it. 💪


What ad blocker do you use? Drop a comment. 💬👇

P.S. If you're working on similar problems, let's chat. Privacy should be accessible to everyone. 🔒✨

Top comments (0)