diff --git a/OpacityCore/proguard-rules.pro b/OpacityCore/proguard-rules.pro index 25b25c7..c5c5e67 100644 --- a/OpacityCore/proguard-rules.pro +++ b/OpacityCore/proguard-rules.pro @@ -14,8 +14,10 @@ -keep class com.opacitylabs.opacitycore.OpacityResponse { *; } -keep class com.opacitylabs.opacitycore.OpacityError { *; } -# Keep the InAppBrowserActivity as it's referenced by string name --keep class com.opacitylabs.opacitycore.InAppBrowserActivity { *; } +# Keep browser activity classes +-keep class com.opacitylabs.opacitycore.BaseBrowserActivity { *; } +-keep class com.opacitylabs.opacitycore.WebViewBrowserActivity { *; } +-keep class com.opacitylabs.opacitycore.GeckoViewBrowserActivity { *; } # Keep utility classes -keep class com.opacitylabs.opacitycore.JsonUtils { *; } @@ -64,7 +66,15 @@ -keep class * extends android.content.BroadcastReceiver -keep class * extends android.os.Parcelable -# Keep GeckoView related classes (since you use Mozilla GeckoView) +# Keep WebView JavascriptInterface methods +-keepclassmembers class * { + @android.webkit.JavascriptInterface ; +} + +# Keep the OpacityJsBridge inner class +-keep class com.opacitylabs.opacitycore.WebViewBrowserActivity$OpacityJsBridge { *; } + +# Keep GeckoView classes -keep class org.mozilla.geckoview.** { *; } -dontwarn org.mozilla.geckoview.** diff --git a/OpacityCore/src/main/cpp/OpacityCore.cpp b/OpacityCore/src/main/cpp/OpacityCore.cpp index 2879c86..80bd5c7 100644 --- a/OpacityCore/src/main/cpp/OpacityCore.cpp +++ b/OpacityCore/src/main/cpp/OpacityCore.cpp @@ -136,17 +136,15 @@ extern "C" void android_set_request_header(const char *key, const char *value) { env->CallVoidMethod(java_object, method, jkey, jvalue); } -extern "C" void android_present_webview(bool shouldIntercept) { +extern "C" void android_present_webview(bool shouldIntercept, bool androidUseSystemWebView) { JNIEnv *env = GetJniEnv(); - // Get the Kotlin class jclass jOpacityCore = env->GetObjectClass(java_object); - // Get the method ID for the method you want to call - jmethodID method = env->GetMethodID(jOpacityCore, "presentBrowser", "(Z)V"); + jmethodID method = env->GetMethodID(jOpacityCore, "presentBrowser", "(ZZ)V"); - // Call the method with the necessary parameters jboolean jshouldIntercept = shouldIntercept ? JNI_TRUE : JNI_FALSE; - env->CallVoidMethod(java_object, method, jshouldIntercept); + jboolean jandroidUseSystemWebView = androidUseSystemWebView ? JNI_TRUE : JNI_FALSE; + env->CallVoidMethod(java_object, method, jshouldIntercept, jandroidUseSystemWebView); } extern "C" void android_webview_change_url(const char *url) { diff --git a/OpacityCore/src/main/jni/include/sdk.h b/OpacityCore/src/main/jni/include/sdk.h index 716e189..154572e 100644 --- a/OpacityCore/src/main/jni/include/sdk.h +++ b/OpacityCore/src/main/jni/include/sdk.h @@ -250,7 +250,7 @@ extern void android_prepare_request(const char *url); extern void android_set_request_header(const char *key, const char *value); -extern void android_present_webview(bool should_intercept); +extern void android_present_webview(bool should_intercept, bool android_use_system_webview); extern void android_close_webview(void); diff --git a/OpacityCore/src/main/kotlin/com/opacitylabs/opacitycore/InAppBrowserActivity.kt b/OpacityCore/src/main/kotlin/com/opacitylabs/opacitycore/BaseBrowserActivity.kt similarity index 50% rename from OpacityCore/src/main/kotlin/com/opacitylabs/opacitycore/InAppBrowserActivity.kt rename to OpacityCore/src/main/kotlin/com/opacitylabs/opacitycore/BaseBrowserActivity.kt index 2838bb0..72653a7 100644 --- a/OpacityCore/src/main/kotlin/com/opacitylabs/opacitycore/InAppBrowserActivity.kt +++ b/OpacityCore/src/main/kotlin/com/opacitylabs/opacitycore/BaseBrowserActivity.kt @@ -1,12 +1,12 @@ package com.opacitylabs.opacitycore -import android.util.Log import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.Bundle +import android.util.Log import android.view.Gravity import android.view.ViewGroup import android.widget.Button @@ -17,16 +17,8 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.localbroadcastmanager.content.LocalBroadcastManager import org.json.JSONObject -import org.mozilla.geckoview.AllowOrDeny -import org.mozilla.geckoview.GeckoResult -import org.mozilla.geckoview.GeckoSession -import org.mozilla.geckoview.GeckoSession.ContentDelegate -import org.mozilla.geckoview.GeckoSessionSettings -import org.mozilla.geckoview.GeckoView -import org.mozilla.geckoview.WebExtension -import java.net.HttpCookie - -class InAppBrowserActivity : AppCompatActivity() { + +abstract class BaseBrowserActivity : AppCompatActivity() { private val closeReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { @@ -39,9 +31,9 @@ class InAppBrowserActivity : AppCompatActivity() { private val cookiesForCurrentURLRequestReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == "com.opacitylabs.opacitycore.GET_COOKIES_FOR_CURRENT_URL" - ) { + if (intent?.action == "com.opacitylabs.opacitycore.GET_COOKIES_FOR_CURRENT_URL") { val receiver = intent.getParcelableExtra("receiver") + onCookiesRequested() val domain = java.net.URL(currentUrl).host receiver?.onReceiveResult(getMatchedCookies(domain)) } @@ -54,12 +46,11 @@ class InAppBrowserActivity : AppCompatActivity() { val receiver = intent.getParcelableExtra("receiver") var domain = intent.getStringExtra("domain") if (domain?.startsWith(".") == true) { - // If the domain starts with a dot, we have to remove it as per rfc 6265 - // https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3 domain = domain.substring(1) } if (domain != null) { + onCookiesForDomainRequested(domain) receiver?.onReceiveResult(getMatchedCookies(domain)) } else { receiver?.onReceiveResult(JSONObject()) @@ -68,11 +59,23 @@ class InAppBrowserActivity : AppCompatActivity() { } } - private lateinit var geckoSession: GeckoSession - private lateinit var geckoView: GeckoView - private var cookies: MutableMap = mutableMapOf() + private val changeUrlReceiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == "com.opacitylabs.opacitycore.CHANGE_URL") { + currentUrl = intent.getStringExtra("url")!! + navigateToUrl(currentUrl) + } + } + } - private fun getMatchedCookies(domain: String): JSONObject { + protected var cookies: MutableMap = mutableMapOf() + protected var htmlBody: String = "" + protected var currentUrl: String = "" + protected val visitedUrls = mutableListOf() + protected var interceptExtensionEnabled = false + + protected fun getMatchedCookies(domain: String): JSONObject { val matchedCookies = JSONObject() for ((cookieDomain, cookieObject) in cookies) { val cleanDomain = cookieDomain.trimStart('.') @@ -86,24 +89,97 @@ class InAppBrowserActivity : AppCompatActivity() { } return matchedCookies } - private var htmlBody: String = "" - private var currentUrl: String = "" - private val visitedUrls = mutableListOf() - private var interceptExtensionEnabled = false - private val changeUrlReceiver = - object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == "com.opacitylabs.opacitycore.CHANGE_URL") { - currentUrl = intent.getStringExtra("url")!! - if (geckoSession.isOpen) { - geckoSession.loadUri(currentUrl) - } else { - Log.d("MainActivity", "Warning: Browser is not open.") - } - } - } + protected fun emitNavigationEvent() { + val event: MutableMap = + mutableMapOf( + "event" to "navigation", + "url" to currentUrl, + "visited_urls" to visitedUrls, + "id" to System.currentTimeMillis().toString() + ) + + try { + val domain = java.net.URL(currentUrl).host + event["cookies"] = cookies[domain] + } catch (e: Exception) { + // If the URL is malformed (usually when it is a URI like "uberlogin://blabla") + // we don't set any cookies + } + + if (htmlBody != "") { + event["html_body"] = htmlBody + } + + OpacityCore.emitWebviewEvent(JSONObject(event).toString()) + clearVisitedUrls() + } + + protected fun emitLocationEvent(url: String) { + val event: Map = + mapOf( + "event" to "location_changed", + "url" to url, + "id" to System.currentTimeMillis().toString() + ) + OpacityCore.emitWebviewEvent(JSONObject(event).toString()) + } + + protected fun emitInterceptedRequest(requestData: JSONObject) { + val event: MutableMap = + mutableMapOf( + "event" to "intercepted_request", + "request_type" to requestData.optString("request_type"), + "data" to requestData.opt("data"), + "id" to System.currentTimeMillis().toString() + ) + val json = JSONObject(event).toString() + OpacityCore.emitWebviewEvent(json) + } + + protected fun onClose() { + val event: Map = + mapOf("event" to "close", "id" to System.currentTimeMillis().toString()) + OpacityCore.emitWebviewEvent(JSONObject(event).toString()) + interceptExtensionEnabled = false + finish() + } + + protected fun addToVisitedUrls(url: String) { + if (visitedUrls.isNotEmpty() && visitedUrls.last() == url) { + return } + visitedUrls.add(url) + } + + protected fun clearVisitedUrls() { + visitedUrls.clear() + } + + /** + * Called before returning cookies for current URL — subclasses can override + * to sync cookies from their engine (e.g., WebView's CookieManager). No-op by default. + */ + protected open fun onCookiesRequested() {} + + /** + * Called before returning cookies for a specific domain. Default delegates to onCookiesRequested(). + */ + protected open fun onCookiesForDomainRequested(domain: String) { + onCookiesRequested() + } + + /** Set up the browser engine and add its view to the container. */ + protected abstract fun setupBrowser(container: LinearLayout, headers: Bundle?, interceptEnabled: Boolean) + + /** Load the initial URL with optional headers. */ + protected abstract fun loadInitialUrl(url: String, headers: Bundle?) + + /** Navigate to a new URL (e.g., from changeUrl broadcast). */ + protected abstract fun navigateToUrl(url: String) + + /** Clean up browser engine resources. */ + protected abstract fun cleanupBrowser() @SuppressLint("WrongThread") override fun onCreate(savedInstanceState: Bundle?) { @@ -113,7 +189,6 @@ class InAppBrowserActivity : AppCompatActivity() { window.statusBarColor = android.graphics.Color.TRANSPARENT window.navigationBarColor = android.graphics.Color.TRANSPARENT - // Enable edge-to-edge ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { view, windowInsets -> val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) view.setPadding(0, insets.top, 0, insets.bottom) @@ -125,17 +200,14 @@ class InAppBrowserActivity : AppCompatActivity() { closeReceiver, IntentFilter("com.opacitylabs.opacitycore.CLOSE_BROWSER") ) - localBroadcastManager.registerReceiver( cookiesForCurrentURLRequestReceiver, IntentFilter("com.opacitylabs.opacitycore.GET_COOKIES_FOR_CURRENT_URL") ) - localBroadcastManager.registerReceiver( cookiesForDomainRequestReceiver, IntentFilter("com.opacitylabs.opacitycore.GET_COOKIES_FOR_DOMAIN") ) - localBroadcastManager.registerReceiver( changeUrlReceiver, IntentFilter("com.opacitylabs.opacitycore.CHANGE_URL") @@ -145,7 +217,7 @@ class InAppBrowserActivity : AppCompatActivity() { val closeButton = Button(this, null, android.R.attr.buttonStyleSmall).apply { - text = "✕" + text = "\u2715" textSize = 18f setBackgroundColor(android.graphics.Color.TRANSPARENT) setOnClickListener { onClose() } @@ -162,127 +234,7 @@ class InAppBrowserActivity : AppCompatActivity() { interceptExtensionEnabled = intent.getBooleanExtra("enableInterceptRequests", false) - // Create shared message delegate for both extensions - val sharedMessageDelegate = object : WebExtension.MessageDelegate { - override fun onMessage( - nativeApp: String, - message: Any, - sender: WebExtension.MessageSender - ): GeckoResult? { - try { - val jsonMessage = message as JSONObject - when (jsonMessage.getString("event")) { - "html_body" -> { - htmlBody = jsonMessage.getString("html") - emitNavigationEvent() - - // clear the html_body, needed so we stay consistent with iOS - htmlBody = "" - } - - "cookies" -> { - val receivedCookies = jsonMessage.getString("cookies") - val defaultDomain = jsonMessage.getString("domain") - - val lines = receivedCookies.lines().filter { it.isNotBlank() } - - val parsedCookies = lines.flatMap { HttpCookie.parse(it) } - - val cookiesByDomain = mutableMapOf() - for (cookie in parsedCookies) { - val cookieDomain = cookie.domain?.trimStart('.') ?: defaultDomain - val cookieDict = cookiesByDomain.getOrPut(cookieDomain) { JSONObject() } - - cookieDict.put(cookie.name, cookie.value) - } - - for ((domain, cookieDict) in cookiesByDomain) { - cookies[domain] = - cookies[domain]?.let { existingCookies -> - JsonUtils.mergeJsonObjects(existingCookies, cookieDict) - } ?: cookieDict - } - } - - "intercepted_request" -> { - if (interceptExtensionEnabled) { - val requestData = jsonMessage.optJSONObject("data") - if (requestData != null) { - emitInterceptedRequest(requestData) - } - } - } - - - else -> { - // Intentionally left blank - } - } - } catch (e: Exception) { - Log.e("MainActivity", "Error processing extension message", e) - } - - return super.onMessage(nativeApp, message, sender) - } - } - - OpacityCore.setMainMessageDelegate(sharedMessageDelegate) - - if (interceptExtensionEnabled) { - OpacityCore.setInterceptMessageDelegate(sharedMessageDelegate) - } - - // Create GeckoSession only once - val settings = GeckoSessionSettings.Builder() - .usePrivateMode(true) - .useTrackingProtection(true) - .allowJavascript(true) - .build() - - geckoSession = - GeckoSession(settings).apply { - setContentDelegate(object : ContentDelegate {}) - open(OpacityCore.getRuntime()) - } - - geckoSession.settings.apply { allowJavascript = true } - val headers: Bundle? = intent.getBundleExtra("headers") - val customUserAgent = headers?.getString("user-agent") - if (customUserAgent != null) { - geckoSession.settings.userAgentOverride = customUserAgent - } - - geckoSession.navigationDelegate = - object : GeckoSession.NavigationDelegate { - override fun onLoadRequest( - session: GeckoSession, - request: GeckoSession.NavigationDelegate.LoadRequest - ): GeckoResult? { - currentUrl = request.uri - addToVisitedUrls(request.uri) - - emitNavigationEvent() - - return super.onLoadRequest(session, request) - } - - override fun onLocationChange( - session: GeckoSession, - url: String?, - perms: - MutableList< - GeckoSession.PermissionDelegate.ContentPermission>, - hasUserGesture: Boolean - ) { - if (url != null) { - addToVisitedUrls(url) - emitLocationEvent(url) - } - } - } - - geckoView = GeckoView(this).apply { setSession(geckoSession) } // Create a container layout to properly handle the action bar spacing val container = LinearLayout(this).apply { @@ -292,7 +244,6 @@ class InAppBrowserActivity : AppCompatActivity() { ViewGroup.LayoutParams.MATCH_PARENT ) - // Handle window insets for the container ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) view.setPadding(0, insets.top, 0, insets.bottom) @@ -300,107 +251,22 @@ class InAppBrowserActivity : AppCompatActivity() { } } - // Configure GeckoView layout params to account for action bar - val geckoLayoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.MATCH_PARENT - ).apply { - // Add top margin to account for action bar height - val actionBarHeight = supportActionBar?.height ?: 0 - if (actionBarHeight == 0) { - // Fallback to standard action bar height - val typedArray = - theme.obtainStyledAttributes(intArrayOf(android.R.attr.actionBarSize)) - topMargin = typedArray.getDimensionPixelSize(0, 0) - typedArray.recycle() - } else { - topMargin = actionBarHeight - } - } - - geckoView.layoutParams = geckoLayoutParams - container.addView(geckoView) + setupBrowser(container, headers, interceptExtensionEnabled) setContentView(container) - val url = intent.getStringExtra("url")!! - geckoSession.loadUri(url) - } - - private fun emitInterceptedRequest(requestData: JSONObject) { - val event: MutableMap = - mutableMapOf( - "event" to "intercepted_request", - "request_type" to requestData.optString("request_type"), - "data" to requestData.opt("data"), - "id" to System.currentTimeMillis().toString() - ) - OpacityCore.emitWebviewEvent(JSONObject(event).toString()) - } - private fun emitLocationEvent(url: String) { - val event: Map = - mapOf( - "event" to "location_changed", - "url" to url, - "id" to System.currentTimeMillis().toString() - ) - OpacityCore.emitWebviewEvent(JSONObject(event).toString()) - } - - private fun emitNavigationEvent() { - val event: MutableMap = - mutableMapOf( - "event" to "navigation", - "url" to currentUrl, - "visited_urls" to visitedUrls, - "id" to System.currentTimeMillis().toString() - ) - - try { - val domain = java.net.URL(currentUrl).host - event["cookies"] = cookies[domain] - } catch (e: Exception) { - // If the URL is malformed (usually when it is a URI like "uberlogin://blabla") - // we don't set any cookies - } - - if (htmlBody != "") { - event["html_body"] = htmlBody - } - - OpacityCore.emitWebviewEvent(JSONObject(event).toString()) - clearVisitedUrls() - } - - private fun onClose() { - val event: Map = - mapOf("event" to "close", "id" to System.currentTimeMillis().toString()) - OpacityCore.emitWebviewEvent(JSONObject(event).toString()) - interceptExtensionEnabled = false - finish() - } - - private fun addToVisitedUrls(url: String) { - if (visitedUrls.isNotEmpty() && visitedUrls.last() == url) { - return - } - visitedUrls.add(url) - } - - private fun clearVisitedUrls() { - visitedUrls.clear() + val url = intent.getStringExtra("url")!! + loadInitialUrl(url, headers) } override fun onDestroy() { super.onDestroy() - OpacityCore.setMainMessageDelegate(null) - OpacityCore.setInterceptMessageDelegate(null) - - LocalBroadcastManager.getInstance(this).unregisterReceiver(closeReceiver) - LocalBroadcastManager.getInstance(this).unregisterReceiver(cookiesForDomainRequestReceiver) - LocalBroadcastManager.getInstance(this) - .unregisterReceiver(cookiesForCurrentURLRequestReceiver) - geckoSession.close() + val lbm = LocalBroadcastManager.getInstance(this) + lbm.unregisterReceiver(closeReceiver) + lbm.unregisterReceiver(cookiesForDomainRequestReceiver) + lbm.unregisterReceiver(cookiesForCurrentURLRequestReceiver) + lbm.unregisterReceiver(changeUrlReceiver) + cleanupBrowser() OpacityCore.onBrowserDestroyed() } } diff --git a/OpacityCore/src/main/kotlin/com/opacitylabs/opacitycore/GeckoViewBrowserActivity.kt b/OpacityCore/src/main/kotlin/com/opacitylabs/opacitycore/GeckoViewBrowserActivity.kt new file mode 100644 index 0000000..6da809e --- /dev/null +++ b/OpacityCore/src/main/kotlin/com/opacitylabs/opacitycore/GeckoViewBrowserActivity.kt @@ -0,0 +1,178 @@ +package com.opacitylabs.opacitycore + +import android.os.Bundle +import android.util.Log +import android.widget.LinearLayout +import org.json.JSONObject +import org.mozilla.geckoview.AllowOrDeny +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.ContentDelegate +import org.mozilla.geckoview.GeckoSessionSettings +import org.mozilla.geckoview.GeckoView +import org.mozilla.geckoview.WebExtension +import java.net.HttpCookie + +class GeckoViewBrowserActivity : BaseBrowserActivity() { + private lateinit var geckoSession: GeckoSession + private lateinit var geckoView: GeckoView + + override fun setupBrowser(container: LinearLayout, headers: Bundle?, interceptEnabled: Boolean) { + // Create shared message delegate for both extensions + val sharedMessageDelegate = object : WebExtension.MessageDelegate { + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender + ): GeckoResult? { + try { + val jsonMessage = message as JSONObject + when (jsonMessage.getString("event")) { + "html_body" -> { + htmlBody = jsonMessage.getString("html") + emitNavigationEvent() + + // clear the html_body, needed so we stay consistent with iOS + htmlBody = "" + } + + "cookies" -> { + val receivedCookies = jsonMessage.getString("cookies") + val defaultDomain = jsonMessage.getString("domain") + + val lines = receivedCookies.lines().filter { it.isNotBlank() } + + val parsedCookies = lines.flatMap { HttpCookie.parse(it) } + + val cookiesByDomain = mutableMapOf() + for (cookie in parsedCookies) { + val cookieDomain = cookie.domain?.trimStart('.') ?: defaultDomain + val cookieDict = cookiesByDomain.getOrPut(cookieDomain) { JSONObject() } + + cookieDict.put(cookie.name, cookie.value) + } + + for ((domain, cookieDict) in cookiesByDomain) { + cookies[domain] = + cookies[domain]?.let { existingCookies -> + JsonUtils.mergeJsonObjects(existingCookies, cookieDict) + } ?: cookieDict + } + } + + "intercepted_request" -> { + if (interceptExtensionEnabled) { + val requestData = jsonMessage.optJSONObject("data") + if (requestData != null) { + emitInterceptedRequest(requestData) + } + } + } + + else -> { + // Intentionally left blank + } + } + } catch (e: Exception) { + Log.e("Opacity SDK", "Error processing extension message", e) + } + + return super.onMessage(nativeApp, message, sender) + } + } + + OpacityCore.setMainMessageDelegate(sharedMessageDelegate) + + if (interceptEnabled) { + OpacityCore.setInterceptMessageDelegate(sharedMessageDelegate) + } + + // Create GeckoSession + val settings = GeckoSessionSettings.Builder() + .usePrivateMode(true) + .useTrackingProtection(true) + .allowJavascript(true) + .build() + + geckoSession = + GeckoSession(settings).apply { + setContentDelegate(object : ContentDelegate {}) + open(OpacityCore.getRuntime()) + } + + geckoSession.settings.apply { allowJavascript = true } + + val customUserAgent = headers?.getString("user-agent") + if (customUserAgent != null) { + geckoSession.settings.userAgentOverride = customUserAgent + } + + geckoSession.navigationDelegate = + object : GeckoSession.NavigationDelegate { + override fun onLoadRequest( + session: GeckoSession, + request: GeckoSession.NavigationDelegate.LoadRequest + ): GeckoResult? { + currentUrl = request.uri + addToVisitedUrls(request.uri) + + emitNavigationEvent() + + return super.onLoadRequest(session, request) + } + + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: + MutableList< + GeckoSession.PermissionDelegate.ContentPermission>, + hasUserGesture: Boolean + ) { + if (url != null) { + addToVisitedUrls(url) + emitLocationEvent(url) + } + } + } + + geckoView = GeckoView(this).apply { setSession(geckoSession) } + + // Configure GeckoView layout params to account for action bar + val geckoLayoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + ).apply { + val actionBarHeight = supportActionBar?.height ?: 0 + if (actionBarHeight == 0) { + val typedArray = + theme.obtainStyledAttributes(intArrayOf(android.R.attr.actionBarSize)) + topMargin = typedArray.getDimensionPixelSize(0, 0) + typedArray.recycle() + } else { + topMargin = actionBarHeight + } + } + + geckoView.layoutParams = geckoLayoutParams + container.addView(geckoView) + } + + override fun loadInitialUrl(url: String, headers: Bundle?) { + geckoSession.loadUri(url) + } + + override fun navigateToUrl(url: String) { + if (geckoSession.isOpen) { + geckoSession.loadUri(url) + } else { + Log.d("Opacity SDK", "Warning: GeckoSession is not open.") + } + } + + override fun cleanupBrowser() { + OpacityCore.setMainMessageDelegate(null) + OpacityCore.setInterceptMessageDelegate(null) + geckoSession.close() + } +} diff --git a/OpacityCore/src/main/kotlin/com/opacitylabs/opacitycore/OpacityCore.kt b/OpacityCore/src/main/kotlin/com/opacitylabs/opacitycore/OpacityCore.kt index 77f8096..64ef580 100644 --- a/OpacityCore/src/main/kotlin/com/opacitylabs/opacitycore/OpacityCore.kt +++ b/OpacityCore/src/main/kotlin/com/opacitylabs/opacitycore/OpacityCore.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle +import android.util.Log import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.opacitylabs.opacitycore.JsonConverter.Companion.mapToJsonElement import com.opacitylabs.opacitycore.JsonConverter.Companion.parseJsonElementToAny @@ -27,8 +28,8 @@ object OpacityCore { private lateinit var cryptoManager: CryptoManager private lateinit var _url: String private var headers: Bundle = Bundle() - private lateinit var sRuntime: GeckoRuntime private var isBrowserActive = false + private var sRuntime: GeckoRuntime? = null private var mainExtension: WebExtension? = null private var interceptExtension: WebExtension? = null private var extensionsInstalled = false @@ -52,21 +53,23 @@ object OpacityCore { @JvmStatic fun setContext(context: Context) { appContext = context - // Only create GeckoRuntime if it hasn't been created yet - if (!::sRuntime.isInitialized) { + cryptoManager = CryptoManager(appContext.applicationContext) + if (sRuntime == null) { sRuntime = GeckoRuntime.create(appContext.applicationContext) - // Install extensions once when runtime is created installExtensions() } - cryptoManager = CryptoManager(appContext.applicationContext) + } + + fun getRuntime(): GeckoRuntime { + return sRuntime!! } private fun installExtensions() { if (extensionsInstalled) return + val runtime = sRuntime ?: return - val controller = sRuntime.webExtensionController + val controller = runtime.webExtensionController - // For cookies controller.installBuiltIn("resource://android/assets/extension/") .accept( { ext -> @@ -76,13 +79,10 @@ object OpacityCore { } }, { e -> - android.util.Log.e("OpacityCore", "Failed to install main extension", e) + Log.e("OpacityCore", "Failed to install main extension", e) } ) - extensionsInstalled = true - - // For intercepting browser requests controller.installBuiltIn("resource://android/assets/interceptExtension/") .accept( { ext -> @@ -92,13 +92,12 @@ object OpacityCore { } }, { e -> - android.util.Log.e("OpacityCore", "Failed to install intercept extension", e) + Log.e("OpacityCore", "Failed to install intercept extension", e) } ) - } - fun getMainExtension(): WebExtension? = mainExtension - fun getInterceptExtension(): WebExtension? = interceptExtension + extensionsInstalled = true + } fun setMainMessageDelegate(delegate: WebExtension.MessageDelegate?) { pendingMainMessageDelegate = delegate @@ -110,13 +109,6 @@ object OpacityCore { interceptExtension?.setMessageDelegate(delegate, "gecko") } - fun getRuntime(): GeckoRuntime { - if (!::sRuntime.isInitialized) { - throw IllegalStateException("GeckoRuntime is not initialized. Call initialize() first.") - } - return sRuntime - } - fun isAppForegrounded(): Boolean { return try { val activityManager = appContext.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager @@ -199,8 +191,13 @@ object OpacityCore { headers.putString(key.lowercase(), value) } - fun presentBrowser(shouldIntercept: Boolean) { - val intent = Intent(appContext, InAppBrowserActivity::class.java) + fun presentBrowser(shouldIntercept: Boolean, androidUseSystemWebView: Boolean = false) { + val activityClass = if (androidUseSystemWebView) { + WebViewBrowserActivity::class.java + } else { + GeckoViewBrowserActivity::class.java + } + val intent = Intent(appContext, activityClass) intent.putExtra("url", _url) intent.putExtra("headers", headers) intent.putExtra("enableInterceptRequests", shouldIntercept) diff --git a/OpacityCore/src/main/kotlin/com/opacitylabs/opacitycore/WebViewBrowserActivity.kt b/OpacityCore/src/main/kotlin/com/opacitylabs/opacitycore/WebViewBrowserActivity.kt new file mode 100644 index 0000000..ea27f12 --- /dev/null +++ b/OpacityCore/src/main/kotlin/com/opacitylabs/opacitycore/WebViewBrowserActivity.kt @@ -0,0 +1,301 @@ +package com.opacitylabs.opacitycore + +import android.annotation.SuppressLint +import android.os.Bundle +import android.util.Log +import android.webkit.CookieManager +import android.webkit.JavascriptInterface +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.LinearLayout +import org.json.JSONObject + +class WebViewBrowserActivity : BaseBrowserActivity() { + private lateinit var webView: WebView + + inner class OpacityJsBridge { + @JavascriptInterface + fun onInterceptedRequest(json: String) { + if (!interceptExtensionEnabled) return + try { + val requestData = JSONObject(json) + emitInterceptedRequest(requestData) + } catch (e: Exception) { + Log.e("Opacity SDK", "Error parsing intercepted request", e) + } + } + } + + @SuppressLint("SetJavaScriptEnabled") + override fun setupBrowser(container: LinearLayout, headers: Bundle?, interceptEnabled: Boolean) { + // Clear cookies for private-mode-like behavior + CookieManager.getInstance().removeAllCookies(null) + CookieManager.getInstance().setAcceptCookie(true) + + webView = WebView(this).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.databaseEnabled = true + settings.javaScriptCanOpenWindowsAutomatically = true + settings.setSupportMultipleWindows(false) + + addJavascriptInterface(OpacityJsBridge(), "OpacityNative") + } + + val customUserAgent = headers?.getString("user-agent") + if (customUserAgent != null) { + webView.settings.userAgentString = customUserAgent + } + + CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true) + + webView.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + val url = request?.url?.toString() ?: return false + currentUrl = url + addToVisitedUrls(url) + emitNavigationEvent() + return false + } + + override fun onPageStarted( + view: WebView?, + url: String?, + favicon: android.graphics.Bitmap? + ) { + super.onPageStarted(view, url, favicon) + if (url != null) { + currentUrl = url + addToVisitedUrls(url) + } + if (interceptExtensionEnabled) { + view?.evaluateJavascript(INTERCEPT_SCRIPT, null) + } + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + if (url != null) { + currentUrl = url + updateCookiesFromCookieManager(url) + } + + view?.evaluateJavascript("document.documentElement.outerHTML") { rawResult -> + if (rawResult != null && rawResult != "null") { + htmlBody = unescapeJsString(rawResult) + emitNavigationEvent() + htmlBody = "" + } else { + emitNavigationEvent() + } + } + } + + override fun doUpdateVisitedHistory( + view: WebView?, + url: String?, + isReload: Boolean + ) { + super.doUpdateVisitedHistory(view, url, isReload) + if (url != null) { + addToVisitedUrls(url) + emitLocationEvent(url) + } + } + } + + val webViewLayoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + ).apply { + val actionBarHeight = supportActionBar?.height ?: 0 + if (actionBarHeight == 0) { + val typedArray = + theme.obtainStyledAttributes(intArrayOf(android.R.attr.actionBarSize)) + topMargin = typedArray.getDimensionPixelSize(0, 0) + typedArray.recycle() + } else { + topMargin = actionBarHeight + } + } + + webView.layoutParams = webViewLayoutParams + container.addView(webView) + } + + override fun loadInitialUrl(url: String, headers: Bundle?) { + val headerMap = mutableMapOf() + headers?.keySet()?.forEach { key -> + if (key != "user-agent") { + headers.getString(key)?.let { headerMap[key] = it } + } + } + webView.loadUrl(url, headerMap) + } + + override fun navigateToUrl(url: String) { + webView.loadUrl(url) + } + + override fun onCookiesRequested() { + updateCookiesFromCookieManager(currentUrl) + } + + override fun onCookiesForDomainRequested(domain: String) { + updateCookiesFromCookieManager(currentUrl) + updateCookiesFromCookieManager("https://$domain") + } + + override fun cleanupBrowser() { + CookieManager.getInstance().removeAllCookies(null) + webView.destroy() + } + + private fun updateCookiesFromCookieManager(url: String) { + try { + if (!url.startsWith("http://") && !url.startsWith("https://")) return + val domain = java.net.URL(url).host + val cookieString = CookieManager.getInstance().getCookie(url) ?: return + val cookieDict = JSONObject() + cookieString.split(";").forEach { part -> + val trimmed = part.trim() + val eqIdx = trimmed.indexOf('=') + if (eqIdx > 0) { + val name = trimmed.substring(0, eqIdx).trim() + val value = trimmed.substring(eqIdx + 1).trim() + cookieDict.put(name, value) + } + } + cookies[domain] = + cookies[domain]?.let { existing -> + JsonUtils.mergeJsonObjects(existing, cookieDict) + } ?: cookieDict + } catch (e: Exception) { + Log.e("Opacity SDK", "Error updating cookies from CookieManager", e) + } + } + + private fun unescapeJsString(raw: String): String { + var s = raw + if (s.startsWith("\"") && s.endsWith("\"")) { + s = s.substring(1, s.length - 1) + } + return s + .replace("\\\\", "\\") + .replace("\\\"", "\"") + .replace("\\/", "/") + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t") + .replace("\\u003C", "<") + .replace("\\u003E", ">") + .replace("\\u0026", "&") + .replace("\\u003c", "<") + .replace("\\u003e", ">") + .replace("\\u0026", "&") + } + + companion object { + private const val INTERCEPT_SCRIPT = """ +(function() { + const log = (requestType, data) => { try { OpacityNative.onInterceptedRequest(JSON.stringify({ request_type: requestType, data })); } catch(e) {} }; + + const nativeToString = Function.prototype.toString; + const nativeCallToString = Function.prototype.call.bind(nativeToString); + const wrappedFns = new WeakMap(); + + Function.prototype.toString = function() { + if (wrappedFns.has(this)) { + return wrappedFns.get(this); + } + return nativeCallToString(this); + }; + wrappedFns.set(Function.prototype.toString, 'function toString() { [native code] }'); + + const originalFetch = window.fetch; + const wrappedFetch = function fetch(input, init) { + const method = (init && init.method) || (typeof input === 'string' ? 'GET' : input.method || 'GET'); + const url = typeof input === 'string' ? input : input.url; + if (method.toUpperCase() === 'POST' && init?.body && url.indexOf('/v2/submit-form') !== -1) { + var bodyStr = typeof init.body === 'string' ? init.body : JSON.stringify(init.body); + var fullUrl = new URL(url, location.href).href; + try { OpacityNative.storePostBody(fullUrl, bodyStr); } catch(e) {} + } + let requestHeaders = init?.headers || {}; + if (requestHeaders instanceof Headers) requestHeaders = Object.fromEntries(requestHeaders.entries()); + log('fetch_request', { url, method, headers: requestHeaders, body: init?.body }); + return originalFetch.apply(this, arguments).then(function(response) { + const cloned = response.clone(); + let responseHeaders = cloned.headers || {}; + if (responseHeaders instanceof Headers) responseHeaders = Object.fromEntries(responseHeaders.entries()); + cloned.text().then(function(body) { + log('fetch_response', { url, method, headers: responseHeaders, body, status: cloned.status }); + }); + return response; + }); + }; + wrappedFns.set(wrappedFetch, 'function fetch() { [native code] }'); + Object.defineProperty(window, 'fetch', { value: wrappedFetch, writable: true, configurable: true }); + + const OriginalXHR = window.XMLHttpRequest; + const xhrProto = OriginalXHR.prototype; + const originalOpen = xhrProto.open; + const originalSend = xhrProto.send; + const originalSetHeader = xhrProto.setRequestHeader; + const xhrData = new WeakMap(); + + xhrProto.open = function(method, url) { + xhrData.set(this, { method, url, headers: {} }); + return originalOpen.apply(this, arguments); + }; + wrappedFns.set(xhrProto.open, 'function open() { [native code] }'); + + xhrProto.setRequestHeader = function(name, value) { + const data = xhrData.get(this); + if (data) data.headers[name] = value; + return originalSetHeader.apply(this, arguments); + }; + wrappedFns.set(xhrProto.setRequestHeader, 'function setRequestHeader() { [native code] }'); + + xhrProto.send = function(body) { + const data = xhrData.get(this); + if (data && data.method && data.method.toUpperCase() === 'POST' && body && data.url.indexOf('/v2/submit-form') !== -1) { + var bodyStr = typeof body === 'string' ? body : JSON.stringify(body); + var fullUrl = new URL(data.url, location.href).href; + try { OpacityNative.storePostBody(fullUrl, bodyStr); } catch(e) {} + } + if (data) { + log('xhr_request', { method: data.method, url: data.url, headers: data.headers, body }); + this.addEventListener('loadend', () => { + log('xhr_response', { method: data.method, url: data.url, headers: data.headers, body: this.responseText || this.response, status: this.status }); + }); + } + return originalSend.apply(this, arguments); + }; + wrappedFns.set(xhrProto.send, 'function send() { [native code] }'); + + Object.defineProperty(navigator, 'webdriver', { get: () => undefined, configurable: true }); + + const automationProps = ['__webdriver_script_fn', '__driver_evaluate', '__webdriver_evaluate', + '__selenium_evaluate', '__fxdriver_evaluate', '__driver_unwrapped', '__webdriver_unwrapped', + '__selenium_unwrapped', '__fxdriver_unwrapped', '_Selenium_IDE_Recorder', '_selenium', + 'calledSelenium', '_WEBDRIVER_ELEM_CACHE', 'ChromeDriverw', 'driver-hierarchical', + '__nightmare', '__phantomas', '_phantom', 'phantom', 'callPhantom']; + automationProps.forEach(p => { try { Object.defineProperty(window, p, { get: () => undefined, configurable: true }); } catch(e) {} }); + + const OriginalError = Error; + Error = function(...args) { + const err = new OriginalError(...args); + if (err.stack) err.stack = err.stack.replace(/\n.*OpacityNative.*/g, ''); + return err; + }; + Error.prototype = OriginalError.prototype; + Object.setPrototypeOf(Error, OriginalError); +})(); +""" + } +} diff --git a/app/src/androidTest/java/UITests.kt b/app/src/androidTest/java/UITests.kt index 0cd9ab1..b35b75c 100644 --- a/app/src/androidTest/java/UITests.kt +++ b/app/src/androidTest/java/UITests.kt @@ -65,10 +65,10 @@ class UITests { // Perform click composeTestRule.onNodeWithText("Test flow always succeeds").performClick() - // Wait for InAppBrowserActivity to be dismissed + // Wait for GeckoViewBrowserActivity to be dismissed waitForActivityToFinish( - com.opacitylabs.opacitycore.InAppBrowserActivity::class.java, - timeoutMillis = 30000 + com.opacitylabs.opacitycore.GeckoViewBrowserActivity::class.java, + timeoutMillis = 30_000L ) Thread.sleep(10000) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index db3c7dc..eb12625 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -32,7 +32,10 @@ + \ No newline at end of file diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 5e4ba9c..521b597 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -3,4 +3,10 @@ localhost + + + + + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e1d369..c7259d0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,8 +20,8 @@ workRuntimeKtx = "2.10.5" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } -androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } geckoview = { module = "org.mozilla.geckoview:geckoview", version.ref = "geckoview" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }