diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserController.kt index edf183470e..a8060594aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserController.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt new file mode 100644 index 0000000000..35c0375c0f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt @@ -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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt index 1b58b21d8d..c8de72dad8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -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) { 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) { // 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) { ) } } + + // 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), + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt new file mode 100644 index 0000000000..99bf47a76c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt @@ -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() ?: "" + } + } +} diff --git a/commons/src/commonMain/composeResources/files/napplet/shim.js b/commons/src/commonMain/composeResources/files/napplet/shim.js index 7b6cb8cd94..86fd294d49 100644 --- a/commons/src/commonMain/composeResources/files/napplet/shim.js +++ b/commons/src/commonMain/composeResources/files/napplet/shim.js @@ -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 diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt index b6287c7063..ecffc88973 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -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" diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index 7988997d05..25c5c6f690 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -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 ""