From 1531f3baadff7ddbb191fda521efa2aae524faf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 22:46:31 +0000 Subject: [PATCH] feat: direct-WebView browser activity (scroll, zoom, keyboard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed on a real device: the streamed SurfaceControlViewHost surface forwards taps but drops scroll/zoom/keyboard gestures — a hard limitation of androidx.privacysandbox.ui on current Android. No tweak fixes it. Add NappletBrowserActivity: a full-screen browser that hosts the WebView *directly* in its own window in the keyless :napplet process, so scrolling, pinch-zoom, and the soft keyboard (windowSoftInputMode=adjustResize) all work natively. It carries over NappletBrowserService's per-origin NIP-07 bridge and Tor proxy, plus NappletHostActivity's trusted chip, loading screen, and foreground hold — so it stays just as keyless (page JS runs in :napplet, every window.nostr call is brokered + consent-gated per origin in the main process). Web favorites and URL launches now open this activity instead of the streamed BrowserHostActivity. Per-host Tor choice persists via a new MSG_SET_WEB_TOR broker message (the :napplet process relays it to WebUrlNetworkRegistry). The embedded bottom-row web tab still uses the streamed surface (it must, to live inside MainActivity) and so still can't scroll — that's a follow-up. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN --- amethyst/src/main/AndroidManifest.xml | 13 + .../amethyst/favorites/FavoriteAppLauncher.kt | 19 +- .../amethyst/napplet/NappletBrokerService.kt | 9 + .../napplethost/NappletBrowserActivity.kt | 545 ++++++++++++++++++ .../amethyst/napplethost/NappletIpc.kt | 10 + 5 files changed, 591 insertions(+), 5 deletions(-) create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 5932e41837..8b45b4f018 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -424,6 +424,19 @@ android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|keyboard|uiMode|navigation|fontScale|density" android:theme="@style/Theme.Amethyst" /> + + + 0 && WebUrlNetworkRegistry.useTor(url) val intent = - BrowserHostActivity.intent(context, url).apply { + NappletBrowserActivity.intent(context, url, proxyPort, useTor).apply { if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(intent) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index a98635e980..95236d20d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -146,6 +146,15 @@ class NappletBrokerService : Service() { return true } + // The direct-WebView browser relays its per-host Tor choice; persist it (main process only). + if (msg.what == NappletIpc.MSG_SET_WEB_TOR) { + val data = msg.data ?: return true + val host = data.getString(NappletIpc.KEY_WEB_HOST)?.takeIf { it.isNotBlank() } ?: return true + WebUrlNetworkRegistry.init(applicationContext) + WebUrlNetworkRegistry.set(host, data.getBoolean(NappletIpc.KEY_NETWORK_USE_TOR, true)) + return true + } + // The sandbox relays the user's per-site network choice; persist it against the trusted // coordinate the launch token resolves to (the sandbox can't state its own coordinate). if (msg.what == NappletIpc.MSG_SET_NETWORK_MODE) { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt new file mode 100644 index 0000000000..091c3dc240 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -0,0 +1,545 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplethost + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.Message +import android.os.Messenger +import android.util.Log +import android.view.Gravity +import android.view.View +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import androidx.core.content.ContextCompat +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.ProxyConfig +import androidx.webkit.ProxyController +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract +import org.json.JSONObject +import java.util.concurrent.Executor + +/** + * Full-screen **direct-WebView** browser for an arbitrary URL, running in the keyless `:napplet` + * process. Unlike the embedded browser ([NappletBrowserService], which streams its surface to the main + * app through SurfaceControlViewHost — a path that, on current Android, forwards taps but drops scroll/ + * zoom/keyboard gestures), this hosts the WebView **directly** in its own window, so scrolling, pinch + * zoom, and the soft keyboard (`adjustResize`) all work natively. It stays just as keyless: the page JS + * runs here, every NIP-07 `window.nostr` call is brokered + consent-gated in the main process per origin, + * and the keys never leave it. + * + * Mirrors [NappletHostActivity]'s sandbox scaffolding (trusted chrome, loading screen, foreground hold) + * but loads a live URL directly instead of serving verified blobs through a shell. + */ +class NappletBrowserActivity : ComponentActivity() { + private lateinit var webView: WebView + + private var startUrl: String = "about:blank" + private var proxyPort: Int = -1 + private var useTor: Boolean = true + + private val contentFrame by lazy { FrameLayout(this) } + private var loadingView: View? = null + private var resumed = false + + // ---- broker bridge (per-origin NIP-07 tokens; identical to NappletBrowserService) ---- + private var brokerMessenger: Messenger? = null + private val replyMessenger = Messenger(Handler(Looper.getMainLooper(), ::onBrokerReply)) + private val pendingBrokerRequests = mutableListOf() + private var bridgeReplyProxy: JavaScriptReplyProxy? = null + private var fireSeq = 0 + private val originTokens = mutableMapOf() + private val pendingByOrigin = mutableMapOf>() + private val mintInFlight = mutableSetOf() + + private val backCallback = + object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + if (this@NappletBrowserActivity::webView.isInitialized && webView.canGoBack()) { + webView.goBack() + } else { + isEnabled = false + onBackPressedDispatcher.onBackPressed() + } + } + } + + private fun syncBackState() { + if (this::webView.isInitialized) backCallback.isEnabled = webView.canGoBack() + } + + private val brokerConnection = + object : ServiceConnection { + override fun onServiceConnected( + name: ComponentName?, + service: IBinder?, + ) { + brokerMessenger = Messenger(service) + pendingBrokerRequests.forEach { sendToBroker(it) } + pendingBrokerRequests.clear() + if (resumed) setBrokerForeground(true) + } + + override fun onServiceDisconnected(name: ComponentName?) { + brokerMessenger = null + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + startUrl = intent.getStringExtra(EXTRA_URL)?.takeIf { it.isNotBlank() } ?: run { + finish() + return + } + proxyPort = intent.getIntExtra(EXTRA_PROXY_PORT, -1) + useTor = intent.getBooleanExtra(EXTRA_USE_TOR, true) + title = intent.getStringExtra(EXTRA_TITLE).orEmpty() + + if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + Toast.makeText(this, getString(R.string.napplet_webview_too_old), Toast.LENGTH_LONG).show() + finish() + return + } + + webView = WebView(this) + configureWebView(webView) + webView.setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground)) + webView.dropSystemBarInsets() + applyWebViewProxy(if (useTor) proxyPort else -1) + + // NIP-07 over the direct bridge (no shell): the shim talks to native at document start for every + // origin; the broker scopes consent per visited origin. + val shim = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString() + WebViewCompat.addWebMessageListener(webView, NappletWebContract.BRIDGE_NAME, setOf("*"), ::onBridgeMessage) + val startScript = "if (window.top === window) { window.__nappletDirectBridge = true; window.__nappletNip07 = true; }\n$shim" + WebViewCompat.addDocumentStartJavaScript(webView, startScript, setOf("*")) + + bindService(Intent().setClassName(this, NappletHostContract.BROKER_SERVICE_CLASS), brokerConnection, BIND_AUTO_CREATE) + onBackPressedDispatcher.addCallback(this, backCallback) + + val root = + FrameLayout(this).apply { + addView(contentFrame, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) + addView( + buildFloatingChip(), + FrameLayout + .LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.TOP or Gravity.END, + ).apply { setMargins(dp(8), dp(8), dp(8), dp(8)) }, + ) + } + setContentView(root) + // Pad by the system bars + cutout, but NOT the IME — windowSoftInputMode=adjustResize shrinks the + // window when the keyboard shows, and the WebView (filling contentFrame) resizes so focused inputs + // stay visible. Zero the consumed insets before they reach the WebView so it doesn't double-pad. + ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets -> + val applied = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + val bars = insets.getInsets(applied) + view.setPadding(bars.left, bars.top, bars.right, bars.bottom) + WindowInsetsCompat.Builder(insets).setInsets(applied, Insets.NONE).build() + } + + contentFrame.addView(webView, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) + loadingView = buildLoadingView().also { contentFrame.addView(it) } + + webView.loadUrl(startUrl) + } + + override fun onResume() { + super.onResume() + if (this::webView.isInitialized) { + webView.onResume() + webView.resumeTimers() + } + resumed = true + setBrokerForeground(true) + } + + override fun onPause() { + if (this::webView.isInitialized) { + webView.onPause() + webView.pauseTimers() + } + resumed = false + setBrokerForeground(false) + super.onPause() + } + + override fun onDestroy() { + runCatching { unbindService(brokerConnection) } + if (this::webView.isInitialized) webView.destroy() + super.onDestroy() + } + + /** Reports foreground state to the broker so the main process stays resumed (Tor/relays/AUTH). */ + private fun setBrokerForeground(foreground: Boolean) { + val msg = + Message.obtain(null, NappletIpc.MSG_SET_FOREGROUND).apply { + data = + Bundle().apply { + putString(NappletIpc.KEY_LAUNCH_TOKEN, startUrl) + putBoolean(NappletIpc.KEY_FOREGROUND, foreground) + } + } + if (brokerMessenger != null) sendToBroker(msg) + } + + @Suppress("SetJavaScriptEnabled") + private fun configureWebView(wv: WebView) { + wv.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + databaseEnabled = false + allowFileAccess = false + allowContentAccess = false + @Suppress("DEPRECATION") + allowFileAccessFromFileURLs = false + @Suppress("DEPRECATION") + allowUniversalAccessFromFileURLs = false + javaScriptCanOpenWindowsAutomatically = false + setSupportMultipleWindows(false) + setGeolocationEnabled(false) + mediaPlaybackRequiresUserGesture = true + builtInZoomControls = true + displayZoomControls = false + loadWithOverviewMode = true + useWideViewPort = true + mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + if (WebViewFeature.isFeatureSupported(WebViewFeature.SAFE_BROWSING_ENABLE)) { + safeBrowsingEnabled = true + } + } + WebView.setWebContentsDebuggingEnabled(false) + wv.webViewClient = BrowserClient() + } + + private inner class BrowserClient : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest, + ): Boolean { + val uri = request.url + val scheme = uri.scheme?.lowercase() + if (scheme == "http" || scheme == "https") return false + if (request.hasGesture()) { + runCatching { startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } + } + return true + } + + override fun onPageCommitVisible( + view: WebView, + url: String, + ) { + // The page has painted its first frame — drop the loading screen. + loadingView?.let { contentFrame.removeView(it) } + loadingView = null + } + + override fun doUpdateVisitedHistory( + view: WebView, + url: String, + isReload: Boolean, + ) = syncBackState() + + override fun onPageFinished( + view: WebView, + url: String, + ) = syncBackState() + } + + // ---- bridge: page <-> native (mirror of NappletBrowserService.onBridgeMessage) ---- + + private fun onBridgeMessage( + view: WebView, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + replyProxy: JavaScriptReplyProxy, + ) { + if (!isMainFrame) return + bridgeReplyProxy = replyProxy + val raw = message.data ?: return + val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return + + val scheme = sourceOrigin.scheme ?: return + val host = sourceOrigin.host ?: return + val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" + + val id = envelope.optString("id").ifEmpty { "fire-${fireSeq++}" } + val msg = + Message.obtain(null, NappletIpc.MSG_REQUEST).apply { + replyTo = replyMessenger + data = + Bundle().apply { + putString(NappletIpc.KEY_REQUEST_ID, id) + putString(NappletIpc.KEY_PAYLOAD, raw) + } + } + + val token = originTokens[origin] + if (token != null) { + msg.data.putString(NappletIpc.KEY_LAUNCH_TOKEN, token) + if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg) + } else { + pendingByOrigin.getOrPut(origin) { mutableListOf() }.add(msg) + requestBrowserToken(origin) + } + } + + private fun requestBrowserToken(origin: String) { + if (!mintInFlight.add(origin)) return + val msg = + Message.obtain(null, NappletIpc.MSG_MINT_BROWSER_TOKEN).apply { + replyTo = replyMessenger + data = Bundle().apply { putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) } + } + if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg) + } + + private fun sendToBroker(msg: Message) { + try { + brokerMessenger?.send(msg) + } catch (e: Exception) { + Log.w(TAG, "Failed to deliver request to broker", e) + } + } + + private fun onBrokerReply(msg: Message): Boolean { + val data = msg.data ?: return true + when (msg.what) { + NappletIpc.MSG_RESPONSE -> { + val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true + val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true + val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject() + result.put("id", id) + bridgeReplyProxy?.postMessage(result.toString()) + } + NappletIpc.MSG_PUSH -> { + val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true + bridgeReplyProxy?.postMessage(payload) + } + NappletIpc.MSG_BROWSER_TOKEN -> { + val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN) ?: return true + val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true + originTokens[origin] = token + mintInFlight.remove(origin) + pendingByOrigin.remove(origin)?.forEach { queued -> + queued.data.putString(NappletIpc.KEY_LAUNCH_TOKEN, token) + sendToBroker(queued) + } + } + else -> return false + } + return true + } + + /** + * Routes WebView traffic through the Tor SOCKS proxy when [port] > 0, else clears the override. + * Process-global (this `:napplet` process hosts only sandbox WebViews) and best-effort. + */ + private fun applyWebViewProxy(port: Int) { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) return + val executor = Executor { it.run() } + runCatching { + if (port > 0) { + val config = ProxyConfig.Builder().addProxyRule("socks5://127.0.0.1:$port").build() + ProxyController.getInstance().setProxyOverride(config, executor) {} + } else { + ProxyController.getInstance().clearProxyOverride(executor) {} + } + }.onFailure { Log.w(TAG, "Failed to apply WebView proxy override", it) } + } + + /** Persists the per-host Tor choice in the main process and re-applies it to the live WebView. */ + private fun setNetworkMode(newUseTor: Boolean) { + useTor = newUseTor + applyWebViewProxy(if (useTor) proxyPort else -1) + if (this::webView.isInitialized) webView.reload() + val host = runCatching { Uri.parse(startUrl).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: return + val msg = + Message.obtain(null, NappletIpc.MSG_SET_WEB_TOR).apply { + data = + Bundle().apply { + putString(NappletIpc.KEY_WEB_HOST, host) + putBoolean(NappletIpc.KEY_NETWORK_USE_TOR, newUseTor) + } + } + if (brokerMessenger != null) sendToBroker(msg) + } + + // ---- trusted chrome + loading ---- + + private var title: String = "" + + private fun barTitle(): String = title.ifBlank { runCatching { Uri.parse(startUrl).host }.getOrNull() ?: getString(R.string.napplet_untitled) } + + /** + * The floating control chip: a globe (trusted external-web marker) that expands on tap to the Tor + * toggle (when Tor is available) and reload. The page can't draw over it. + */ + private fun buildFloatingChip(): View { + val onSurface = resolveThemeColor(android.R.attr.textColorPrimary) + val dimmed = resolveThemeColor(android.R.attr.textColorSecondary) + + val actions = + LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + visibility = View.GONE + } + if (proxyPort > 0) { + actions.addView( + ImageView(this).apply { + setImageResource(R.drawable.ic_tor) + setColorFilter(if (useTor) onSurface else dimmed) + alpha = if (useTor) 1f else 0.4f + setPadding(dp(8), 0, dp(8), 0) + isClickable = true + setOnClickListener { setNetworkMode(!useTor) } + contentDescription = getString(if (useTor) R.string.napplet_net_tor_desc else R.string.napplet_net_open_desc) + layoutParams = LinearLayout.LayoutParams(dp(34), dp(22)) + }, + ) + } + actions.addView(chipGlyph("↻", onSurface, getString(R.string.napplet_chrome_reload)) { if (this::webView.isInitialized) webView.reload() }) + + val marker = + TextView(this).apply { + text = "🌐" // globe + textSize = 16f + setPadding(dp(4), dp(2), dp(4), dp(2)) + isClickable = true + contentDescription = barTitle() + setOnClickListener { + actions.visibility = if (actions.visibility == View.GONE) View.VISIBLE else View.GONE + } + } + + return LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(8), dp(6), dp(8), dp(6)) + elevation = dp(4).toFloat() + background = + android.graphics.drawable.GradientDrawable().apply { + cornerRadius = dp(24).toFloat() + setColor(resolveThemeColor(android.R.attr.colorBackground)) + } + addView(marker) + addView(actions) + } + } + + private fun chipGlyph( + glyph: String, + color: Int, + desc: String, + onClick: () -> Unit, + ): TextView = + TextView(this).apply { + text = glyph + setTextColor(color) + textSize = 18f + setPadding(dp(8), 0, dp(8), 0) + isClickable = true + contentDescription = desc + setOnClickListener { onClick() } + } + + private fun buildLoadingView(): View = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground)) + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + addView( + TextView(this@NappletBrowserActivity).apply { + text = barTitle() + setTextColor(resolveThemeColor(android.R.attr.textColorPrimary)) + textSize = 18f + gravity = Gravity.CENTER + }, + ) + addView(View(this@NappletBrowserActivity).apply { layoutParams = LinearLayout.LayoutParams(1, dp(20)) }) + addView(ProgressBar(this@NappletBrowserActivity)) + } + + private fun resolveThemeColor(attr: Int): Int { + val tv = android.util.TypedValue() + theme.resolveAttribute(attr, tv, true) + return if (tv.resourceId != 0) ContextCompat.getColor(this, tv.resourceId) else tv.data + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + + private fun readContractAsset(path: String): ByteArray = assets.open(NappletWebContract.RESOURCE_ASSET_ROOT + path).use { it.readBytes() } + + companion object { + private const val TAG = "NappletBrowserActivity" + private const val EXTRA_URL = "url" + private const val EXTRA_PROXY_PORT = "proxyPort" + private const val EXTRA_USE_TOR = "useTor" + private const val EXTRA_TITLE = "title" + + fun intent( + context: Context, + url: String, + proxyPort: Int, + useTor: Boolean, + title: String = "", + ): Intent = + Intent() + .setClassName(context, "com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity") + .putExtra(EXTRA_URL, url) + .putExtra(EXTRA_PROXY_PORT, proxyPort) + .putExtra(EXTRA_USE_TOR, useTor) + .putExtra(EXTRA_TITLE, title) + // Distinct task identity per URL for documentLaunchMode=intoExisting. + .setData(Uri.parse(url)) + } +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt index a9eb037238..cf3655d7b7 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt @@ -66,9 +66,19 @@ object NappletIpc { */ const val MSG_SET_FOREGROUND = 7 + /** + * Host → broker: persist the per-host Tor choice for the direct-WebView browser + * ([NappletBrowserActivity]). Carries [KEY_WEB_HOST] and [KEY_NETWORK_USE_TOR]. The `:napplet` + * process can't touch the main process's preference store, so it relays the choice here. + */ + const val MSG_SET_WEB_TOR = 8 + const val KEY_REQUEST_ID = "requestId" const val KEY_PAYLOAD = "payload" + /** The bare host (e.g. `example.com`) a browser Tor choice belongs to. */ + const val KEY_WEB_HOST = "webHost" + /** Boolean: this sandbox surface is now foreground (true) or backgrounded (false). */ const val KEY_FOREGROUND = "foreground"