feat: soft keyboard in the embedded browser via a host-window input proxy

The embedded browser renders cross-process through SurfaceControlViewHost, which
forwards touch but not the soft keyboard (the embedded window can't be an IME
target, and androidx.privacysandbox.ui never wires IME). So focusing a field in
an embedded page did nothing.

Bridge the keyboard instead: host it in the main app window and relay editing to
the page.

- Shim IME agent (embedded browser only, gated by __nappletImeProxy): tracks the
  focused editable, reports focus/blur/external-change, and applies host ops
  (commit / compose / delete / key / editor-action) with real input & composition
  events. Scrolls the field into view on focus.
- NappletBrowserService relays ime.* envelopes between the page bridge and the
  client (MSG_IME_EVENT / MSG_IME_OP), per tab.
- EmbeddedBrowserController implements EmbeddedImeBridge (parses events, sends ops).
- RemoteImeView: an invisible EditText in the main window that takes the keyboard
  for the active tab. Keeps a real local Editable (so the platform handles
  composing/suggestions/selection) while an InputConnection wrapper forwards every
  op to the page. Maps web input types / enterKeyHint to inputType/IME action.
- EmbeddedTabLayer hosts the proxy bound to the active tab and shrinks the active
  surface by the IME height so the page can scroll the field clear of the keyboard.

Covers <input>/<textarea> fully and contenteditable best-effort (plain text).
Napplet/nsite embeds still need their own wiring (the shell path); this is the
browser surface.

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:13:25 +00:00
parent e3e2482954
commit f0d5e7a626
7 changed files with 514 additions and 4 deletions
@@ -36,7 +36,10 @@ import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract
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
/**
@@ -51,7 +54,8 @@ class EmbeddedBrowserController(
private val proxyPort: Int,
private val initialUseTor: Boolean,
private val backgroundColor: Int,
) : EmbeddedSurfaceController {
) : EmbeddedSurfaceController,
EmbeddedImeBridge {
private val incoming = Messenger(Handler(Looper.getMainLooper(), ::onServiceMessage))
private var serviceMessenger: Messenger? = null
private var bound = false
@@ -67,6 +71,8 @@ class EmbeddedBrowserController(
/** Invoked on the main thread when the page navigates: (url, canGoBack). */
var onUrlChanged: ((String, Boolean) -> Unit)? = null
override var onImeEvent: ((ImeEvent) -> Unit)? = null
private val connection =
object : ServiceConnection {
override fun onServiceConnected(
@@ -98,6 +104,7 @@ class EmbeddedBrowserController(
sandboxedSdkView = null
pendingAdapter = null
onUrlChanged = null
onImeEvent = null
}
override fun teardown() = unbind()
@@ -143,6 +150,10 @@ class EmbeddedBrowserController(
val canGoBack = msg.data?.getBoolean(NappletBrowserContract.KEY_CAN_GO_BACK, false) ?: false
onUrlChanged?.invoke(url, canGoBack)
}
NappletBrowserContract.MSG_IME_EVENT -> {
val payload = msg.data?.getString(NappletBrowserContract.KEY_IME_PAYLOAD) ?: return true
parseImeEvent(payload)?.let { event -> onImeEvent?.invoke(event) }
}
else -> return false
}
return true
@@ -156,6 +167,31 @@ class EmbeddedBrowserController(
fun setTor(useTor: Boolean) = send(NappletBrowserContract.MSG_SET_TOR) { putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor) }
override fun sendImeOp(json: String) = send(NappletBrowserContract.MSG_IME_OP) { putString(NappletBrowserContract.KEY_IME_PAYLOAD, json) }
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
}
}
private inline fun send(
what: Int,
crossinline block: Bundle.() -> Unit,
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
/**
* A cross-process editable: the focused field lives in the embedded WebView (a different process/window
* that can't host the soft keyboard), so the main app keeps the keyboard and relays editing across this
* bridge. The host view ([RemoteImeView]) drives the keyboard from [ImeEvent]s and forwards every edit
* op back via [sendImeOp]. Implemented by the controllers whose surface can take text input.
*/
interface EmbeddedImeBridge {
/** Page → host: a field gained focus, the field blurred, or its text changed outside the keyboard. */
var onImeEvent: ((ImeEvent) -> Unit)?
/** Host → page: a single IME op as a JSON envelope (`{type:"ime.commit", ...}`). */
fun sendImeOp(json: String)
}
/** What the focused page field reports up to the host keyboard. */
sealed interface ImeEvent {
/** A field took focus; carries enough to configure the keyboard and seed the editing buffer. */
data class Focus(
val inputType: String,
val enterKeyHint: String,
val multiline: Boolean,
val text: String,
val selStart: Int,
val selEnd: Int,
) : ImeEvent
/** The field lost focus — dismiss the keyboard. */
data object Blur : ImeEvent
/** The page changed the field's text/selection itself; resync the keyboard's buffer. */
data class State(
val text: String,
val selStart: Int,
val selEnd: Int,
) : ImeEvent
}
@@ -26,11 +26,14 @@ import androidx.annotation.RequiresApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
@@ -41,7 +44,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
@@ -78,12 +83,21 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
val bounds = EmbeddedTabHost.contentBounds
var layerOrigin by remember { mutableStateOf(Offset.Zero) }
var layerSize by remember { mutableStateOf(IntSize.Zero) }
val density = LocalDensity.current
// While the soft keyboard is up (hosted by [RemoteImeView] in this window), shrink the active
// surface so its bottom clears the keyboard — the embedded WebView then reflows and scrolls the
// focused field into view. Only the portion of the keyboard that overlaps the surface counts.
val imeBottomPx = WindowInsets.ime.getBottom(density)
Box(
Modifier
.fillMaxSize()
.onGloballyPositioned { layerOrigin = it.positionInWindow() },
.onGloballyPositioned {
layerOrigin = it.positionInWindow()
layerSize = it.size
},
) {
EmbeddedTabHost.sessions.forEach { session ->
key(session.id) {
@@ -101,9 +115,10 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
// surface re-render, no black flash between tabs. (Parking at 1dp forced a
// resize + re-render on every switch, which flashed black for ~1s.)
val left = (bounds.left - layerOrigin.x).toDp() + (if (active) 0.dp else OFFSCREEN_SHIFT)
val imeOverlap = if (active) (imeBottomPx - (layerOrigin.y + layerSize.height - bounds.bottom)).coerceAtLeast(0f) else 0f
Modifier
.absoluteOffset(left, (bounds.top - layerOrigin.y).toDp())
.size(bounds.width.toDp(), bounds.height.toDp())
.size(bounds.width.toDp(), (bounds.height - imeOverlap).coerceAtLeast(1f).toDp())
}
} else {
// No content bounds reported yet: park tiny off-screen until a tab is shown.
@@ -160,5 +175,38 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
)
}
}
// The embedded surface can't host the soft keyboard, so an invisible main-window EditText takes
// it whenever a field in the ACTIVE tab focuses, relaying edits across [EmbeddedImeBridge]. Lives
// here, in the main app window, so it can actually receive the IME.
val context = LocalContext.current
val imeBridge = EmbeddedTabHost.sessions.firstOrNull { it.id == activeId }?.controller as? EmbeddedImeBridge
val imeView = remember { RemoteImeView(context) }
DisposableEffect(imeBridge) {
imeView.bind(imeBridge)
imeBridge?.onImeEvent = { event ->
when (event) {
is ImeEvent.Focus -> imeView.onPageFocus(event)
ImeEvent.Blur -> imeView.onPageBlur()
is ImeEvent.State -> imeView.onPageState(event)
}
}
onDispose {
imeBridge?.onImeEvent = null
imeView.onPageBlur()
imeView.bind(null)
}
}
with(density) {
AndroidView(
factory = { imeView },
modifier =
Modifier
.absoluteOffset(
(bounds.left - layerOrigin.x).toDp().coerceAtLeast(0.dp),
(bounds.top - layerOrigin.y).toDp().coerceAtLeast(0.dp),
).size(1.dp),
)
}
}
}
@@ -0,0 +1,203 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import android.annotation.SuppressLint
import android.content.Context
import android.text.InputType
import android.view.KeyEvent
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputConnectionWrapper
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import org.json.JSONObject
/**
* The main-app-window home for the soft keyboard when a field is focused inside an embedded WebView. The
* embedded surface is a cross-process [android.view.SurfaceControlViewHost] window that can't be an IME
* target, so this invisible [EditText] takes the keyboard in the main window instead and relays every
* edit to the page.
*
* It keeps a real local [android.text.Editable], so the platform handles composing regions, suggestions,
* selection, and `getTextBeforeCursor` correctly. A thin [InputConnection] wrapper *also* forwards each
* op (commit / compose / delete / key / editor-action) to the page as a JSON envelope, where the shim
* applies it with real input/composition events. Page-side changes come back as [ImeEvent.State] and are
* mirrored into the local buffer so the keyboard stays in sync.
*/
@SuppressLint("ViewConstructor", "AppCompatCustomView")
class RemoteImeView(
context: Context,
) : EditText(context) {
private var bridge: EmbeddedImeBridge? = null
// True while we mutate our own text from a page-side update, so the resulting edits aren't echoed
// back to the page (which would loop).
private var applyingRemote = false
private val imm get() = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
init {
// Invisible but focusable: the IME needs a laid-out, visible target, but the user must never see
// this field or its cursor/selection handles — only the embedded page.
isFocusableInTouchMode = true
alpha = 0f
background = null
setTextColor(0x00000000)
setCursorVisible(false)
setPadding(0, 0, 0, 0)
}
/** Binds the controller of whatever embedded tab is active; null unbinds (no relay target). */
fun bind(bridge: EmbeddedImeBridge?) {
this.bridge = bridge
}
/** A page field focused: configure the keyboard, seed the buffer, and raise the IME. */
fun onPageFocus(focus: ImeEvent.Focus) {
configureFor(focus)
applyingRemote = true
setText(focus.text)
setSelection(clamp(focus.selStart), clamp(focus.selEnd))
applyingRemote = false
requestFocus()
imm.restartInput(this)
imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}
/** The page changed the field itself (its JS, autofill): mirror it without echoing back. */
fun onPageState(state: ImeEvent.State) {
if (text?.toString() == state.text && selectionStart == state.selStart && selectionEnd == state.selEnd) return
applyingRemote = true
setText(state.text)
setSelection(clamp(state.selStart), clamp(state.selEnd))
applyingRemote = false
}
/** The page field blurred: drop the keyboard. */
fun onPageBlur() {
clearFocus()
imm.hideSoftInputFromWindow(windowToken, 0)
}
private fun clamp(i: Int): Int = i.coerceIn(0, text?.length ?: 0)
private fun configureFor(focus: ImeEvent.Focus) {
inputType =
when (focus.inputType) {
"password" -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD
"email" -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS
"url" -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
"number" -> InputType.TYPE_CLASS_NUMBER
"tel" -> InputType.TYPE_CLASS_PHONE
else ->
InputType.TYPE_CLASS_TEXT or
(if (focus.multiline) InputType.TYPE_TEXT_FLAG_MULTI_LINE else InputType.TYPE_TEXT_VARIATION_NORMAL)
}
imeOptions = editorActionFor(focus) or EditorInfo.IME_FLAG_NO_FULLSCREEN or EditorInfo.IME_FLAG_NO_EXTRACT_UI
}
private fun editorActionFor(focus: ImeEvent.Focus): Int =
when (focus.enterKeyHint) {
"go" -> EditorInfo.IME_ACTION_GO
"search" -> EditorInfo.IME_ACTION_SEARCH
"send" -> EditorInfo.IME_ACTION_SEND
"next" -> EditorInfo.IME_ACTION_NEXT
"done" -> EditorInfo.IME_ACTION_DONE
else -> if (focus.multiline) EditorInfo.IME_ACTION_NONE else EditorInfo.IME_ACTION_GO
}
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
val base = super.onCreateInputConnection(outAttrs) ?: return null
return ForwardingConnection(base)
}
private fun op(envelope: JSONObject) {
if (!applyingRemote) bridge?.sendImeOp(envelope.toString())
}
/** Wraps the platform connection: edits the local buffer (via super) AND relays each op to the page. */
private inner class ForwardingConnection(
target: InputConnection,
) : InputConnectionWrapper(target, true) {
override fun commitText(
text: CharSequence,
newCursorPosition: Int,
): Boolean {
op(JSONObject().put("type", "ime.commit").put("text", text.toString()))
return super.commitText(text, newCursorPosition)
}
override fun setComposingText(
text: CharSequence,
newCursorPosition: Int,
): Boolean {
op(JSONObject().put("type", "ime.composing").put("text", text.toString()))
return super.setComposingText(text, newCursorPosition)
}
override fun finishComposingText(): Boolean {
op(JSONObject().put("type", "ime.finishComposing"))
return super.finishComposingText()
}
override fun deleteSurroundingText(
beforeLength: Int,
afterLength: Int,
): Boolean {
op(JSONObject().put("type", "ime.delete").put("before", beforeLength).put("after", afterLength))
return super.deleteSurroundingText(beforeLength, afterLength)
}
override fun setSelection(
start: Int,
end: Int,
): Boolean {
op(JSONObject().put("type", "ime.setSelection").put("start", start).put("end", end))
return super.setSelection(start, end)
}
override fun sendKeyEvent(event: KeyEvent): Boolean {
if (event.action == KeyEvent.ACTION_DOWN) {
op(JSONObject().put("type", "ime.key").put("keyCode", event.keyCode).put("key", keyLabel(event)))
}
return super.sendKeyEvent(event)
}
override fun performEditorAction(editorAction: Int): Boolean {
op(JSONObject().put("type", "ime.action").put("action", editorAction))
// Don't call super: the local buffer has no "submit"; the page handles the action.
return true
}
private fun keyLabel(event: KeyEvent): String =
when (event.keyCode) {
KeyEvent.KEYCODE_ENTER -> "Enter"
KeyEvent.KEYCODE_DEL -> "Backspace"
KeyEvent.KEYCODE_TAB -> "Tab"
else ->
event.unicodeChar
.takeIf { it != 0 }
?.toChar()
?.toString() ?: ""
}
}
}
@@ -86,6 +86,8 @@
}
// keys.action push: the shell triggers a registered keyboard/command action.
if (msg.type === 'keys.action') { var cb = actions[msg.actionId]; if (cb) cb(); return; }
// IME ops (host keyboard -> focused page field). Only the direct-bridge browser installs the agent.
if (msg.type && msg.type.indexOf('ime.') === 0) { if (window.__nappletImeHandle) window.__nappletImeHandle(msg); return; }
// identity.changed push: the active user's key changed (account switch / connect / disconnect).
if (msg.type === 'identity.changed') { identityHandlers.slice().forEach(function(h){ try { h(msg.pubkey); } catch (_) {} }); return; }
if (!msg.id) return;
@@ -178,6 +180,139 @@
};
window.napplet = Object.freeze(napplet);
// ---- IME agent (in-app browser only) -------------------------------------------------------
// 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).
var IME_PROXY = false; try { IME_PROXY = !!window.__nappletImeProxy; } catch (_) {}
if (DIRECT && IME_PROXY) (function(){
var el = null; // the focused editable element, or null
var composing = null; // { start, end } of the active composing region in el's value, or null
function isEditable(n){
if (!n) return false;
if (n.isContentEditable) return true;
var t = (n.tagName || '').toUpperCase();
if (t === 'TEXTAREA') return true;
if (t === 'INPUT') {
var ty = (n.type || 'text').toLowerCase();
return ['text','search','url','email','tel','password','number',''].indexOf(ty) >= 0;
}
return false;
}
function isCE(n){ return !!(n && n.isContentEditable); }
function valOf(n){ return isCE(n) ? n.textContent : (n.value || ''); }
function selOf(n){
if (isCE(n)) { var v = n.textContent.length; return [v, v]; }
return [n.selectionStart || 0, n.selectionEnd || 0];
}
function setSel(n, s, e){ if (!isCE(n)) { try { n.setSelectionRange(s, e); } catch (_) {} } }
function dispatchInput(n, inputType, data){
var ev;
try { ev = new InputEvent('input', { bubbles: true, cancelable: false, inputType: inputType, data: data }); }
catch (_) { ev = new Event('input', { bubbles: true }); }
n.__nappletIme = true;
try { n.dispatchEvent(ev); } finally { n.__nappletIme = false; }
}
// Replace [start,end) in n with text, move the caret after it, and fire an input event.
function replaceRange(n, start, end, text, inputType){
var v = valOf(n);
start = Math.max(0, Math.min(start, v.length));
end = Math.max(start, Math.min(end, v.length));
var next = v.slice(0, start) + text + v.slice(end);
if (isCE(n)) { n.textContent = next; } else { n.value = next; }
var caret = start + text.length;
setSel(n, caret, caret);
dispatchInput(n, inputType, text);
return caret;
}
function focusInfo(n){
var t = (n.tagName || '').toUpperCase();
var multiline = isCE(n) || t === 'TEXTAREA';
var inputType = isCE(n) ? 'text' : (t === 'TEXTAREA' ? 'textarea' : (n.type || 'text').toLowerCase());
var sel = selOf(n);
return { type:'ime.focus', inputType: inputType, enterKeyHint: (n.enterKeyHint || ''),
multiline: multiline, text: valOf(n), selStart: sel[0], selEnd: sel[1] };
}
function reportState(){
if (!el) return;
var sel = selOf(el);
send({ type:'ime.state', text: valOf(el), selStart: sel[0], selEnd: sel[1] });
}
document.addEventListener('focusin', function(e){
if (isEditable(e.target)) {
el = e.target; composing = null; send(focusInfo(el));
// The host shrinks the surface for the keyboard, but also nudge the field into view in case IME
// insets aren't delivered (some hosts) so it never sits behind the keyboard.
try { el.scrollIntoView({ block: 'center', inline: 'nearest' }); } catch (_) {}
} else if (el) { el = null; composing = null; send({ type:'ime.blur' }); }
}, true);
document.addEventListener('focusout', function(e){
if (e.target === el) { el = null; composing = null; send({ type:'ime.blur' }); }
}, true);
// The page (its own JS, autofill) changed the field: resync the host keyboard's view of it.
document.addEventListener('input', function(e){ if (e.target === el && !el.__nappletIme) reportState(); }, true);
document.addEventListener('selectionchange', function(){ if (el && !isCE(el) && !el.__nappletIme) reportState(); }, true);
function dispatchKey(n, key, kc){
['keydown','keyup'].forEach(function(type){
try { n.dispatchEvent(new KeyboardEvent(type, { bubbles: true, cancelable: true, key: key || '', keyCode: kc, which: kc })); } catch (_) {}
});
}
function enter(n){
var t = (n.tagName || '').toUpperCase();
if (isCE(n) || t === 'TEXTAREA') { var s = selOf(n); replaceRange(n, s[0], s[1], '\n', 'insertLineBreak'); return; }
dispatchKey(n, 'Enter', 13);
if (n.form) { try { (n.form.requestSubmit ? n.form.requestSubmit() : n.form.submit()); } catch (_) {} }
}
// Apply one host->page IME op to the focused field, then echo authoritative state back.
window.__nappletImeHandle = function(msg){
if (!el) return;
var n = el, sel = selOf(n);
switch (msg.type) {
case 'ime.commit': {
var cs = composing ? composing.start : sel[0];
var ce = composing ? composing.end : sel[1];
replaceRange(n, cs, ce, msg.text || '', 'insertText');
composing = null;
break;
}
case 'ime.composing': {
var s = composing ? composing.start : sel[0];
var e = composing ? composing.end : sel[1];
replaceRange(n, s, e, msg.text || '', 'insertCompositionText');
composing = (msg.text && msg.text.length) ? { start: s, end: s + msg.text.length } : null;
break;
}
case 'ime.finishComposing': composing = null; break;
case 'ime.delete': {
var before = msg.before || 0, after = msg.after || 0;
if (sel[0] !== sel[1]) { replaceRange(n, sel[0], sel[1], '', 'deleteContentBackward'); }
else { replaceRange(n, Math.max(0, sel[0] - before), sel[1] + after, '', 'deleteContentBackward'); }
composing = null;
break;
}
case 'ime.setSelection': setSel(n, msg.start, msg.end); break;
case 'ime.key': {
var kc = msg.keyCode | 0;
if (kc === 67) { // Android KEYCODE_DEL (backspace)
if (sel[0] !== sel[1]) replaceRange(n, sel[0], sel[1], '', 'deleteContentBackward');
else if (sel[0] > 0) replaceRange(n, sel[0] - 1, sel[0], '', 'deleteContentBackward');
} else if (kc === 66) { enter(n); } // KEYCODE_ENTER
else { dispatchKey(n, msg.key, kc); }
composing = null;
break;
}
case 'ime.action': enter(n); break;
}
reportState();
};
})();
// NIP-07 provider (window.nostr), installed only for nSites in website mode (the host sets
// window.__nappletNip07 synchronously before this shim). Lets standard Nostr web apps "log in with
// Amethyst" and sign, bridged to the same consent-gated signer: getPublicKey + getRelays reuse the
@@ -53,6 +53,18 @@ object NappletBrowserContract {
/** Provider → client: the page navigated; carries [KEY_URL] and [KEY_CAN_GO_BACK]. */
const val MSG_URL_CHANGED = 7
/**
* Provider client: the page reported an IME event (a focused editable, a blur, or an external
* text change). The raw JSON envelope (`{type:"ime.focus"|...}`) is carried in [KEY_IME_PAYLOAD]; the
* embedded surface can't host the soft keyboard, so the main app shows it and relays editing.
*/
const val MSG_IME_EVENT = 8
/** Client → provider: an IME editing op for the focused field; raw JSON in [KEY_IME_PAYLOAD]. */
const val MSG_IME_OP = 9
const val KEY_IME_PAYLOAD = "imePayload"
const val KEY_URL = "url"
const val KEY_PROXY_PORT = "proxyPort"
const val KEY_USE_TOR = "useTor"
@@ -153,6 +153,11 @@ class NappletBrowserService : Service() {
NappletBrowserContract.MSG_NAVIGATE -> tabFor(msg)?.webView?.loadUrl(normalizeUrl(msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty()))
NappletBrowserContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload()
NappletBrowserContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() }
NappletBrowserContract.MSG_IME_OP -> {
val tab = tabFor(msg) ?: return true
val payload = msg.data?.getString(NappletBrowserContract.KEY_IME_PAYLOAD) ?: return true
tab.bridgeReplyProxy?.postMessage(payload)
}
NappletBrowserContract.MSG_SET_TOR -> {
val tab = tabFor(msg) ?: return true
tab.useTor = msg.data?.getBoolean(NappletBrowserContract.KEY_USE_TOR, false) ?: false
@@ -200,7 +205,10 @@ class NappletBrowserService : Service() {
onBridgeMessage(tab, view, message, sourceOrigin, isMainFrame, replyProxy)
}
}
val startScript = "if (window.top === window) { window.__nappletDirectBridge = true; window.__nappletNip07 = true; }\n$shim"
// __nappletImeProxy: this is the EMBEDDED surface (no native keyboard), so install the IME agent
// that relays the focused field to the host's keyboard. The full-screen browser activity sets the
// direct bridge but NOT this flag (it has a real WebView window with a native keyboard).
val startScript = "if (window.top === window) { window.__nappletDirectBridge = true; window.__nappletNip07 = true; window.__nappletImeProxy = true; }\n$shim"
WebViewCompat.addDocumentStartJavaScript(wv, startScript, setOf("*"))
tab?.webView = wv
wv.loadUrl(tab?.url ?: "about:blank")
@@ -348,6 +356,16 @@ class NappletBrowserService : Service() {
val raw = message.data ?: return
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: 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, NappletBrowserContract.MSG_IME_EVENT).apply {
data = Bundle().apply { putString(NappletBrowserContract.KEY_IME_PAYLOAD, raw) }
}
runCatching { tab.clientMessenger?.send(reply) }
return
}
val scheme = sourceOrigin.scheme ?: return
val host = sourceOrigin.host ?: return
val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else ""