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 7eee31f71e..c3f8024026 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 @@ -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) diff --git a/commons/src/commonMain/composeResources/files/napplet/shim.js b/commons/src/commonMain/composeResources/files/napplet/shim.js index 94b6f301a5..e701b76f93 100644 --- a/commons/src/commonMain/composeResources/files/napplet/shim.js +++ b/commons/src/commonMain/composeResources/files/napplet/shim.js @@ -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; diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt index 0b569af9c4..d8c48bcc24 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletContentServer.kt @@ -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 = "" // In website mode, set the NIP-07 flag synchronously *before* the shim so window.nostr installs. val nip07Flag = if (websiteMode) "" else "" - val script = "$style$nip07Flag" + // Embedded surface: turn on the IME proxy agent (set before the shim runs). + val imeFlag = if (imeProxy) "" else "" + val script = "$style$nip07Flag$imeFlag" val headIdx = text.indexOf(" { + 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 {