mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
+40
-1
@@ -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;
|
||||
|
||||
|
||||
+7
-1
@@ -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 {
|
||||
|
||||
+10
@@ -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"
|
||||
|
||||
+15
-1
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user