Lessons Learned from Creating an Android Browser: A Deeper Dive

Hey everyone! It’s been a minute since I’ve shared a story from the trenches, but a recent project felt like the perfect opportunity. If you've been in Android development for a while, you know it's a field that never stops evolving. New tools, APIs, architectural patterns – there's always something to learn. But sometimes, the most profound lessons come not from the shiny new things, but from tackling seemingly simple requirements that turn out to be surprisingly complex.

Today, I want to talk about building a custom Android browser app. Sounds straightforward, right? Slap a WebView in a layout, add a URL bar, maybe some back/forward buttons. Job done? Well, not quite. This particular project had a couple of unique twists – PIN-based authentication and, the kicker, making the WebView vibrate on a long press.

This post isn't a technical tutorial (though we'll dive into some code and challenges). It's also a reflection on the problem-solving process, the unexpected hurdles you hit even on familiar ground, and how navigating those challenges shapes you as a developer. Whether you're just starting your Android journey or you're a seasoned pro, I hope sharing my recent experience offers some valuable insights and maybe even sparks a few "aha!" moments.

Android Development in 2025: A Quick Snapshot

Before we dive into the browser saga, let's quickly set the stage. Android development today is arguably more exciting and productive than ever.

  • Kotlin First: Kotlin is the undisputed king. Its conciseness, null safety, and coroutines have fundamentally changed how we write Android apps for the better.

  • Jetpack Compose: Declarative UI is here and maturing rapidly. While XML layouts aren't going away overnight, Compose offers a powerful, reactive way to build UIs, often with less code and more flexibility. As you'll see, I jumped into this project using Compose.

  • Architecture Matters: Patterns like MVVM (Model-View-ViewModel) and MVI (Model-View-Intent), often coupled with Dependency Injection frameworks like Hilt or Koin, are standard practice for building scalable and maintainable apps.

  • Key Libraries: The Jetpack suite continues to expand, offering solutions for navigation, data persistence (Room), background processing (WorkManager), and much more. Libraries like Retrofit (networking) and Coil (image loading) remain essential tools.

  • AI Assistance: Tools like Claude and ChatGPT are increasingly part of the developer workflow, helping with boilerplate, suggesting solutions, and sometimes, as I experienced, being a helpful rubber duck during debugging.

It's a dynamic ecosystem, but the fundamentals of good software engineering – clean code, solid architecture, thorough testing, and persistent problem-solving – remain constant.

The "Vibrating Browser" Project: A Journey Begins

So, the client needed a simple browser, but with two key custom features:

  1. PIN Authentication: Secure access to the browser.

  2. Long-Press Vibration: The web content itself should trigger vibration when long-pressed. Don’t ask why.

I decided to build it using Jetpack Compose, embracing the modern toolkit.

Step 1: The PIN Screen - A Quick Security Decision

First up was the PIN screen. This was relatively standard Compose UI work. The interesting part was deciding how to store the PIN for verification. My initial thought, perhaps influenced by some AI suggestions, leaned towards EncryptedSharedPreferences. It sounds secure, right?

But then I paused. EncryptedSharedPreferences is great for storing sensitive data you need to retrieve later in its original form. For PIN verification, however, you don't need the original PIN; you just need to check if the entered PIN matches the stored one. Decrypting the stored PIN just to compare it felt unnecessary and potentially less secure than the standard practice:

  • Hashing: Store a salted hash (e.g., using SHA-256) of the user's chosen PIN. When the user enters their PIN again, hash the input using the same salt and compare the hashes.

This approach avoids storing the actual PIN (even encrypted) and is the standard way to handle password/PIN verification. It felt like a more robust and elegant solution, side-stepping potential issues with key management or deprecated Encrypted Shared Preferences APIs. Lesson learned: always question the first suggestion, even from an AI, and think about the actual requirement. Sometimes the classic, simpler approach is the best.

Step 2: The WebView - Entering Murky Waters

With the PIN screen done, I moved to the core browser functionality. Naturally, WebView is the component for displaying web content. Since I was using Compose, I reached for a community library: compose-webview by KevinnZou. It provides a nice Composable wrapper around the Android WebView.

My initial thought for the vibration feature was: "This should be easy. I'll just intercept touch events on the WebView Composable." Famous last words.

Challenge 1: The Long Press Conundrum

The compose-webview library takes a WebView instance via a factory lambda. This meant I could extend the standard Android WebView class and pass my custom version in. My plan was to override onTouchEvent or use setOnTouchListener to detect ACTION_DOWN (start timer for long press, maybe start vibrating tentatively) and ACTION_UP (stop vibration).

Here's where the trouble started. I wired up my setOnTouchListener:

private val touchListener = OnTouchListener { _, event ->
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                Log.d(PrivateVibratingWebView::class.simpleName, "ACTION_DOWN")
                startLongPressTimer(1000)
                super.onTouchEvent(event)
            }

            MotionEvent.ACTION_UP -> {
                Log.d(PrivateVibratingWebView::class.simpleName, "ACTION_UP")
                stopLongPress()
                super.onTouchEvent(event)
            }

            MotionEvent.ACTION_CANCEL -> {
                Log.d(PrivateVibratingWebView::class.simpleName, "ACTION_CANCEL")
                stopLongPress()
                super.onTouchEvent(event)
            }

            else -> {
                Log.d(PrivateVibratingWebView::class.simpleName, event.action.toString())
                super.onTouchEvent(event)
            }
        }
    }

When I tapped or scrolled, I saw ACTION_DOWN and ACTION_UP. But when I long-pressed on a link or image, something frustrating happened. The WebView's default long-press behavior kicked in (trying to show the context menu for opening in a new tab, saving image, etc.). This interaction caused the WebView to consume the event stream, and instead of getting an ACTION_UP when I lifted my finger, my listener often received an ACTION_CANCEL almost immediately after the long press was detected internally by the WebView. This meant my vibration wouldn't stop correctly!

After much hair-pulling, searching Stack Overflow, and debating with my AI coding buddy, I stumbled upon the crucial piece: setOnLongClickListener.

The default WebView behavior was hijacking the long press. The solution? Explicitly capture the long press myself and tell the WebView not to do anything with it.

private fun updateFocusAndTouchSettings() {
        isFocusable = true
        isFocusableInTouchMode = true
        setOnTouchListener(touchListener)
        setOnLongClickListener {
            Log.d("WebViewTouch", "onLongClick consumed!")
            // We handle the long click purely via ACTION_DOWN/UP in onTouchEvent now.
            // By returning true, we tell the WebView "I've handled this long press,
            // don't show your context menu or do anything else."
            true    
        }
}

// The setOnTouchListener remains similar, but now ACTION_UP
// reliably fires after a long press, because the default
// long-click handler that caused ACTION_CANCEL was suppressed.

By overriding setOnLongClickListener and simply returning true, I effectively swallowed the long press event before the WebView could trigger its default context menu behavior. This prevented the dreaded ACTION_CANCEL and allowed my ACTION_DOWN / ACTION_UP logic in setOnTouchListener to work as intended. The vibration started on ACTION_DOWN and reliably stopped on ACTION_UP. Success!

Personal Reflection: This felt like a classic Android development problem. You're dealing with a complex view (WebView) with its own internal event handling, and you need to carefully intercept and modify that behavior without breaking everything else. It requires understanding the event propagation chain and sometimes finding right APIs (setOnLongClickListener in this case) to achieve your goal.

Challenge 2: The Pull-to-Refresh Puzzle

Next, the client requested pull-to-refresh functionality. In Compose, this is typically done using PullToRefreshBox combined with a scrollable container. My initial thought was to wrap the WebView Composable within the scrollable Column which would again be wrapped by a PullToRefreshBox as shown in pull to refresh sample.

Disaster struck again. As noted in an issue on the compose-webview library's GitHub (#10), WebView needs a fixed or constrained height to render correctly, especially within complex layouts. Pull-to-refresh mechanisms usually require their scrollable child (in this case, the container holding the WebView) to be able to take up all available space vertically to detect the pull gesture correctly. These two requirements clashed.

If I let the WebView container try to fill the available height (as pull-to-refresh wanted), the WebView itself wouldn't render its content correctly – some sites would break, appear blank, or have strange scrolling issues (I noticed this with fast.com and even google.com). If I gave the WebView a fixed height, pull-to-refresh wouldn't work because the container wasn't scrollable in the way the modifier expected.

After spending a couple of days trying various layout combinations (Column with verticalScroll, Box with specific constraints, nesting modifiers), I had to make a pragmatic decision. Pull-to-refresh, while nice, wasn't a core requirement. The complexity it introduced due to the WebView's layout sensitivity wasn't worth the development time.

The Compromise: I ditched pull-to-refresh and implemented a simple refresh IconButton in the app bar. Sometimes, you have to recognize technical limitations or integration complexities and opt for a simpler, reliable solution. It's not giving up; it's making a strategic choice to deliver a functional product.

Challenge 3: Taming YouTube with JavaScript

The browser worked, the vibration worked... until I tested YouTube. When I long-pressed on the video player area, YouTube's own web interface intercepted the long press to activate a 2x playback speed toggle! My vibration logic was bypassed again, similar to the context menu issue, but this time it was client-side JavaScript on the webpage itself causing the conflict.

How do you fight JavaScript with native code? You fight JavaScript with JavaScript!

WebView allows you to inject and execute JavaScript on the loaded page using webView.evaluateJavascript(). My plan was to find the specific HTML element or JavaScript listener responsible for YouTube's long-press behavior and disable or hide it.

Using the browser developer tools (inspecting YouTube on a desktop browser), I identified the div that captured the input. Then, I wrote a small JavaScript snippet to hide it:

// This finds an element with a specific class potentially related to player controls
// and hides it. You'd need to inspect YouTube's DOM to find the right selector.
// NOTE: This is brittle and might break if YouTube changes its structure.
(function() {
    // Hide immediately if element exists
    const hideElement = () => {
    const elem = document.querySelector('.player-controls-background');
        if (elem) {
            elem.style.display = 'none'; // or 'visibility: hidden'
            console.log('Hid player-controls-background');
        }
    };

    // Run on initial load
    hideElement();
})();

I executed this using evaluateJavascript after the page finished loading (in onPageFinished callback of WebViewClient).

// Inside WebViewClient's onPageFinished
override fun onPageFinished(view: WebView?, url: String?) {
    super.onPageFinished(view, url)
    if (url != null && url.contains("youtube.com")) {
        val script = // ... JavaScript string from above ...
        view?.evaluateJavascript(script, null)
    }
}

It worked! Injecting that JavaScript hid the conflicting element, preventing YouTube from hijacking the long press and allowing my native vibration logic to function correctly again.

Bonus YouTube Challenge: While testing YouTube, I noticed another common WebView issue with Single Page Applications (SPAs). When navigating within YouTube (clicking related videos, etc.), the URL in my app's address bar wasn't updating because the page wasn't performing traditional full reloads. The solution, once again, was JavaScript. Modern browsers have navigation APIs. I used a JavaScript Interface to bridge communication between the webpage's JavaScript and my native Kotlin code.

  1. Create a JavaScript Interface: A simple Kotlin class with methods annotated with @JavascriptInterface.

     class WebAppInterface(private val onUrlChange: (String) -> Unit) {
         @JavascriptInterface
         fun notifyUrlChange(url: String) {
             // Run on main thread as it might update UI
             Handler(Looper.getMainLooper()).post {
                 onUrlChange(url)
             }
         }
     }
    
  2. Add it to WebView:

     // In WebView setup
     settings.javaScriptEnabled = true // Essential!
     val jsInterface = WebAppInterface { newUrl ->
         // Update your address bar State<String> here
         currentUrlState.value = newUrl
     }
     addJavascriptInterface(jsInterface, "AndroidInterface") // "AndroidInterface" is how JS calls it
    
  3. Inject JavaScript to Listen for Navigation: Use window.navigation.addEventListener (a modern browser API) to detect SPA navigation and call back to the native interface.

     // JavaScript injected after page load (e.g., in onPageFinished)
     (function() {
         window.navigation.addEventListener("navigate", (event) => {
             console.log('Location changed to:', event.destination.url);
             AndroidInterface.onUrlChange(event.destination.url);
         });
     })();
    

This allowed the YouTube SPA to inform my app whenever the "URL" changed internally, keeping my address bar accurate.

Personal Reflection: This part of the project was a powerful reminder that WebView isn't just a passive display box. It's an integration point. Leveraging JavaScript injection and interfaces opens up huge possibilities for customizing behavior and bridging the gap between web content and native code, but it requires vigilance as webpage structures can change.

Challenge 4: Fullscreen Video (and More Vibration!)

The final hurdle was YouTube's fullscreen video mode. When a user taps the fullscreen button in the YouTube player, WebView doesn't automatically handle it. You need to use WebChromeClient.

The WebChromeClient has two key callbacks:

  • onShowCustomView(view: View, callback: CustomViewCallback): Called when the web content wants to enter fullscreen. You need to take the provided view (the video surface), hide your normal app UI, and add this view to your activity's window.

  • onHideCustomView(): Called when the web content exits fullscreen. You need to remove the custom view and show your app UI again.

This involved getting access to the current Activity's Window and DecorView (using LocalActivity.current?.window?.decorView in Compose) to add/remove the fullscreen view.

override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
        if (fullScreenVideoView != null) {
            onHideCustomView()
        }

        fullScreenVideoView = view
        fullScreenCallback = callback
        onMadeFullScreen(true)

        activity?.window?.decorView?.let { decorView ->
            val rootView = decorView.findViewById(R.id.content) as? ViewGroup
            rootView?.addView(
                fullScreenVideoView,
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT
            )
            activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE

            val childView = (fullScreenVideoView as? ViewGroup)?.getChildAt(0)
            childView?.isFocusable = true
            childView?.isFocusableInTouchMode = true
            childView?.setOnTouchListener(touchListener)
            childView?.setOnLongClickListener { true }
        }
    }

    override fun onHideCustomView() {
        fullScreenVideoView?.let { view ->
            activity?.window?.decorView?.let { decorView ->
                val rootView = decorView.findViewById(R.id.content) as? ViewGroup
                rootView?.removeView(view)
                activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
            }
        }
        coroutineScope.cancel()
        onMadeFullScreen(false)
        fullScreenVideoView = null
        fullScreenCallback?.onCustomViewHidden()
        fullScreenCallback = null
    }

And, of course, the client wanted the long-press vibration to work in fullscreen mode too. This meant that the touch handling logic (the combination of setOnTouchListener and setOnLongClickListener) needed to be applied not just to the main WebView, but potentially to the View provided in onShowCustomView as well, or ensuring the container holding this view intercepted touches appropriately. It required careful reuse and adaptation of the logic developed earlier.

Personal Reflection: Implementing fullscreen required digging into the Activity lifecycle and view hierarchy management, stepping slightly outside the pure Compose world. It highlighted how even with modern frameworks, you sometimes need to interact with the underlying Android system views and lifecycles.

Lessons Learned & Practical Advice

This seemingly simple browser project packed a surprising number of lessons:

  1. Master the Basics: Even with fancy libraries and Compose, understanding Android fundamentals like View touch event propagation (onTouchEvent, onInterceptTouchEvent, listeners), WebView APIs (WebViewClient, WebChromeClient, JavascriptInterface), and basic security practices (hashing passwords) is crucial.

  2. WebView is Deceivingly Complex: It's powerful, but integrating it smoothly, especially with custom interactions or modern UI frameworks like Compose, often requires workarounds, deep dives into its settings, and sometimes JavaScript injection. Be prepared for unexpected layout issues and event conflicts.

  3. Layout Sensitivity: How you embed WebView in your layout hierarchy matters a lot. Issues with height constraints, scrolling containers (like pull-to-refresh), and rendering glitches on specific websites are common. Test thoroughly.

  4. Embrace JavaScript Injection (Carefully): evaluateJavascript and JavascriptInterface are powerful tools for taming unruly web content or enabling two-way communication. Use them wisely, be aware that website changes can break your injected code, and always validate/sanitize data passed across the bridge.

  5. Problem-Solving is the Core Skill: Whether using AI assistance, Stack Overflow, library documentation, or just plain trial-and-error, the ability to diagnose issues, research potential solutions, and persistently try different approaches is key.

  6. Know When to Compromise: Not every feature is worth implementing if it introduces excessive complexity or instability. Cutting pull-to-refresh was a pragmatic choice that kept the project on track. Focus on delivering the core value reliably.

  7. Compose and Views Interop: While Compose is fantastic, you'll often need to interact with the traditional View system, especially when using components like WebView or dealing with system-level features like fullscreen. Understanding how they interoperate is essential.

Wrapping Up

Building this custom browser was a fantastic reminder that even after several years in Android development, there are always new challenges and nuances to discover, even within familiar components like WebView. What started as a "simple" app turned into a deep dive into touch events, layout constraints, JavaScript bridging, and the intricacies of WebChromeClient.

The journey was sometimes frustrating, involving dead ends and workarounds, but ultimately rewarding. It reinforced the importance of persistence, understanding the underlying mechanics, and sometimes making pragmatic decisions.

I hope sharing this story – the technical hurdles and the personal reflections – provides some useful insights for your own Android adventures.

What about you? Have you encountered similar surprising challenges with WebView or other standard components? What tricky problems have you solved recently? Share your experiences in the comments below – I'd love to hear them!

0
Subscribe to my newsletter

Read articles from Chiranjeevi Pandey directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Chiranjeevi Pandey
Chiranjeevi Pandey