mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+37
-1
@@ -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,
|
||||
|
||||
+58
@@ -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
|
||||
}
|
||||
+50
-2
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+203
@@ -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
|
||||
|
||||
+12
@@ -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"
|
||||
|
||||
+19
-1
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user