feat: direct-WebView browser activity (scroll, zoom, keyboard)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
This commit is contained in:
Claude
2026-06-23 22:46:31 +00:00
parent b8786091dd
commit 1531f3baad
5 changed files with 591 additions and 5 deletions
+13
View File
@@ -424,6 +424,19 @@
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|keyboard|uiMode|navigation|fontScale|density"
android:theme="@style/Theme.Amethyst" />
<!-- Direct-WebView browser for a single web client. Runs in the isolated, keyless `:napplet`
process and hosts the WebView directly (not a streamed surface), so scroll/zoom/keyboard work
natively. adjustResize shrinks the window for the soft keyboard. Its own task/recents entry. -->
<activity
android:name="com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity"
android:process=":napplet"
android:exported="false"
android:autoRemoveFromRecents="true"
android:documentLaunchMode="intoExisting"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|keyboard|uiMode|navigation|fontScale|density"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Amethyst" />
<!-- Capability-consent dialog. Runs in the main process (the only side trusted to grant). -->
<activity
android:name=".napplet.NappletConsentActivity"
@@ -25,11 +25,13 @@ import android.content.Context
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserHostActivity
import com.vitorpamplona.amethyst.napplet.WebUrlNetworkRegistry
import com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
@@ -40,8 +42,9 @@ import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
* Turns a [FavoriteApp] back into a running app. The two cases map to the two launch paths in the
* codebase, nothing more:
*
* - [FavoriteApp.WebUrl] → a full-screen [BrowserHostActivity] (its own task/recents entry), so the
* web client owns the whole screen with no competing chrome.
* - [FavoriteApp.WebUrl] → a full-screen direct-WebView
* [NappletBrowserActivity][com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity] (its own
* task/recents entry), so the web client owns the whole screen and scrolls/zooms natively.
* - [FavoriteApp.NostrApp] → re-resolve the live event from [LocalCache] by coordinate, read its
* `requires`/website-mode off the event, then hand to [NappletLauncher] (the sandboxed `:napplet`
* host). nsite vs napplet is decided *here*, from the event, never from stored state.
@@ -60,13 +63,19 @@ object FavoriteAppLauncher {
}
}
/** Opens [url] full-screen in its own task, so back/recents treat it like a separate app. */
/**
* Opens [url] full-screen in its own task, so back/recents treat it like a separate app. Uses the
* direct-WebView [NappletBrowserActivity] (page scrolls/zooms and the keyboard resizes natively),
* resolving the proxy port + this site's remembered Tor choice here in the main process.
*/
fun launchUrl(
context: Context,
url: String,
) {
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
val useTor = proxyPort > 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)
@@ -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) {
@@ -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<Message>()
private var bridgeReplyProxy: JavaScriptReplyProxy? = null
private var fireSeq = 0
private val originTokens = mutableMapOf<String, String>()
private val pendingByOrigin = mutableMapOf<String, MutableList<Message>>()
private val mintInFlight = mutableSetOf<String>()
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))
}
}
@@ -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"