refactor: remove dead full-screen browser path superseded by embedded surface

The full-screen NappletHostActivity browser-mode and NappletLauncher.launchBrowser
were added alongside the embedded browser but never wired into any UI. The embedded
surface (NappletBrowserService rendered via SurfaceControlViewHost) supersedes them
and draws the address bar in the trusted main process rather than inside the sandbox,
so this removes the weaker, unused surface.

Removed:
- NappletHostActivity browser-mode (setupBrowser, address bar, per-origin bridge) —
  reverted the host activity to its pre-browser state.
- NappletLauncher.launchBrowser and the EXTRA_BROWSER_MODE/EXTRA_BROWSER_URL extras.
- The now-unused nappletHost browser strings.

Kept (used by the embedded path): the broker per-origin token mint
(MSG_MINT_BROWSER_TOKEN / MSG_BROWSER_TOKEN / KEY_BROWSER_ORIGIN) in NappletBrokerService
and NappletIpc, so NIP-07 consent stays scoped per visited origin.

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 13:12:00 +00:00
parent d63bf87d2a
commit 785af41c99
5 changed files with 6 additions and 393 deletions
@@ -41,28 +41,6 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
* declared capabilities, and a display title. No account state crosses into the sandbox process.
*/
object NappletLauncher {
/**
* Opens the in-app web browser at [url] in the sandboxed [NappletHostActivity] (the keyless
* `:napplet` process). Unlike an nSite, it loads an arbitrary **live** URL behind an editable
* address bar; it still injects the consent-gated NIP-07 `window.nostr`, scoped per visited origin
* (the sandbox mints a per-origin token from the broker). Routes through Tor when Tor is active.
*/
fun launchBrowser(
context: Context,
url: String,
) {
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
val intent =
Intent(context, NappletHostActivity::class.java).apply {
putExtra(NappletHostContract.EXTRA_BROWSER_MODE, true)
putExtra(NappletHostContract.EXTRA_BROWSER_URL, url)
putExtra(NappletHostContract.EXTRA_PROXY_PORT, proxyPort)
putExtra(NappletHostContract.EXTRA_USE_TOR, proxyPort > 0)
if (context !is android.app.Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
/** Opens a NIP-5D napplet, forwarding its declared capabilities to the broker. */
fun launch(
context: Context,
@@ -54,9 +54,10 @@ import org.json.JSONObject
* JS context, cookies, or the NIP-07 bridge). Keys still live only in the main process; every NIP-07
* `window.nostr` call is brokered and consent-gated there, per visited origin.
*
* Mirrors [NappletHostActivity]'s browser path, but as a windowless Service so the surface can be
* embedded in the main activity rather than taking over the screen. Requires API 30+
* (SurfaceControlViewHost); the feature is hidden below that.
* Shares the keyless-sandbox trust model of [NappletHostActivity] (the verified-blob nSite/napplet
* host), but as a windowless Service rendering an arbitrary live URL, so the surface can be embedded in
* the main activity rather than taking over the screen. Requires API 30+ (SurfaceControlViewHost); the
* feature is hidden below that.
*/
@RequiresApi(Build.VERSION_CODES.R)
class NappletBrowserService : Service() {
@@ -24,7 +24,6 @@ import android.app.AlertDialog
import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.os.Handler
@@ -32,22 +31,18 @@ import android.os.IBinder
import android.os.Looper
import android.os.Message
import android.os.Messenger
import android.text.InputType
import android.util.Log
import android.util.TypedValue
import android.view.Gravity
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
@@ -116,22 +111,6 @@ class NappletHostActivity : ComponentActivity() {
// nSite "website mode": a normal web app (NIP-07 window.nostr + normal network), vs a locked napplet.
private var websiteMode: Boolean = false
// "Browser mode": render an arbitrary live URL with an editable address bar (see EXTRA_BROWSER_MODE).
// No manifest/content server; NIP-07 is injected per visited origin.
private var browserMode: Boolean = false
// The address bar shown in browser mode.
private var addressBar: android.widget.EditText? = null
// Visited origin (e.g. https://example.com) → its broker-minted launch token, plus the requests that
// arrived for an origin before its token was minted, and the set of origins a mint is in flight for.
private val browserOriginTokens = mutableMapOf<String, String>()
private val browserPendingByOrigin = mutableMapOf<String, MutableList<Message>>()
private val browserMintInFlight = mutableSetOf<String>()
// The onion toggle in the browser bar, kept so its tint can follow the live useTor state.
private var browserTorIcon: ImageView? = null
// Per-site network choice: route this site's traffic through Tor (default) or over the open web.
// Applied to both the WebView proxy and the blob-fetch client; toggled from the top-bar onion.
private var useTor: Boolean = true
@@ -204,11 +183,6 @@ class NappletHostActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent.getBooleanExtra(NappletHostContract.EXTRA_BROWSER_MODE, false)) {
setupBrowser()
return
}
if (!readManifestExtras()) {
Toast.makeText(this, getString(R.string.napplet_invalid), Toast.LENGTH_SHORT).show()
finish()
@@ -225,7 +199,8 @@ class NappletHostActivity : ComponentActivity() {
// once up front so the WebView worker threads that serve them never block on resource I/O.
// This isolated `:napplet` process skips app init (to stay key-free), so the compose-resources
// Android context is never set here and `Res.readBytes` would crash — read the contract bytes
// straight from the APK assets, where compose-resources packages them (see [readContractAsset]).
// straight from the APK assets, where compose-resources packages them.
fun readContractAsset(path: String): ByteArray = assets.open(NappletWebContract.RESOURCE_ASSET_ROOT + path).use { it.readBytes() }
val shellHtml = readContractAsset(NappletWebContract.SHELL_HTML_PATH)
val shim = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString()
val appOrigin = NappletWebContract.appOrigin(deriveAppId(author, identifier))
@@ -383,315 +358,6 @@ class NappletHostActivity : ComponentActivity() {
return author.isNotEmpty() && launchToken.isNotEmpty()
}
/**
* Reads a shared napplet web-contract asset (shell HTML / shim JS) straight from the APK assets. This
* isolated `:napplet` process skips app init to stay key-free, so the compose-resources Android context
* is never set and `Res.readBytes` would crash; the bytes are packaged under
* [NappletWebContract.RESOURCE_ASSET_ROOT].
*/
private fun readContractAsset(path: String): ByteArray = assets.open(NappletWebContract.RESOURCE_ASSET_ROOT + path).use { it.readBytes() }
// ---- browser mode ----
/**
* Sets up the in-app web browser: a hardened, keyless WebView behind an editable address bar, loading
* an arbitrary live URL. The same consent-gated NIP-07 `window.nostr` shim the shell injects is
* injected here at document start for every origin, but each visited origin gets its own broker-minted
* launch token (see [onBrowserMessage]) so a grant to one site never leaks to another.
*/
private fun setupBrowser() {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) ||
!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
) {
Toast.makeText(this, getString(R.string.napplet_webview_too_old), Toast.LENGTH_LONG).show()
finish()
return
}
browserMode = true
proxyPort = intent.getIntExtra(NappletHostContract.EXTRA_PROXY_PORT, -1)
useTor = intent.getBooleanExtra(NappletHostContract.EXTRA_USE_TOR, proxyPort > 0)
val startUrl = normalizeUrl(intent.getStringExtra(NappletHostContract.EXTRA_BROWSER_URL).orEmpty())
webView = WebView(this)
configureBrowserWebView(webView)
applyWebViewProxy(if (useTor) proxyPort else -1)
// Inject the NIP-07 shim at document start for every origin, and let it reach native directly (no
// shell). addWebMessageListener exposes the bridge object the shim posts to.
val shim = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString()
WebViewCompat.addWebMessageListener(webView, NappletWebContract.BRIDGE_NAME, setOf("*"), ::onShellMessage)
// Only the TOP frame becomes an Amethyst-aware Nostr page; sub-frames get neither flag, so they
// don't try (and fail) to reach native — their NIP-07 calls would otherwise hang on isMainFrame.
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 =
LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
addView(buildBrowserBar())
addView(buildDivider())
addView(contentFrame, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f))
}
setContentView(root)
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))
started = true
webView.loadUrl(startUrl)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// singleTask: relaunching the browser with a new URL reuses this instance, so load the new URL.
if (browserMode && intent.getBooleanExtra(NappletHostContract.EXTRA_BROWSER_MODE, false)) {
val url = intent.getStringExtra(NappletHostContract.EXTRA_BROWSER_URL)
if (!url.isNullOrBlank() && this::webView.isInitialized) webView.loadUrl(normalizeUrl(url))
}
}
@Suppress("SetJavaScriptEnabled")
private fun configureBrowserWebView(webView: WebView) {
webView.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
if (WebViewFeature.isFeatureSupported(WebViewFeature.SAFE_BROWSING_ENABLE)) {
safeBrowsingEnabled = true
}
}
WebView.setWebContentsDebuggingEnabled(false)
webView.webViewClient = BrowserWebViewClient()
}
/** Loads live web pages in-WebView (http/https) and hands other schemes to the system on a user tap. */
private inner class BrowserWebViewClient : 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 // navigate inside the browser
// Non-web schemes (mailto:, tel:, lightning:, bitcoin:, intent:, …) only on a real user tap, so
// a hostile page can't silently fire intents.
if (request.hasGesture()) {
runCatching { startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
}
return true
}
override fun onPageStarted(
view: WebView,
url: String,
favicon: Bitmap?,
) {
updateAddressBar(url)
syncBackState()
}
override fun doUpdateVisitedHistory(
view: WebView,
url: String,
isReload: Boolean,
) {
updateAddressBar(url)
syncBackState()
}
override fun onPageFinished(
view: WebView,
url: String,
) {
updateAddressBar(url)
syncBackState()
}
}
/** Reflects the WebView's current URL in the address bar, unless the user is editing it. */
private fun updateAddressBar(url: String) {
val bar = addressBar ?: return
if (bar.hasFocus()) return
if (url == "about:blank") return
bar.setText(url)
}
/** Editable address bar + reload, plus the Tor onion toggle when Tor is available. */
private fun buildBrowserBar(): View {
val onSurface = resolveThemeColor(android.R.attr.textColorPrimary)
val bar =
LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground))
setPadding(dp(10), dp(8), dp(10), dp(8))
}
val input =
EditText(this).apply {
setSingleLine()
hint = getString(R.string.napplet_browser_address_hint)
setHintTextColor(resolveThemeColor(android.R.attr.textColorSecondary))
setTextColor(onSurface)
textSize = 15f
inputType = InputType.TYPE_TEXT_VARIATION_URI
imeOptions = EditorInfo.IME_ACTION_GO
setSelectAllOnFocus(true)
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
setOnEditorActionListener { v, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_GO) {
navigateTo(v.text.toString())
clearFocus()
hideKeyboard(v)
true
} else {
false
}
}
}
addressBar = input
bar.addView(input)
bar.addView(
TextView(this).apply {
text = "" // reload
setTextColor(onSurface)
textSize = 20f
setPadding(dp(12), 0, dp(4), 0)
isClickable = true
contentDescription = getString(R.string.napplet_browser_reload_desc)
setOnClickListener { if (this@NappletHostActivity::webView.isInitialized) webView.reload() }
},
)
// Tor onion: lit when routing through Tor, dimmed over the open web. Tap flips it for the session.
if (proxyPort > 0) {
browserTorIcon =
ImageView(this).apply {
setImageResource(R.drawable.ic_tor)
applyTorTint(this)
setPadding(dp(8), 0, dp(4), 0)
isClickable = true
setOnClickListener { toggleBrowserTor() }
contentDescription = getString(if (useTor) R.string.napplet_net_tor_desc else R.string.napplet_net_open_desc)
layoutParams = LinearLayout.LayoutParams(dp(34), dp(22))
}
bar.addView(browserTorIcon)
}
return bar
}
private fun applyTorTint(icon: ImageView) {
icon.setColorFilter(if (useTor) resolveThemeColor(android.R.attr.textColorPrimary) else resolveThemeColor(android.R.attr.textColorSecondary))
icon.alpha = if (useTor) 1f else 0.4f
}
/** Flips this browser session between Tor and the open web, re-applies the proxy, and reloads. */
private fun toggleBrowserTor() {
useTor = !useTor
applyWebViewProxy(if (useTor) proxyPort else -1)
browserTorIcon?.let {
applyTorTint(it)
it.contentDescription = getString(if (useTor) R.string.napplet_net_tor_desc else R.string.napplet_net_open_desc)
}
if (this::webView.isInitialized) webView.reload()
}
private fun navigateTo(input: String) {
if (this::webView.isInitialized) webView.loadUrl(normalizeUrl(input))
}
/**
* Turns address-bar text into a URL: keeps an explicit scheme, prefixes a bare domain with `https://`,
* and treats anything else (spaces, or no dot) as a DuckDuckGo search.
*/
private fun normalizeUrl(input: String): String {
val text = input.trim()
if (text.isEmpty()) return "about:blank"
if (text.contains("://")) return text
if (!text.contains(' ') && text.contains('.')) return "https://$text"
return "https://duckduckgo.com/?q=" + Uri.encode(text)
}
private fun hideKeyboard(view: View) {
val imm = getSystemService(INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.hideSoftInputFromWindow(view.windowToken, 0)
}
/**
* Browser-mode bridge handler: a top-frame NIP-07 call from [sourceOrigin]. The origin is the trusted
* value the WebView reports (the page can't forge it), so it keys consent. Each origin uses its own
* broker-minted launch token; until that token arrives the request is queued and a mint is requested.
*/
private fun onBrowserMessage(
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 = browserOriginTokens[origin]
if (token != null) {
msg.data.putString(NappletIpc.KEY_LAUNCH_TOKEN, token)
if (brokerMessenger == null) pendingRequests.add(msg) else sendToBroker(msg)
} else {
browserPendingByOrigin.getOrPut(origin) { mutableListOf() }.add(msg)
requestBrowserToken(origin)
}
}
/** Asks the broker (once per origin) to mint a per-origin launch token. */
private fun requestBrowserToken(origin: String) {
if (!browserMintInFlight.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) pendingRequests.add(msg) else sendToBroker(msg)
}
/**
* Stable, unique, DNS-label-safe id for this applet's sandbox origin: a sha256 of
* `author:identifier`, hex, truncated to 31 chars and letter-prefixed (`n`). The leading letter
@@ -813,10 +479,6 @@ class NappletHostActivity : ComponentActivity() {
isMainFrame: Boolean,
replyProxy: JavaScriptReplyProxy,
) {
if (browserMode) {
onBrowserMessage(message, sourceOrigin, isMainFrame, replyProxy)
return
}
if (!isMainFrame) return // only the trusted shell, never a sub-frame
bridgeReplyProxy = replyProxy
@@ -893,18 +555,6 @@ class NappletHostActivity : ComponentActivity() {
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
bridgeReplyProxy?.postMessage(payload)
}
// Browser mode: the broker minted the per-origin launch token; cache it and flush the requests
// that were waiting on it (each now carries the token, so the broker can resolve their identity).
NappletIpc.MSG_BROWSER_TOKEN -> {
val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN) ?: return true
val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true
browserOriginTokens[origin] = token
browserMintInFlight.remove(origin)
browserPendingByOrigin.remove(origin)?.forEach { queued ->
queued.data.putString(NappletIpc.KEY_LAUNCH_TOKEN, token)
sendToBroker(queued)
}
}
else -> return false
}
return true
@@ -66,18 +66,6 @@ object NappletHostContract {
*/
const val EXTRA_USE_TOR = "napplet_use_tor"
/**
* "Browser mode": the host renders an arbitrary **live** URL ([EXTRA_BROWSER_URL]) with an editable
* address bar instead of a verified-blob nSite/napplet. It still runs in the keyless `:napplet`
* process and injects the same consent-gated NIP-07 `window.nostr`, but per **visited origin** — the
* sandbox mints a separate launch token per origin from the broker, so a grant to one site never
* leaks to another. No manifest, content server, or launch token is passed up front.
*/
const val EXTRA_BROWSER_MODE = "napplet_browser_mode"
/** The initial URL to load in [EXTRA_BROWSER_MODE]. */
const val EXTRA_BROWSER_URL = "napplet_browser_url"
/**
* FQN of the main-process broker service (in `:amethyst`). The sandbox binds it by name so it
* needs no compile-time reference to `:amethyst`. Must match the manifest `<service>` declaration.
@@ -24,10 +24,6 @@
<string name="napplet_net_switch_open">Use open web</string>
<string name="napplet_net_switch_tor">Use Tor</string>
<!-- In-app browser (arbitrary live URLs) -->
<string name="napplet_browser_address_hint">Search or enter address</string>
<string name="napplet_browser_reload_desc">Reload page</string>
<!-- Loading / unavailable screens -->
<string name="napplet_unavailable_title">Couldn\'t load “%1$s”</string>
<string name="napplet_unavailable_subtitle">The publisher\'s servers may be offline, or you\'re not connected. You can try again.</string>