feat: soft keyboard in embedded napplet/nsite tabs (same proxy, via the shell)

Extend the embedded keyboard to napplet/nsite surfaces. The shim's IME agent now
installs on any embedded surface (gated by __nappletImeProxy), reaching native
over whichever transport it has — the direct bridge for the browser, the trusted
shell relay for napplets — both through send().

- NappletContentServer gains an imeProxy flag that injects __nappletImeProxy
  before the shim; NappletHostService sets it (embedded), the full-screen
  NappletHostActivity leaves it off (native keyboard).
- NappletHostService relays ime.* between the applet (via the shell bridge) and
  the client (MSG_IME_EVENT / MSG_IME_OP) — the shell already forwards all
  message types, so no change to the trusted shell page.
- EmbeddedNappletController implements EmbeddedImeBridge, so EmbeddedTabLayer's
  RemoteImeView drives it exactly like the browser.

Correct for a single nsite/napplet tab. Multiple simultaneous napplet tabs share
the host service's single client/bridge pointer (same limitation as reload/back/
NIP-07 there) — making that per-tab needs the session-scoping the browser host
already got; tracked as 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-24 03:50:47 +00:00
parent e6bbecdea2
commit 09cd69ba7c
5 changed files with 76 additions and 6 deletions
@@ -37,7 +37,10 @@ import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
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
/**
* Client-side handle to an embedded nsite/napplet. Binds [NappletHostService][com.vitorpamplona.amethyst.napplethost.NappletHostService]
@@ -53,7 +56,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceContro
class EmbeddedNappletController(
private val appContext: Context,
private val params: Bundle,
) : EmbeddedSurfaceController {
) : EmbeddedSurfaceController,
EmbeddedImeBridge {
private val incoming = Messenger(Handler(Looper.getMainLooper(), ::onServiceMessage))
private var serviceMessenger: Messenger? = null
private var bound = false
@@ -72,6 +76,8 @@ class EmbeddedNappletController(
/** A granted "allow always" sensitive op just ran (one of NappletEmbedContract.NOTICE_*). */
var onNotice: ((String) -> Unit)? = null
override var onImeEvent: ((ImeEvent) -> Unit)? = null
private val connection =
object : ServiceConnection {
override fun onServiceConnected(
@@ -103,6 +109,7 @@ class EmbeddedNappletController(
pendingAdapter = null
onStateChanged = null
onNotice = null
onImeEvent = null
}
override fun attachView(view: SandboxedSdkView) {
@@ -151,11 +158,43 @@ class EmbeddedNappletController(
val notice = msg.data?.getString(NappletEmbedContract.KEY_NOTICE) ?: return true
onNotice?.invoke(notice)
}
NappletEmbedContract.MSG_IME_EVENT -> {
val payload = msg.data?.getString(NappletEmbedContract.KEY_IME_PAYLOAD) ?: return true
parseImeEvent(payload)?.let { event -> onImeEvent?.invoke(event) }
}
else -> return false
}
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) }
}
private fun parseImeEvent(payload: String): ImeEvent? {
val o = runCatching { JSONObject(payload) }.getOrNull() ?: return null
return when (o.optString("type")) {
"ime.focus" ->
ImeEvent.Focus(
inputType = o.optString("inputType", "text"),
enterKeyHint = o.optString("enterKeyHint", ""),
multiline = o.optBoolean("multiline", false),
text = o.optString("text", ""),
selStart = o.optInt("selStart", 0),
selEnd = o.optInt("selEnd", 0),
)
"ime.blur" -> ImeEvent.Blur
"ime.state" ->
ImeEvent.State(
text = o.optString("text", ""),
selStart = o.optInt("selStart", 0),
selEnd = o.optInt("selEnd", 0),
)
else -> null
}
}
fun back() = send(NappletEmbedContract.MSG_BACK)
fun reload() = send(NappletEmbedContract.MSG_RELOAD)
@@ -184,10 +184,11 @@
// The embedded browser surface renders cross-process via SurfaceControlViewHost, which forwards
// touch but NOT the soft keyboard (the embedded window can't be an IME target). So the host shows
// the keyboard in the main app window and relays editing here, where we apply it to the focused
// field with real input/composition events. Installed only on the EMBEDDED browser surface; the
// full-screen browser activity sets the direct bridge but not __nappletImeProxy (it has a native kbd).
// field with real input/composition events. Installed on any EMBEDDED surface (browser via the direct
// bridge, napplet/nSite via the shell relay); both transports go through send(). The full-screen
// hosts set neither flag (they have a native keyboard).
var IME_PROXY = false; try { IME_PROXY = !!window.__nappletImeProxy; } catch (_) {}
if (DIRECT && IME_PROXY) (function(){
if (IME_PROXY) (function(){
var el = null; // the focused editable element, or null
var inComposition = false;
@@ -60,6 +60,10 @@ class NappletContentServer(
// nSite "website mode": the applet is a normal web app — it gets a NIP-07 window.nostr provider,
// normal network (no app CSP; off-origin requests defer to the WebView), unlike a locked napplet.
private val websiteMode: Boolean = false,
// Embedded surfaces (a windowless Service) can't host the soft keyboard, so the shim installs the
// IME proxy agent that relays the focused field to the host's keyboard. The full-screen Activity
// host has a native keyboard and leaves this false.
private val imeProxy: Boolean = false,
) {
private val cache = NappletBlobCache(NappletBlobCache.dirFor(cacheDir))
private val http = NappletBlobHttp.client(proxyPort)
@@ -177,7 +181,9 @@ class NappletContentServer(
val style = "<style>html,body{overscroll-behavior:none !important}</style>"
// In website mode, set the NIP-07 flag synchronously *before* the shim so window.nostr installs.
val nip07Flag = if (websiteMode) "<script>window.__nappletNip07=true;</script>" else ""
val script = "$style$nip07Flag<script>$shimJs</script>"
// Embedded surface: turn on the IME proxy agent (set before the shim runs).
val imeFlag = if (imeProxy) "<script>window.__nappletImeProxy=true;</script>" else ""
val script = "$style$nip07Flag$imeFlag<script>$shimJs</script>"
val headIdx = text.indexOf("<head", ignoreCase = true)
val injected =
when {
@@ -68,9 +68,19 @@ object NappletEmbedContract {
*/
const val MSG_NOTICE = 12
/**
* Provider → client: the applet reported an IME event (focused editable / blur / external change);
* raw JSON in [KEY_IME_PAYLOAD]. The embedded surface can't host the keyboard, so the main app does.
*/
const val MSG_IME_EVENT = 13
/** Client → provider: an IME editing op for the focused field; raw JSON in [KEY_IME_PAYLOAD]. */
const val MSG_IME_OP = 14
const val KEY_CORE_LIB_INFO = "coreLibInfo"
const val KEY_CAN_GO_BACK = "canGoBack"
const val KEY_NOTICE = "notice"
const val KEY_IME_PAYLOAD = "imePayload"
const val NOTICE_PUBLISHED = "published"
const val NOTICE_UPLOADED = "uploaded"
@@ -141,6 +141,10 @@ class NappletHostService : Service() {
it.onResume()
it.resumeTimers()
}
NappletEmbedContract.MSG_IME_OP -> {
val payload = msg.data?.getString(NappletEmbedContract.KEY_IME_PAYLOAD) ?: return true
bridgeReplyProxy?.postMessage(payload)
}
else -> return false
}
return true
@@ -192,7 +196,7 @@ class NappletHostService : Service() {
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)
contentServer = NappletContentServer(paths, servers, effectiveProxy, cacheDir, shellHtml, shim, appOrigin, websiteMode, imeProxy = true)
val wv = WebView(context)
hardenWebView(wv)
@@ -312,6 +316,16 @@ class NappletHostService : Service() {
return
}
// IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client.
if (envelope.optString("type").startsWith("ime.")) {
val reply =
Message.obtain(null, NappletEmbedContract.MSG_IME_EVENT).apply {
data = Bundle().apply { putString(NappletEmbedContract.KEY_IME_PAYLOAD, raw) }
}
runCatching { clientMessenger?.send(reply) }
return
}
val id = envelope.optString("id").ifEmpty { "fire-${fireSeq++}" }
val msg =
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {