21 KiB
Android SharedWebView with Image Interception Implementation
Objective
Implement an Android equivalent of the iOS SharedWebView module with advanced image interception capabilities using WebView hooks instead of custom URL schemes. This will provide feature parity between iOS and Android platforms while leveraging Android's superior request interception capabilities.
Context
The iOS implementation uses a custom URL scheme (follow-image://) to intercept image requests due to WKWebView limitations. Android WebView offers more powerful request interception through WebViewClient.shouldInterceptRequest(), allowing for a more elegant and performant solution.
Current iOS Architecture (Reference)
- SharedWebViewModule (apps/mobile/native/ios/Modules/SharedWebView/SharedWebViewModule.swift)
- WebViewManager (apps/mobile/native/ios/Modules/SharedWebView/WebViewManager.swift) - Singleton with lifecycle management
- FOWebView (apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift) - Custom WKWebView
- FollowImageURLSchemeHandler (apps/mobile/native/ios/Modules/SharedWebView/FollowImageURLSchemeHandler.swift) - Custom scheme handler
- WebViewState (apps/mobile/native/ios/Modules/SharedWebView/WebViewState.swift) - Observable state management
Existing Android Infrastructure
- Module Path:
apps/mobile/native/android/src/main/java/expo/modules/follownative/ - Build Configuration: Standard Expo modules with Kotlin support
- Naming Convention:
expo.modules.follownative.modulename.ModuleName - TypeScript Interface: Already defined in
apps/mobile/src/components/native/webview/index.ts
Implementation Requirements
Core Components to Implement
- SharedWebViewModule.kt - Main Expo module exposing API to React Native
- WebViewManager.kt - Singleton manager for WebView lifecycle and state
- SharedWebViewView.kt - Expo view component
- FOWebView.kt - Custom WebView with image interception
- ImageInterceptClient.kt - WebViewClient with request interception
- ImageCache.kt - Memory and disk caching system
- WebViewState.kt - Reactive state management with StateFlow
- BridgeData.kt - Message payloads and type definitions
API Compatibility Requirements
Maintain 100% compatibility with existing TypeScript interface:
interface ISharedWebViewModule
extends NativeModule<{
onContentHeightChanged: ({ height }: { height: number }) => void
onImagePreview: (event: ImagePreviewEvent) => void
onSeekAudio?: (e: { time: number }) => void
}> {
load(url: string): void
evaluateJavaScript(js: string): void
dispatch?(type: string, payload?: string): void
// Debug helpers
getDebugState?(): WebViewDebugState
destroyForDebug?(): void
reloadLastURL?(): void
flushQueue?(): void
}
Technical Architecture
Image Interception Strategy (Android Advantage)
iOS Approach: Custom URL scheme with JavaScript injection
// JavaScript injection to rewrite image URLs
url.replace(/^https?:/, 'follow-image:')
// Custom WKURLSchemeHandler for follow-image://
Android Approach: Native request interception (Superior)
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
if (isImageRequest(request)) {
return handleImageRequest(request)
}
return super.shouldInterceptRequest(view, request)
}
Implementation Blueprint
Phase 1: Core Infrastructure (Priority 1)
// 1. SharedWebViewModule.kt
@ExpoModule(name = "FOSharedWebView")
class SharedWebViewModule : Module() {
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
override fun definition() = ModuleDefinition {
Name("FOSharedWebView")
Function("load") { urlString: String ->
WebViewManager.load(urlString)
}
Function("evaluateJavaScript") { js: String ->
WebViewManager.evaluateJavaScript(js)
}
Function("dispatch") { type: String, payload: String? ->
WebViewManager.dispatch(type, payload)
}
View(SharedWebViewView::class) {
Events("onContentHeightChange", "onSeekAudio")
Prop("url") { view: SharedWebViewView, url: String ->
WebViewManager.load(url)
}
}
Events("onContentHeightChanged", "onImagePreview", "onSeekAudio")
OnCreate {
WebViewManager.initializeLifecycleObservers(appContext.currentActivity!!)
}
OnStartObserving {
// StateFlow subscriptions for reactive events
WebViewManager.state.contentHeight.onEach { height ->
sendEvent("onContentHeightChanged", mapOf("height" to height))
}.launchIn(coroutineScope)
}
OnStopObserving {
coroutineScope.cancel()
}
// Debug functions
Function("getDebugState") { WebViewManager.getDebugState() }
Function("destroyForDebug") { WebViewManager.destroyForDebug() }
Function("reloadLastURL") { WebViewManager.reloadLastURL() }
Function("flushQueue") { WebViewManager.flushQueue() }
}
}
// 2. WebViewManager.kt - Singleton with lifecycle management
object WebViewManager {
val state = WebViewState()
private var sharedWebView: FOWebView? = null
private var currentHost: ViewGroup? = null
private var isReady = false
private val pendingScripts = mutableListOf<String>()
private var lastUrl: String? = null
private val lastState = mutableMapOf<String, Any>()
fun initializeLifecycleObservers(activity: Activity) {
val observer = object : DefaultLifecycleObserver {
override fun onPause(owner: LifecycleOwner) { onEnterBackground() }
override fun onResume(owner: LifecycleOwner) { onEnterForeground() }
}
(activity as ComponentActivity).lifecycle.addObserver(observer)
}
fun load(urlString: String) {
MainScope().launch {
lastUrl = urlString
val webView = getOrCreateWebView()
if (webView.url == urlString && !isLoading(webView)) return@launch
isReady = false
webView.loadUrl(urlString)
}
}
fun attach(host: ViewGroup) {
MainScope().launch {
val webView = getOrCreateWebView()
(webView.parent as? ViewGroup)?.removeView(webView)
host.addView(webView)
currentHost = host
}
}
private fun getOrCreateWebView(): FOWebView {
return sharedWebView ?: FOWebView(context, state).also { sharedWebView = it }
}
private fun onEnterBackground() {
if (currentHost == null) {
MainScope().launch {
delay(3000)
if (currentHost == null) destroyWebView()
}
}
}
private fun onEnterForeground() {
if (sharedWebView == null) {
getOrCreateWebView()
lastUrl?.let { load(it) }
replayState()
}
}
}
Phase 2: Image Interception System (Priority 1)
// 3. ImageInterceptClient.kt - Advanced request interception
class ImageInterceptClient(
private val imageCache: ImageCache,
private val baseClient: WebViewClient? = null
) : WebViewClient() {
companion object {
private val IMAGE_MIME_TYPES = setOf(
"image/jpeg", "image/jpg", "image/png", "image/gif",
"image/webp", "image/svg+xml", "image/avif"
)
}
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? {
val url = request?.url?.toString() ?: return super.shouldInterceptRequest(view, request)
if (!isImageRequest(request)) {
return baseClient?.shouldInterceptRequest(view, request)
?: super.shouldInterceptRequest(view, request)
}
return handleImageRequest(view, request)
}
private fun isImageRequest(request: WebResourceRequest): Boolean {
val url = request.url.toString()
val acceptHeader = request.requestHeaders["Accept"] ?: ""
// Multi-strategy detection
return acceptHeader.contains("image/") ||
url.substringAfterLast('.', "").lowercase() in IMAGE_EXTENSIONS ||
url.contains(Regex("/(images?|img|pics?|photos?|assets)/", RegexOption.IGNORE_CASE))
}
private fun handleImageRequest(
view: WebView?,
request: WebResourceRequest
): WebResourceResponse? {
val url = request.url.toString()
val cacheKey = url
// Check cache first
imageCache.get(cacheKey)?.let { cachedData ->
return createImageResponse(cachedData, detectMimeType(cachedData))
}
// Network request with proper headers
return try {
val modifiedRequest = buildImageRequest(request)
val response = executeImageRequest(modifiedRequest)
response?.let { (data, mimeType) ->
imageCache.put(cacheKey, data)
createImageResponse(data, mimeType)
}
} catch (e: Exception) {
Log.w("ImageIntercept", "Failed to load image: $url", e)
null // Fallback to default WebView behavior
}
}
private fun buildImageRequest(original: WebResourceRequest): HttpURLConnection {
val connection = URL(original.url.toString()).openConnection() as HttpURLConnection
// Copy original headers
original.requestHeaders.forEach { (key, value) ->
if (!isRestrictedHeader(key)) {
connection.setRequestProperty(key, value)
}
}
// Optimize for images
connection.setRequestProperty("Accept", "image/webp,image/avif,image/*,*/*;q=0.8")
connection.setRequestProperty("User-Agent",
"Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 Mobile Safari/537.36")
// Set referer for CORS compatibility
original.url.let { url ->
val referer = "${url.scheme}://${url.host}"
connection.setRequestProperty("Referer", referer)
}
connection.connectTimeout = 10000
connection.readTimeout = 15000
return connection
}
private fun createImageResponse(data: ByteArray, mimeType: String): WebResourceResponse {
val headers = mapOf(
"Access-Control-Allow-Origin" to "*",
"Cache-Control" to "public, max-age=3600",
"Content-Length" to data.size.toString()
)
return WebResourceResponse(mimeType, "utf-8", 200, "OK", headers, ByteArrayInputStream(data))
}
}
// 4. ImageCache.kt - High-performance caching system
class ImageCache(private val context: Context) {
private val memoryCache = LruCache<String, ByteArray>(16 * 1024 * 1024) // 16MB
private val diskCacheDir = File(context.cacheDir, "webview_images")
init {
if (!diskCacheDir.exists()) diskCacheDir.mkdirs()
}
fun get(key: String): ByteArray? {
// Memory first, then disk
return memoryCache.get(key) ?: getDiskCache(key)?.also {
memoryCache.put(key, it)
}
}
fun put(key: String, data: ByteArray) {
memoryCache.put(key, data)
putDiskCache(key, data)
}
private fun getDiskCache(key: String): ByteArray? = try {
val file = getCacheFile(key)
if (file.exists() && System.currentTimeMillis() - file.lastModified() < 24 * 60 * 60 * 1000) {
file.readBytes()
} else null
} catch (e: Exception) { null }
private fun putDiskCache(key: String, data: ByteArray) {
try {
getCacheFile(key).writeBytes(data)
} catch (e: Exception) {
Log.w("ImageCache", "Failed to cache image", e)
}
}
private fun getCacheFile(key: String): File {
val fileName = key.hashCode().toString(16) + ".cache"
return File(diskCacheDir, fileName)
}
}
Phase 3: State Management and Views (Priority 2)
// 5. WebViewState.kt - Reactive state management
class WebViewState {
private val _contentHeight = MutableStateFlow(Resources.getSystem().displayMetrics.heightPixels.toFloat())
val contentHeight: StateFlow<Float> = _contentHeight.asStateFlow()
private val _imagePreviewEvent = MutableStateFlow<ImagePreviewEvent?>(null)
val imagePreviewEvent: StateFlow<ImagePreviewEvent?> = _imagePreviewEvent.asStateFlow()
private val _audioSeekEvent = MutableStateFlow<AudioSeekEvent?>(null)
val audioSeekEvent: StateFlow<AudioSeekEvent?> = _audioSeekEvent.asStateFlow()
fun updateContentHeight(height: Float) { _contentHeight.value = height }
fun triggerImagePreview(urls: List<String>, index: Int) {
_imagePreviewEvent.value = ImagePreviewEvent(urls, index)
}
fun triggerAudioSeek(time: Double) {
_audioSeekEvent.value = AudioSeekEvent(time)
}
}
data class ImagePreviewEvent(val imageUrls: List<String>, val index: Int)
data class AudioSeekEvent(val time: Double)
// 6. FOWebView.kt - Custom WebView implementation
class FOWebView(context: Context, private val state: WebViewState) : WebView(context) {
private val imageCache = ImageCache(context)
private val baseWebViewClient = FOWebViewClient()
private val javascriptInterface = JavaScriptInterface(state)
init {
setupWebView()
webViewClient = ImageInterceptClient(imageCache, baseWebViewClient)
webChromeClient = FOWebChromeClient()
addJavaScriptInterface(javascriptInterface, "Android")
}
private fun setupWebView() {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
allowFileAccess = true
allowContentAccess = true
loadsImagesAutomatically = true
blockNetworkImage = false
cacheMode = WebSettings.LOAD_DEFAULT
setAppCacheEnabled(true)
}
// Inject JavaScript bridge
injectBridgeScript()
}
private fun injectBridgeScript() {
val script = """
;(() => {
window.__RN__ = true
function send(data) {
Android.postMessage(JSON.stringify(data))
}
window.bridge = {
measure: () => send({ type: "measure" }),
setContentHeight: (height) => send({ type: "setContentHeight", payload: height }),
previewImage: (data) => send({ type: "previewImage", payload: data }),
seekAudio: (time) => send({ type: "audio:seekTo", payload: { time } })
}
// Signal readiness
document.addEventListener("DOMContentLoaded", () => {
send({ type: "ready" })
})
})()
"""
evaluateJavascript(script, null)
}
fun clearImageCache() { imageCache.clear() }
}
Phase 4: Integration and Testing (Priority 3)
File Structure to Create
apps/mobile/native/android/src/main/java/expo/modules/follownative/sharedwebview/
├── SharedWebViewModule.kt # Main Expo module
├── SharedWebViewView.kt # Expo view component
├── WebViewManager.kt # Singleton WebView manager
├── FOWebView.kt # Custom WebView implementation
├── ImageInterceptClient.kt # Request interception logic
├── ImageCache.kt # Caching system
├── WebViewState.kt # State management
├── BridgeData.kt # Message payloads
└── JavaScriptInterface.kt # WebView-to-native bridge
Configuration Updates Required
- expo-module.config.json - Add new Android module:
{
"android": {
"modules": [
"expo.modules.follownative.sharedwebview.SharedWebViewModule"
// ... existing modules
]
}
}
- Android Gradle Dependencies - Add to
apps/mobile/native/android/build.gradle:
dependencies {
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.6"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0"
}
Implementation Tasks
Phase 1: Core Infrastructure (Week 1)
- Create module structure and base classes
- Implement SharedWebViewModule.kt with Expo module definition
- Implement WebViewManager.kt singleton with lifecycle management
- Implement WebViewState.kt with StateFlow-based reactive state
- Create basic SharedWebViewView.kt Expo view component
Phase 2: Image Interception System (Week 1-2)
- Implement ImageInterceptClient.kt with shouldInterceptRequest logic
- Build ImageCache.kt with memory and disk caching
- Create image request detection and classification system
- Implement network request handling with proper headers
- Add MIME type detection and response creation
Phase 3: WebView Integration (Week 2)
- Implement FOWebView.kt custom WebView
- Create JavaScriptInterface.kt for native-JS communication
- Add JavaScript bridge injection and message handling
- Implement content height tracking and layout updates
- Add debug utilities and state inspection tools
Phase 4: Testing and Integration (Week 2-3)
- Create comprehensive unit tests for each component
- Implement integration tests with mock WebView scenarios
- Add performance tests for image caching and request interception
- Test cross-platform API compatibility with iOS implementation
- Validate memory management and lifecycle behavior
Phase 5: Documentation and Deployment (Week 3)
- Update expo-module.config.json with new Android module
- Add necessary Gradle dependencies and configurations
- Create comprehensive documentation for the implementation
- Update TypeScript definitions if needed
- Perform final testing and validation
Validation Gates
Build Validation
cd apps/mobile/native/android
./gradlew assembleDebug
Code Quality Checks
cd apps/mobile
npm run typecheck
Functional Testing
# Test module loading
adb logcat | grep "FOSharedWebView"
# Test image interception
# Navigate to image-heavy content and verify cache behavior
Performance Testing
// Measure image cache hit rates and memory usage
WebViewManager.getImageCacheStats().let { stats ->
assertTrue("Cache hit rate should be > 80%", stats.hitRate > 0.8)
assertTrue("Memory usage should be < 50MB", stats.memoryUsage < 50 * 1024 * 1024)
}
Cross-Platform Compatibility Testing
// Verify identical API behavior across platforms
const debugState = await SharedWebViewModule.getDebugState()
expect(debugState).toHaveProperty("hasWebView")
expect(debugState).toHaveProperty("contentHeight")
Documentation and References
Essential References
- Expo Modules API Documentation
- Native View Tutorial
- Android WebView Request Interception
- WebView Security Best Practices
Key Implementation Files to Reference
apps/mobile/native/ios/Modules/SharedWebView/- Complete iOS implementationapps/mobile/native/android/src/main/java/expo/modules/follownative/FollowNativeModule.kt- Existing module patternsapps/mobile/src/components/native/webview/index.ts- TypeScript API interface
Critical Security Considerations
- Request Validation: Always validate URLs before processing to prevent malicious requests
- Header Sanitization: Filter restricted headers to prevent security vulnerabilities
- File Access Limiting: Restrict file:// URL access to app bundle resources only
- Content Security: Implement proper CSP headers for loaded content
- Bridge Data Validation: Validate all JavaScript bridge message payloads
Expected Outcomes
Performance Improvements over iOS
- No URL Rewriting Overhead: Direct request interception vs. JavaScript URL manipulation
- Better Error Handling: Graceful fallback to default WebView behavior on failure
- Enhanced Debugging: Standard HTTP request debugging vs. custom scheme debugging
- Improved Reliability: No dependency on JavaScript injection success
Feature Parity Achievement
- 100% API compatibility with iOS implementation
- Identical event handling and state management
- Same debugging capabilities and state inspection tools
- Consistent caching behavior and memory management
Success Metrics
- Image cache hit rate > 90%
- Memory usage < 64MB for typical usage
- Zero crashes related to WebView lifecycle management
- API response time < 50ms for cached resources
- Cross-platform test suite passing rate > 99%
Confidence Score: 9/10
This PRP provides comprehensive context, detailed implementation blueprints, existing code patterns to follow, specific file references, security considerations, and executable validation gates. The Android implementation leverages superior platform capabilities while maintaining complete API compatibility with iOS. All necessary technical context, external documentation, and testing patterns are included to enable successful one-pass implementation.