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:
PIN Authentication: Secure access to the browser.
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.
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) } } }
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
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 providedview
(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:
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.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.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.Embrace JavaScript Injection (Carefully):
evaluateJavascript
andJavascriptInterface
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.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.
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.
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!
Subscribe to my newsletter
Read articles from Chiranjeevi Pandey directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
