diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNappletController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNappletController.kt index c3f8024026..d0839d6293 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNappletController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNappletController.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent import org.json.JSONObject +import java.util.concurrent.atomic.AtomicLong /** * Client-side handle to an embedded nsite/napplet. Binds [NappletHostService][com.vitorpamplona.amethyst.napplethost.NappletHostService] @@ -65,6 +66,10 @@ class EmbeddedNappletController( private var sandboxedSdkView: SandboxedSdkView? = null private var pendingAdapter: SandboxedUiAdapter? = null + // A single NappletHostService instance serves every embedded napplet tab, so each controller stamps + // its own id on every message; the provider uses it to route controls/state/IME to this tab. + private val sessionId: String = "napplet-${SESSION_SEQ.incrementAndGet()}" + // A parked tab can be hidden (paused) before the service even binds, so the pause message is // dropped (no messenger yet). Remember the intent and replay it right after the session is created, // otherwise an applet that was never shown comes up running in the background. @@ -133,7 +138,7 @@ class EmbeddedNappletController( val msg = Message.obtain(null, NappletEmbedContract.MSG_CREATE_SESSION).apply { replyTo = incoming - data = Bundle(params) + data = Bundle(params).apply { putString(NappletEmbedContract.KEY_SESSION_ID, sessionId) } } runCatching { serviceMessenger?.send(msg) } // Replay a pause that was requested before we had a messenger to send it on (parked-before-bound), @@ -167,10 +172,7 @@ class EmbeddedNappletController( return true } - override fun sendImeOp(json: String) { - val msg = Message.obtain(null, NappletEmbedContract.MSG_IME_OP).apply { data = Bundle().apply { putString(NappletEmbedContract.KEY_IME_PAYLOAD, json) } } - runCatching { serviceMessenger?.send(msg) } - } + override fun sendImeOp(json: String) = send(NappletEmbedContract.MSG_IME_OP) { putString(NappletEmbedContract.KEY_IME_PAYLOAD, json) } private fun parseImeEvent(payload: String): ImeEvent? { val o = runCatching { JSONObject(payload) }.getOrNull() ?: return null @@ -210,8 +212,22 @@ class EmbeddedNappletController( send(NappletEmbedContract.MSG_RESUME) } - private fun send(what: Int) { - val msg = Message.obtain(null, what) + private inline fun send( + what: Int, + crossinline block: Bundle.() -> Unit = {}, + ) { + val msg = + Message.obtain(null, what).apply { + data = + Bundle().apply { + putString(NappletEmbedContract.KEY_SESSION_ID, sessionId) + block() + } + } runCatching { serviceMessenger?.send(msg) } } + + private companion object { + private val SESSION_SEQ = AtomicLong() + } } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt index 6034fb266a..7ec7e44000 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt @@ -82,6 +82,13 @@ object NappletEmbedContract { const val KEY_NOTICE = "notice" const val KEY_IME_PAYLOAD = "imePayload" + /** + * Opaque per-tab session id the client stamps on [MSG_CREATE_SESSION] and every control message. A + * single provider instance is shared by all embedded napplet/nSite tabs (they bind the same Intent), + * so this scopes a control to the right surface and routes state/notices/IME back to the right tab. + */ + const val KEY_SESSION_ID = "sessionId" + const val NOTICE_PUBLISHED = "published" const val NOTICE_UPLOADED = "uploaded" const val NOTICE_PAID = "paid" diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index 1a4cff9130..91988cbb2b 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -72,29 +72,40 @@ import java.util.concurrent.Executor @RequiresApi(Build.VERSION_CODES.R) class NappletHostService : Service() { private val incoming = Messenger(Handler(Looper.getMainLooper(), ::onClientMessage)) - private var clientMessenger: Messenger? = null - private val paths = mutableListOf() - private val servers = mutableListOf() - private var author = "" - private var identifier = "" - private var launchToken = "" - private var websiteMode = false - private var useTor = true - private var proxyPort = -1 - private var bgColor = android.graphics.Color.WHITE - private var declaredDomains: List = emptyList() + /** + * Per-embedded-tab state. A single NappletHostService instance is shared by every embedded + * napplet/nSite tab (they bind the same Intent), so each tab's config, content server, WebView, and + * broker bridge live here, keyed by [sessionId]. Each tab has its OWN [replyMessenger] so broker + * responses AND unsolicited pushes come back tagged to the right tab (the broker echoes replyTo). + */ + private inner class NappletTab( + val sessionId: String, + var clientMessenger: Messenger?, + val paths: List, + val servers: List, + val author: String, + val identifier: String, + val launchToken: String, + val websiteMode: Boolean, + val useTor: Boolean, + val proxyPort: Int, + val bgColor: Int, + val declaredDomains: List, + ) { + var contentServer: NappletContentServer? = null + var webView: WebView? = null + var bridgeReplyProxy: JavaScriptReplyProxy? = null + var fireSeq = 0 + val replyMessenger = Messenger(Handler(Looper.getMainLooper()) { onBrokerReply(this, it) }) + } - private lateinit var contentServer: NappletContentServer + private val tabs = mutableMapOf() - private var webView: WebView? = null - - // ---- broker bridge (identical trust model to NappletHostActivity: one launch token) ---- + // ---- broker bridge: ONE binding shared by all tabs; per-message replyTo scopes the routing ---- private var brokerMessenger: Messenger? = null - private val replyMessenger = Messenger(Handler(Looper.getMainLooper(), ::onBrokerReply)) + private var brokerBound = false private val pendingBrokerRequests = mutableListOf() - private var bridgeReplyProxy: JavaScriptReplyProxy? = null - private var fireSeq = 0 private val brokerConnection = object : ServiceConnection { @@ -115,71 +126,90 @@ class NappletHostService : Service() { override fun onBind(intent: Intent?): IBinder = incoming.binder override fun onDestroy() { - runCatching { unbindService(brokerConnection) } - webView?.destroy() - webView = null + if (brokerBound) { + runCatching { unbindService(brokerConnection) } + brokerBound = false + } + tabs.values.forEach { it.webView?.destroy() } + tabs.clear() super.onDestroy() } + private fun tabFor(msg: Message): NappletTab? = msg.data?.getString(NappletEmbedContract.KEY_SESSION_ID)?.let { tabs[it] } + + private fun tabFor(view: WebView): NappletTab? = tabs.values.firstOrNull { it.webView === view } + private fun onClientMessage(msg: Message): Boolean { when (msg.what) { NappletEmbedContract.MSG_CREATE_SESSION -> { - if (!readParams(msg)) return true - clientMessenger = msg.replyTo - bindService(Intent().setClassName(this, NappletHostContract.BROKER_SERVICE_CLASS), brokerConnection, BIND_AUTO_CREATE) - replyWithAdapter() + val tab = buildTab(msg) ?: return true + tabs[tab.sessionId] = tab + // Bind the broker once; a re-sent MSG_CREATE_SESSION must not leak a second binding. + if (!brokerBound) { + brokerBound = bindService(Intent().setClassName(this, NappletHostContract.BROKER_SERVICE_CLASS), brokerConnection, BIND_AUTO_CREATE) + } + replyWithAdapter(tab) } - NappletEmbedContract.MSG_BACK -> webView?.let { if (it.canGoBack()) it.goBack() } - NappletEmbedContract.MSG_RELOAD -> webView?.reload() + NappletEmbedContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() } + NappletEmbedContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload() NappletEmbedContract.MSG_PAUSE -> - webView?.let { + tabFor(msg)?.webView?.let { it.onPause() it.pauseTimers() } NappletEmbedContract.MSG_RESUME -> - webView?.let { + tabFor(msg)?.webView?.let { it.onResume() it.resumeTimers() } NappletEmbedContract.MSG_IME_OP -> { + val tab = tabFor(msg) ?: return true val payload = msg.data?.getString(NappletEmbedContract.KEY_IME_PAYLOAD) ?: return true - bridgeReplyProxy?.postMessage(payload) + tab.bridgeReplyProxy?.postMessage(payload) } else -> return false } return true } - private fun readParams(msg: Message): Boolean { - val data = msg.data ?: return false - val pathList = data.getStringArrayList(NappletHostContract.EXTRA_PATHS) ?: return false - val hashList = data.getStringArrayList(NappletHostContract.EXTRA_HASHES) ?: return false - if (pathList.size != hashList.size || pathList.isEmpty()) return false - for (i in pathList.indices) paths.add(PathTag(pathList[i], hashList[i])) - servers.addAll(data.getStringArrayList(NappletHostContract.EXTRA_SERVERS) ?: emptyList()) - author = data.getString(NappletHostContract.EXTRA_AUTHOR).orEmpty() - identifier = data.getString(NappletHostContract.EXTRA_IDENTIFIER).orEmpty() - websiteMode = data.getBoolean(NappletHostContract.EXTRA_WEBSITE_MODE, false) - useTor = data.getBoolean(NappletHostContract.EXTRA_USE_TOR, true) - proxyPort = data.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1) - bgColor = data.getInt(NappletHostContract.EXTRA_BG_COLOR, android.graphics.Color.WHITE) - launchToken = data.getString(NappletHostContract.EXTRA_LAUNCH_TOKEN).orEmpty() + private fun buildTab(msg: Message): NappletTab? { + val data = msg.data ?: return null + val sessionId = data.getString(NappletEmbedContract.KEY_SESSION_ID) ?: return null + val pathList = data.getStringArrayList(NappletHostContract.EXTRA_PATHS) ?: return null + val hashList = data.getStringArrayList(NappletHostContract.EXTRA_HASHES) ?: return null + if (pathList.size != hashList.size || pathList.isEmpty()) return null + val author = data.getString(NappletHostContract.EXTRA_AUTHOR).orEmpty() + val launchToken = data.getString(NappletHostContract.EXTRA_LAUNCH_TOKEN).orEmpty() + if (author.isEmpty() || launchToken.isEmpty()) return null val requires = data.getStringArrayList(NappletHostContract.EXTRA_REQUIRES) ?: emptyList() - declaredDomains = (listOf("shell") + resolveRequiredCapabilities(requires).capabilities.map { it.name.lowercase() }).distinct() + val declaredDomains = (listOf("shell") + resolveRequiredCapabilities(requires).capabilities.map { it.name.lowercase() }).distinct() - return author.isNotEmpty() && launchToken.isNotEmpty() + return NappletTab( + sessionId = sessionId, + clientMessenger = msg.replyTo, + paths = pathList.indices.map { PathTag(pathList[it], hashList[it]) }, + servers = data.getStringArrayList(NappletHostContract.EXTRA_SERVERS) ?: emptyList(), + author = author, + identifier = data.getString(NappletHostContract.EXTRA_IDENTIFIER).orEmpty(), + launchToken = launchToken, + websiteMode = data.getBoolean(NappletHostContract.EXTRA_WEBSITE_MODE, false), + useTor = data.getBoolean(NappletHostContract.EXTRA_USE_TOR, true), + proxyPort = data.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1), + bgColor = data.getInt(NappletHostContract.EXTRA_BG_COLOR, android.graphics.Color.WHITE), + declaredDomains = declaredDomains, + ) } - /** Builds the SandboxedUiAdapter and ships its cross-process handle (coreLibInfo) to the client. */ - private fun replyWithAdapter() { - val adapter = NappletHostUiAdapter(this) + /** Builds the SandboxedUiAdapter for [tab] and ships its cross-process handle (coreLibInfo) to the client. */ + private fun replyWithAdapter(tab: NappletTab) { + val adapter = NappletHostUiAdapter(this, tab.sessionId) val coreLibInfo = adapter.toCoreLibInfo(this) val reply = Message.obtain(null, NappletEmbedContract.MSG_SESSION_READY).apply { data = Bundle().apply { putBundle(NappletEmbedContract.KEY_CORE_LIB_INFO, coreLibInfo) } } - runCatching { clientMessenger?.send(reply) } + runCatching { tab.clientMessenger?.send(reply) } } /** @@ -187,38 +217,39 @@ class NappletHostService : Service() { * surface). Mirrors [NappletHostActivity]: serves the shell + verified blobs through the content * server, installs the origin-restricted shell bridge, loads the trusted shell URL. */ - fun createHostWebView(context: Context): WebView { - // NOTE: a single NappletHostService instance is shared by every embedded napplet tab (they bind - // the same Intent), but each tab opens its own session with its own WebView; [webView] is just a - // "latest" pointer. Do NOT destroy it here — that would tear down a *sibling* tab's live WebView - // and black it out. Each session owns its WebView until it closes. + fun createHostWebView( + context: Context, + sessionId: String, + ): WebView { + val wv = WebView(context) + val tab = tabs[sessionId] ?: return wv val shellHtml = readContractAsset(NappletWebContract.SHELL_HTML_PATH) val shim = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString() - val appOrigin = NappletWebContract.appOrigin(deriveAppId(author, identifier)) - val effectiveProxy = if (useTor) proxyPort else -1 - contentServer = NappletContentServer(paths, servers, effectiveProxy, cacheDir, shellHtml, shim, appOrigin, websiteMode, imeProxy = true) + val appOrigin = NappletWebContract.appOrigin(deriveAppId(tab.author, tab.identifier)) + val effectiveProxy = if (tab.useTor) tab.proxyPort else -1 + tab.contentServer = NappletContentServer(tab.paths, tab.servers, effectiveProxy, cacheDir, shellHtml, shim, appOrigin, tab.websiteMode, imeProxy = true) - val wv = WebView(context) - hardenWebView(wv) + hardenWebView(wv, tab) // Theme the pre-load background so the shell/app loading shows Amethyst's background, not white. - wv.setBackgroundColor(bgColor) + wv.setBackgroundColor(tab.bgColor) wv.dropSystemBarInsets() - if (websiteMode) applyWebViewProxy(effectiveProxy) + if (tab.websiteMode) applyWebViewProxy(effectiveProxy) WebViewCompat.addWebMessageListener(wv, NappletWebContract.BRIDGE_NAME, setOf(NappletWebContract.ORIGIN), ::onShellMessage) - webView = wv + tab.webView = wv wv.loadUrl(NappletWebContract.SHELL_URL) return wv } - /** A session closed: destroy that session's own WebView and clear the shared pointer only if it - * still referenced it (a sibling tab may have become the latest). */ - fun onSessionClosed(closed: WebView) { - if (webView === closed) webView = null - closed.destroy() + /** A session closed: drop the tab and destroy its own WebView (never a sibling's). */ + fun onSessionClosed(sessionId: String) { + tabs.remove(sessionId)?.webView?.destroy() } @Suppress("SetJavaScriptEnabled") - private fun hardenWebView(wv: WebView) { + private fun hardenWebView( + wv: WebView, + tab: NappletTab, + ) { wv.settings.apply { javaScriptEnabled = true domStorageEnabled = true @@ -241,7 +272,7 @@ class NappletHostService : Service() { } wv.overScrollMode = View.OVER_SCROLL_NEVER WebView.setWebContentsDebuggingEnabled(false) - wv.webViewClient = HostClient() + wv.webViewClient = HostClient(tab) } private fun applyWebViewProxy(port: Int) { @@ -258,22 +289,24 @@ class NappletHostService : Service() { } /** Serves only the trusted shell and the manifest's verified blobs; external links go to the system. */ - private inner class HostClient : WebViewClient() { + private inner class HostClient( + private val tab: NappletTab, + ) : WebViewClient() { override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest, - ): WebResourceResponse? = contentServer.serve(request) + ): WebResourceResponse? = tab.contentServer?.serve(request) override fun doUpdateVisitedHistory( view: WebView, url: String, isReload: Boolean, - ) = pushState(view) + ) = pushState(tab, view) override fun onPageFinished( view: WebView, url: String, - ) = pushState(view) + ) = pushState(tab, view) override fun shouldOverrideUrlLoading( view: WebView, @@ -288,12 +321,15 @@ class NappletHostService : Service() { } } - private fun pushState(view: WebView) { + private fun pushState( + tab: NappletTab, + view: WebView, + ) { val message = Message.obtain(null, NappletEmbedContract.MSG_STATE).apply { data = Bundle().apply { putBoolean(NappletEmbedContract.KEY_CAN_GO_BACK, view.canGoBack()) } } - runCatching { clientMessenger?.send(message) } + runCatching { tab.clientMessenger?.send(message) } } // ---- bridge: shell <-> native (mirror of NappletHostActivity.onShellMessage) ---- @@ -306,13 +342,14 @@ class NappletHostService : Service() { replyProxy: JavaScriptReplyProxy, ) { if (!isMainFrame) return - bridgeReplyProxy = replyProxy + val tab = tabFor(view) ?: return + tab.bridgeReplyProxy = replyProxy val raw = message.data ?: return val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return if (envelope.optString("type") == "shell.ready") { - runCatching { replyProxy.postMessage(NappletProtocolJson.encodeShellInit(declaredDomains, declaredDomains)) } + runCatching { replyProxy.postMessage(NappletProtocolJson.encodeShellInit(tab.declaredDomains, tab.declaredDomains)) } return } @@ -322,19 +359,19 @@ class NappletHostService : Service() { Message.obtain(null, NappletEmbedContract.MSG_IME_EVENT).apply { data = Bundle().apply { putString(NappletEmbedContract.KEY_IME_PAYLOAD, raw) } } - runCatching { clientMessenger?.send(reply) } + runCatching { tab.clientMessenger?.send(reply) } return } - val id = envelope.optString("id").ifEmpty { "fire-${fireSeq++}" } + val id = envelope.optString("id").ifEmpty { "fire-${tab.fireSeq++}" } val msg = Message.obtain(null, NappletIpc.MSG_REQUEST).apply { - replyTo = replyMessenger + replyTo = tab.replyMessenger data = Bundle().apply { putString(NappletIpc.KEY_REQUEST_ID, id) putString(NappletIpc.KEY_PAYLOAD, raw) - putString(NappletIpc.KEY_LAUNCH_TOKEN, launchToken) + putString(NappletIpc.KEY_LAUNCH_TOKEN, tab.launchToken) } } if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg) @@ -348,7 +385,11 @@ class NappletHostService : Service() { } } - private fun onBrokerReply(msg: Message): Boolean { + /** Broker reply for [tab] — delivered to the tab's own reply Messenger, so it's already scoped. */ + private fun onBrokerReply( + tab: NappletTab, + msg: Message, + ): Boolean { val data = msg.data ?: return true when (msg.what) { NappletIpc.MSG_RESPONSE -> { @@ -356,12 +397,12 @@ class NappletHostService : Service() { val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject() result.put("id", id) - notifyIfSensitive(result) - bridgeReplyProxy?.postMessage(result.toString()) + notifyIfSensitive(tab, result) + tab.bridgeReplyProxy?.postMessage(result.toString()) } NappletIpc.MSG_PUSH -> { val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true - bridgeReplyProxy?.postMessage(payload) + tab.bridgeReplyProxy?.postMessage(payload) } else -> return false } @@ -369,7 +410,10 @@ class NappletHostService : Service() { } /** Pushes a notice to the main process for a granted "allow always" sensitive op, so it can toast. */ - private fun notifyIfSensitive(result: JSONObject) { + private fun notifyIfSensitive( + tab: NappletTab, + result: JSONObject, + ) { if (!result.optBoolean("ok")) return val notice = when (result.optString("type")) { @@ -382,7 +426,7 @@ class NappletHostService : Service() { Message.obtain(null, NappletEmbedContract.MSG_NOTICE).apply { data = Bundle().apply { putString(NappletEmbedContract.KEY_NOTICE, notice) } } - runCatching { clientMessenger?.send(message) } + runCatching { tab.clientMessenger?.send(message) } } private fun readContractAsset(path: String): ByteArray = assets.open(NappletWebContract.RESOURCE_ASSET_ROOT + path).use { it.readBytes() } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostUiAdapter.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostUiAdapter.kt index 0d2bea9f5d..c5b3fdcf5d 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostUiAdapter.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostUiAdapter.kt @@ -44,6 +44,7 @@ import java.util.concurrent.Executor @RequiresApi(Build.VERSION_CODES.R) class NappletHostUiAdapter( private val service: NappletHostService, + private val sessionId: String, ) : AbstractSandboxedUiAdapter() { private val mainHandler = Handler(Looper.getMainLooper()) @@ -59,11 +60,11 @@ class NappletHostUiAdapter( // WebView creation must run on the main thread; openSession is called on a binder thread. mainHandler.post { runCatching { - val webView = service.createHostWebView(context) + val webView = service.createHostWebView(context, sessionId) // FrameLayout.LayoutParams (a MarginLayoutParams) — the SurfaceControlViewHost container // measures children with measureChildWithMargins, which casts to MarginLayoutParams. webView.layoutParams = FrameLayout.LayoutParams(initialWidth, initialHeight) - HostSession(webView, service) + HostSession(sessionId, webView, service) }.onSuccess { session -> clientExecutor.execute { client.onSessionOpened(session) } } .onFailure { t -> clientExecutor.execute { client.onSessionError(t) } } } @@ -73,6 +74,7 @@ class NappletHostUiAdapter( /** A single embedded napplet/nsite session: the WebView is the rendered view; close tears it down. */ @RequiresApi(Build.VERSION_CODES.R) private class HostSession( + private val sessionId: String, private val webView: WebView, private val service: NappletHostService, ) : SandboxedUiAdapter.Session { @@ -97,6 +99,6 @@ private class HostSession( override fun notifyUiChanged(uiContainerInfo: Bundle) {} override fun close() { - service.onSessionClosed(webView) + service.onSessionClosed(sessionId) } }