mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-24 13:17:10 +08:00
fix(android): stabilize IME composition and deletion handling (#15370)
## Summary Fix Android editor IME corruption around composition, autocorrect replay, delete, Enter, and old WebView delete behavior. ## What changed - Add Android WebView InputConnection wrapper for editor IME handling. - Route Android delete events through BlockSuite editor input. - Guard against keyboard autocorrect/composition replay after delete or space. - Stabilize delete fallback on older Android/WebView versions. - Gate IME diagnostic logs behind debug builds. - Add Android IME fix notes and regression coverage. ## Validation - Manual Android testing passed. - `git diff --check upstream/canary...HEAD` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved Android text editing for Backspace, Delete, Enter, composing text, and keyboard events. * Prevented input methods from targeting the wrong editor area. * Improved caret-based text handling, focus synchronization, and composing-session cleanup. * **Platform Improvements** * Added a dedicated Android input bridge for smoother IME interactions and fallback keyboard behavior. * Improved editor actions, input recovery, and trusted-page validation for Android communication. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
+80
@@ -0,0 +1,80 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputConnection
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import com.getcapacitor.CapacitorWebView
|
||||
|
||||
class AffineEditorWebView(
|
||||
context: Context,
|
||||
attrs: AttributeSet,
|
||||
) : CapacitorWebView(context, attrs) {
|
||||
private val imeState = AndroidImeState()
|
||||
private var imeBridgeInstalled = false
|
||||
@Volatile
|
||||
private var isTrustedPage = false
|
||||
|
||||
private val imeBridge = AffineImeBridge(
|
||||
isTrustedPage = { this.isTrustedPage },
|
||||
clearComposingState = { imeState.nextClearRequestGeneration() },
|
||||
requestRestartInput = ::requestRestartInput,
|
||||
onEditorFocusedChanged = { focused -> imeState.editorFocused = focused },
|
||||
)
|
||||
|
||||
fun updateAndroidIMEBridge(url: String?, expectedOrigin: String?) {
|
||||
val shouldInstallBridge = isTrustedAffineOrigin(url, expectedOrigin)
|
||||
if (shouldInstallBridge == imeBridgeInstalled) {
|
||||
isTrustedPage = shouldInstallBridge
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldInstallBridge) {
|
||||
addJavascriptInterface(imeBridge, AFFINE_IME_BRIDGE_NAME)
|
||||
imeBridgeInstalled = true
|
||||
isTrustedPage = true
|
||||
} else {
|
||||
isTrustedPage = false
|
||||
imeBridgeInstalled = false
|
||||
removeJavascriptInterface(AFFINE_IME_BRIDGE_NAME)
|
||||
imeState.editorFocused = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
|
||||
val connection = super.onCreateInputConnection(outAttrs) ?: return null
|
||||
return AffineInputConnection(
|
||||
connection,
|
||||
imeState,
|
||||
dispatchDeleteBackward = {
|
||||
dispatchAndroidEditorInput(this, AndroidImeInputType.BACKWARD_DELETE)
|
||||
},
|
||||
dispatchDeleteForward = {
|
||||
dispatchAndroidEditorInput(this, AndroidImeInputType.FORWARD_DELETE)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestRestartInput(delayMs: Long) {
|
||||
val restartGeneration = imeState.nextRestartGeneration()
|
||||
if (delayMs <= 0L) {
|
||||
post { restartInput() }
|
||||
return
|
||||
}
|
||||
|
||||
postDelayed(
|
||||
{
|
||||
if (restartGeneration != imeState.restartInputGeneration) return@postDelayed
|
||||
restartInput()
|
||||
},
|
||||
delayMs,
|
||||
)
|
||||
}
|
||||
|
||||
private fun restartInput() {
|
||||
val inputMethodManager =
|
||||
context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||
inputMethodManager?.restartInput(this)
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.net.Uri
|
||||
import android.webkit.JavascriptInterface
|
||||
|
||||
internal const val AFFINE_IME_BRIDGE_NAME = "AffineAndroidIME"
|
||||
internal const val AFFINE_IME_BRIDGE_PROTOCOL_VERSION = 1
|
||||
internal const val DELETE_RESTART_INPUT_DEBOUNCE_MS = 120L
|
||||
|
||||
internal fun normalizeAffineOrigin(url: String?): String? {
|
||||
val uri = Uri.parse(url ?: return null)
|
||||
val scheme = uri.scheme?.lowercase() ?: return null
|
||||
val host = uri.host?.lowercase() ?: return null
|
||||
val isSupportedOrigin =
|
||||
(scheme == "https" && host == "localhost") ||
|
||||
(BuildConfig.DEBUG &&
|
||||
scheme == "http" &&
|
||||
host in setOf("localhost", "127.0.0.1", "10.0.2.2"))
|
||||
if (!isSupportedOrigin) return null
|
||||
|
||||
val port = when {
|
||||
uri.port == -1 -> ""
|
||||
scheme == "https" && uri.port == 443 -> ""
|
||||
scheme == "http" && uri.port == 80 -> ""
|
||||
else -> ":${uri.port}"
|
||||
}
|
||||
return "$scheme://$host$port"
|
||||
}
|
||||
|
||||
internal fun isTrustedAffineOrigin(url: String?, expectedOrigin: String?): Boolean {
|
||||
return expectedOrigin != null && normalizeAffineOrigin(url) == expectedOrigin
|
||||
}
|
||||
|
||||
internal class AffineImeBridge(
|
||||
private val isTrustedPage: () -> Boolean,
|
||||
private val clearComposingState: () -> Unit,
|
||||
private val requestRestartInput: (Long) -> Unit,
|
||||
private val onEditorFocusedChanged: (Boolean) -> Unit,
|
||||
) {
|
||||
@JavascriptInterface
|
||||
fun getProtocolVersion(): Int {
|
||||
return if (isTrustedPage()) AFFINE_IME_BRIDGE_PROTOCOL_VERSION else 0
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun finishComposingSession() {
|
||||
if (!isTrustedPage()) return
|
||||
clearComposingState()
|
||||
requestRestartInput(0L)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun finishDeleteSession() {
|
||||
if (!isTrustedPage()) return
|
||||
requestRestartInput(DELETE_RESTART_INPUT_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun setEditorFocused(focused: Boolean) {
|
||||
if (isTrustedPage()) {
|
||||
onEditorFocusedChanged(focused)
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.webkit.WebView
|
||||
|
||||
internal enum class AndroidImeInputType(
|
||||
val value: String,
|
||||
val key: String,
|
||||
val keyCode: Int,
|
||||
) {
|
||||
BACKWARD_DELETE("deleteContentBackward", "Backspace", 8),
|
||||
FORWARD_DELETE("deleteContentForward", "Delete", 46),
|
||||
}
|
||||
|
||||
internal fun dispatchAndroidEditorInput(
|
||||
webView: WebView,
|
||||
inputType: AndroidImeInputType,
|
||||
) {
|
||||
webView.post {
|
||||
webView.evaluateJavascript(
|
||||
"""
|
||||
(() => {
|
||||
try {
|
||||
const selection = document.getSelection();
|
||||
let target = selection?.anchorNode ?? document.activeElement ?? document.body;
|
||||
if (target && target.nodeType === Node.TEXT_NODE) {
|
||||
target = target.parentElement;
|
||||
}
|
||||
if (!(target instanceof EventTarget)) {
|
||||
target = document.activeElement ?? document.body;
|
||||
}
|
||||
const detail = {
|
||||
inputType: '${inputType.value}',
|
||||
handled: false,
|
||||
};
|
||||
const event = new CustomEvent('affine-android-ime-input', {
|
||||
detail,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
const dispatched = target.dispatchEvent(event);
|
||||
const handled = detail.handled || !dispatched;
|
||||
let fallbackKey = null;
|
||||
if (!handled) {
|
||||
fallbackKey = '${inputType.key}';
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: '${inputType.key}',
|
||||
code: '${inputType.key}',
|
||||
keyCode: ${inputType.keyCode},
|
||||
which: ${inputType.keyCode},
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
return {
|
||||
inputType: '${inputType.value}',
|
||||
handled,
|
||||
fallbackKey,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AffineIME] dispatch editor input failed', error);
|
||||
return { error: String(error) };
|
||||
}
|
||||
})();
|
||||
""".trimIndent(),
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.inputmethod.ExtractedTextRequest
|
||||
import android.view.inputmethod.InputConnection
|
||||
import android.view.inputmethod.InputConnectionWrapper
|
||||
|
||||
internal class AffineInputConnection(
|
||||
target: InputConnection,
|
||||
private val state: AndroidImeState,
|
||||
private val dispatchDeleteBackward: () -> Unit,
|
||||
private val dispatchDeleteForward: () -> Unit,
|
||||
) : InputConnectionWrapper(target, true) {
|
||||
private var handledClearRequestGeneration = state.clearRequestGeneration
|
||||
private var composingText = ""
|
||||
private var isComposingTextActive = false
|
||||
private var isConsumingDeleteKeyEvent = false
|
||||
|
||||
private val replay = ImeReplayController(
|
||||
deleteBefore = { length -> super.deleteSurroundingText(length, 0) },
|
||||
recordDeleteIntent = { recordDeleteIntent() },
|
||||
hasRecentDeleteIntent = { currentTime -> hasRecentDeleteIntent(currentTime) },
|
||||
)
|
||||
|
||||
override fun setComposingRegion(start: Int, end: Int): Boolean {
|
||||
consumeClearRequest()
|
||||
val regionText = getTextForRegion(start, end)
|
||||
|
||||
val nextComposingText = replay.updateComposingRegion(
|
||||
regionText,
|
||||
composingText,
|
||||
isComposingTextActive,
|
||||
)
|
||||
if (nextComposingText != null) {
|
||||
composingText = nextComposingText
|
||||
isComposingTextActive = nextComposingText.isNotEmpty()
|
||||
}
|
||||
|
||||
// Keep the native composing region untouched so IME autocorrect replay stays in the
|
||||
// explicit replay state machine instead of being applied twice by the platform.
|
||||
return true
|
||||
}
|
||||
|
||||
override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean {
|
||||
consumeClearRequest()
|
||||
val nextText = text?.toString() ?: ""
|
||||
|
||||
if (replay.shouldAdoptExternalRegionForReplacement(nextText)) {
|
||||
replay.clearDroppingReplay()
|
||||
}
|
||||
|
||||
if (replay.shouldDropExternalReplay(nextText)) {
|
||||
replay.markDroppingReplay()
|
||||
replay.deleteForShrinkingExternalReplay(nextText.length)
|
||||
return true
|
||||
}
|
||||
|
||||
val adoptedText = replay.adoptExternalRegionAsComposingTextIfNeeded(nextText)
|
||||
if (adoptedText != null) {
|
||||
composingText = adoptedText
|
||||
isComposingTextActive = adoptedText.isNotEmpty()
|
||||
}
|
||||
replay.clearExternalRegion()
|
||||
return applyComposingText(nextText)
|
||||
}
|
||||
|
||||
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
|
||||
consumeClearRequest()
|
||||
val committedText = text?.toString() ?: ""
|
||||
|
||||
if (
|
||||
replay.shouldDeleteExternalRegionOnEmptyCommit(
|
||||
committedText,
|
||||
isComposingTextActive,
|
||||
)
|
||||
) {
|
||||
replay.deleteRemainingExternalReplayText()
|
||||
replay.clearExternalRegion()
|
||||
return true
|
||||
}
|
||||
|
||||
if (replay.isDroppingReplay) {
|
||||
if (
|
||||
committedText.isEmpty() ||
|
||||
committedText == replay.currentExternalRegionText
|
||||
) {
|
||||
if (committedText.isEmpty()) {
|
||||
replay.deleteRemainingExternalReplayTextAfterShrink()
|
||||
}
|
||||
return true
|
||||
}
|
||||
replay.clearExternalRegion()
|
||||
}
|
||||
|
||||
if (isComposingTextActive && committedText.isNotEmpty()) {
|
||||
if (isWordBoundaryCommit(committedText)) {
|
||||
resetComposingText()
|
||||
replay.clearExternalRegion()
|
||||
return super.commitText(text, newCursorPosition)
|
||||
}
|
||||
|
||||
val result = applyComposingText(committedText)
|
||||
resetComposingText()
|
||||
return result
|
||||
}
|
||||
|
||||
if (committedText.isNotEmpty()) {
|
||||
replay.clearExternalRegion()
|
||||
}
|
||||
|
||||
return super.commitText(text, newCursorPosition)
|
||||
}
|
||||
|
||||
override fun finishComposingText(): Boolean {
|
||||
consumeClearRequest()
|
||||
resetComposingText()
|
||||
replay.clearExternalRegion()
|
||||
return super.finishComposingText()
|
||||
}
|
||||
|
||||
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
|
||||
consumeClearRequest()
|
||||
recordDeleteIntent(beforeLength, afterLength)
|
||||
if (replay.shouldDropNativeDeleteAfterSyntheticExternalDelete(beforeLength, afterLength)) {
|
||||
replay.clearExternalRegion()
|
||||
return true
|
||||
}
|
||||
replay.clearExternalRegion()
|
||||
if (isComposingTextActive && beforeLength > 0) {
|
||||
trimComposingTail(beforeLength, codePoints = false)
|
||||
}
|
||||
return super.deleteSurroundingText(beforeLength, afterLength)
|
||||
}
|
||||
|
||||
override fun deleteSurroundingTextInCodePoints(
|
||||
beforeLength: Int,
|
||||
afterLength: Int,
|
||||
): Boolean {
|
||||
consumeClearRequest()
|
||||
recordDeleteIntent(beforeLength, afterLength)
|
||||
if (replay.shouldDropNativeDeleteAfterSyntheticExternalDelete(beforeLength, afterLength)) {
|
||||
replay.clearExternalRegion()
|
||||
return true
|
||||
}
|
||||
replay.clearExternalRegion()
|
||||
if (isComposingTextActive && beforeLength > 0) {
|
||||
trimComposingTail(beforeLength, codePoints = true)
|
||||
}
|
||||
return super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
|
||||
}
|
||||
|
||||
override fun setSelection(start: Int, end: Int): Boolean {
|
||||
consumeClearRequest()
|
||||
replay.clearExternalRegion()
|
||||
resetComposingText()
|
||||
return super.setSelection(start, end)
|
||||
}
|
||||
|
||||
override fun performEditorAction(editorAction: Int): Boolean {
|
||||
consumeClearRequest()
|
||||
resetComposingText()
|
||||
replay.clearExternalRegion()
|
||||
return super.performEditorAction(editorAction)
|
||||
}
|
||||
|
||||
override fun sendKeyEvent(event: KeyEvent): Boolean {
|
||||
val isDeleteActionDown =
|
||||
event.action == KeyEvent.ACTION_DOWN &&
|
||||
(event.keyCode == KeyEvent.KEYCODE_DEL ||
|
||||
event.keyCode == KeyEvent.KEYCODE_FORWARD_DEL)
|
||||
consumeClearRequest(skipNativeFinish = isDeleteActionDown)
|
||||
|
||||
if (event.keyCode == KeyEvent.KEYCODE_DEL) {
|
||||
if (!state.editorFocused && !isConsumingDeleteKeyEvent) {
|
||||
return super.sendKeyEvent(event)
|
||||
}
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
recordDeleteIntent()
|
||||
dispatchDeleteBackward()
|
||||
isConsumingDeleteKeyEvent = true
|
||||
return true
|
||||
}
|
||||
if (event.action == KeyEvent.ACTION_UP && isConsumingDeleteKeyEvent) {
|
||||
isConsumingDeleteKeyEvent = false
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (event.keyCode == KeyEvent.KEYCODE_FORWARD_DEL) {
|
||||
if (!state.editorFocused && !isConsumingDeleteKeyEvent) {
|
||||
return super.sendKeyEvent(event)
|
||||
}
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
recordDeleteIntent()
|
||||
dispatchDeleteForward()
|
||||
isConsumingDeleteKeyEvent = true
|
||||
return true
|
||||
}
|
||||
if (event.action == KeyEvent.ACTION_UP && isConsumingDeleteKeyEvent) {
|
||||
isConsumingDeleteKeyEvent = false
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.action == KeyEvent.ACTION_DOWN &&
|
||||
(event.keyCode == KeyEvent.KEYCODE_SPACE ||
|
||||
event.keyCode == KeyEvent.KEYCODE_ENTER)
|
||||
) {
|
||||
resetComposingText()
|
||||
replay.clearExternalRegion()
|
||||
}
|
||||
return super.sendKeyEvent(event)
|
||||
}
|
||||
|
||||
private fun applyComposingText(nextText: String): Boolean {
|
||||
val previousText = composingText
|
||||
val prefixLength = commonPrefixLength(previousText, nextText)
|
||||
val deleteCount = previousText.length - prefixLength
|
||||
val insertText = nextText.substring(prefixLength)
|
||||
|
||||
if (deleteCount > 0) {
|
||||
super.deleteSurroundingText(deleteCount, 0)
|
||||
}
|
||||
if (insertText.isNotEmpty()) {
|
||||
super.commitText(insertText, 1)
|
||||
}
|
||||
|
||||
composingText = nextText
|
||||
isComposingTextActive = nextText.isNotEmpty()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun trimComposingTail(beforeLength: Int, codePoints: Boolean) {
|
||||
val length =
|
||||
if (codePoints) {
|
||||
beforeLength.coerceAtMost(composingText.codePointCount(0, composingText.length))
|
||||
} else {
|
||||
beforeLength.coerceAtMost(composingText.length)
|
||||
}
|
||||
composingText =
|
||||
if (codePoints) {
|
||||
val end = composingText.offsetByCodePoints(composingText.length, -length)
|
||||
composingText.substring(0, end)
|
||||
} else {
|
||||
composingText.dropLast(length)
|
||||
}
|
||||
if (composingText.isEmpty()) {
|
||||
resetComposingText()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetComposingText() {
|
||||
composingText = ""
|
||||
isComposingTextActive = false
|
||||
}
|
||||
|
||||
private fun consumeClearRequest(skipNativeFinish: Boolean = false) {
|
||||
val clearRequestGeneration = state.clearRequestGeneration
|
||||
if (
|
||||
clearRequestGeneration == 0L ||
|
||||
clearRequestGeneration == handledClearRequestGeneration
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
handledClearRequestGeneration = clearRequestGeneration
|
||||
resetComposingText()
|
||||
replay.clearExternalRegion()
|
||||
if (skipNativeFinish) {
|
||||
return
|
||||
}
|
||||
super.finishComposingText()
|
||||
}
|
||||
|
||||
private fun getTextForRegion(start: Int, end: Int): String {
|
||||
if (start < 0 || end <= start) return ""
|
||||
|
||||
val extractedText =
|
||||
try {
|
||||
getExtractedText(ExtractedTextRequest(), 0)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return ""
|
||||
val text = extractedText.text?.toString().orEmpty()
|
||||
val localStart = start - extractedText.startOffset
|
||||
val localEnd = end - extractedText.startOffset
|
||||
if (localStart < 0 || localEnd > text.length) return ""
|
||||
|
||||
return text.substring(localStart, localEnd)
|
||||
}
|
||||
|
||||
private fun recordDeleteIntent(beforeLength: Int, afterLength: Int) {
|
||||
if (beforeLength <= 0 || afterLength != 0) return
|
||||
recordDeleteIntent()
|
||||
}
|
||||
|
||||
private fun recordDeleteIntent() {
|
||||
state.lastDeleteIntentAtMs = android.os.SystemClock.uptimeMillis()
|
||||
}
|
||||
|
||||
private fun hasRecentDeleteIntent(currentTime: Long): Boolean {
|
||||
return currentTime - state.lastDeleteIntentAtMs <= IME_REPLAY_DELETE_WINDOW_MS
|
||||
}
|
||||
|
||||
private fun isWordBoundaryCommit(text: String): Boolean {
|
||||
return text == " " || text == "\n"
|
||||
}
|
||||
|
||||
private fun commonPrefixLength(left: String, right: String): Int {
|
||||
val maxLength = minOf(left.length, right.length)
|
||||
for (index in 0 until maxLength) {
|
||||
if (left[index] != right[index]) {
|
||||
return snapToCodePointBoundary(left, index)
|
||||
}
|
||||
}
|
||||
return snapToCodePointBoundary(left, maxLength)
|
||||
}
|
||||
|
||||
private fun snapToCodePointBoundary(text: String, index: Int): Int {
|
||||
return if (
|
||||
index > 0 &&
|
||||
index < text.length &&
|
||||
Character.isHighSurrogate(text[index - 1]) &&
|
||||
Character.isLowSurrogate(text[index])
|
||||
) {
|
||||
index - 1
|
||||
} else {
|
||||
index
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import com.getcapacitor.Bridge
|
||||
import com.getcapacitor.BridgeWebViewClient
|
||||
|
||||
internal class AffineWebViewClient(
|
||||
bridge: Bridge,
|
||||
private val trustedOrigin: String?,
|
||||
) : BridgeWebViewClient(bridge) {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): Boolean {
|
||||
val shouldOverride = super.shouldOverrideUrlLoading(view, request)
|
||||
if (!shouldOverride && request.isForMainFrame) {
|
||||
(view as? AffineEditorWebView)?.updateAndroidIMEBridge(
|
||||
request.url.toString(),
|
||||
trustedOrigin,
|
||||
)
|
||||
}
|
||||
return shouldOverride
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package app.affine.pro
|
||||
|
||||
internal class AndroidImeState {
|
||||
@Volatile
|
||||
var clearRequestGeneration: Long = 0L
|
||||
|
||||
@Volatile
|
||||
var editorFocused: Boolean = false
|
||||
|
||||
@Volatile
|
||||
var lastDeleteIntentAtMs: Long = 0L
|
||||
|
||||
@Volatile
|
||||
var restartInputGeneration: Int = 0
|
||||
|
||||
@Synchronized
|
||||
fun nextRestartGeneration(): Int {
|
||||
restartInputGeneration += 1
|
||||
return restartInputGeneration
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun nextClearRequestGeneration(): Long {
|
||||
clearRequestGeneration += 1
|
||||
return clearRequestGeneration
|
||||
}
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.os.SystemClock
|
||||
|
||||
internal const val IME_REPLAY_DELETE_WINDOW_MS = 500L
|
||||
|
||||
internal class ImeReplayController(
|
||||
private val deleteBefore: (Int) -> Unit,
|
||||
private val recordDeleteIntent: () -> Unit,
|
||||
private val hasRecentDeleteIntent: (Long) -> Boolean,
|
||||
private val now: () -> Long = SystemClock::uptimeMillis,
|
||||
) {
|
||||
private var externalRegionText = ""
|
||||
private var externalRegionAtMs = 0L
|
||||
private var isDroppingExternalReplay = false
|
||||
private var lastExternalReplayTextLength = -1
|
||||
private var lastExternalReplayTextAtMs = 0L
|
||||
private var lastExternalReplayDeletedLength = 0
|
||||
private var syntheticExternalDeleteAtMs = 0L
|
||||
|
||||
val currentExternalRegionText: String
|
||||
get() = externalRegionText
|
||||
|
||||
val isDroppingReplay: Boolean
|
||||
get() = isDroppingExternalReplay
|
||||
|
||||
fun updateComposingRegion(
|
||||
regionText: String,
|
||||
composingText: String,
|
||||
isComposingTextActive: Boolean,
|
||||
): String? {
|
||||
if (!isComposingTextActive) {
|
||||
externalRegionText = regionText
|
||||
externalRegionAtMs = now()
|
||||
isDroppingExternalReplay = false
|
||||
lastExternalReplayTextLength = regionText.length
|
||||
lastExternalReplayTextAtMs = externalRegionAtMs
|
||||
lastExternalReplayDeletedLength = 0
|
||||
return null
|
||||
}
|
||||
|
||||
if (regionText == composingText) return null
|
||||
|
||||
clearExternalRegion()
|
||||
return regionText
|
||||
}
|
||||
|
||||
fun shouldAdoptExternalRegionForReplacement(nextText: String): Boolean {
|
||||
if (nextText.isEmpty() || externalRegionText.isEmpty()) return false
|
||||
if (externalRegionText.startsWith(nextText)) return false
|
||||
|
||||
val commonPrefixLength = commonPrefixLength(externalRegionText, nextText)
|
||||
val minPrefixLength = minOf(
|
||||
MIN_REPLACEMENT_COMMON_PREFIX_LENGTH,
|
||||
externalRegionText.length,
|
||||
nextText.length,
|
||||
)
|
||||
|
||||
return commonPrefixLength >= minPrefixLength &&
|
||||
nextText.length >= externalRegionText.length
|
||||
}
|
||||
|
||||
fun shouldDropExternalReplay(nextText: String): Boolean {
|
||||
if (nextText.isEmpty()) return false
|
||||
if (isDroppingExternalReplay) {
|
||||
return !shouldAdoptExternalRegionForReplacement(nextText)
|
||||
}
|
||||
if (externalRegionText.isEmpty()) return false
|
||||
|
||||
val currentTime = now()
|
||||
if (
|
||||
currentTime - externalRegionAtMs > EXTERNAL_REGION_REPLAY_WINDOW_MS &&
|
||||
!shouldAdoptExternalRegionForReplacement(nextText)
|
||||
) {
|
||||
clearExternalRegion()
|
||||
return false
|
||||
}
|
||||
|
||||
val isSameRegionReplay =
|
||||
externalRegionText == nextText && hasRecentDeleteIntent(currentTime)
|
||||
val isDeleteShrinkReplay =
|
||||
externalRegionText.startsWith(nextText) && hasRecentDeleteIntent(currentTime)
|
||||
val isLikelyPassiveReplay = nextText.length > 1 || externalRegionText.length > 1
|
||||
|
||||
return (isSameRegionReplay || isDeleteShrinkReplay) && isLikelyPassiveReplay
|
||||
}
|
||||
|
||||
fun markDroppingReplay() {
|
||||
isDroppingExternalReplay = true
|
||||
}
|
||||
|
||||
fun clearDroppingReplay() {
|
||||
isDroppingExternalReplay = false
|
||||
}
|
||||
|
||||
fun adoptExternalRegionAsComposingTextIfNeeded(nextText: String): String? {
|
||||
if (externalRegionText.isEmpty()) return null
|
||||
|
||||
val currentTime = now()
|
||||
if (
|
||||
currentTime - externalRegionAtMs > EXTERNAL_REGION_REPLAY_WINDOW_MS &&
|
||||
!shouldAdoptExternalRegionForReplacement(nextText)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return externalRegionText
|
||||
}
|
||||
|
||||
fun clearExternalRegion() {
|
||||
externalRegionText = ""
|
||||
externalRegionAtMs = 0L
|
||||
isDroppingExternalReplay = false
|
||||
lastExternalReplayTextLength = -1
|
||||
lastExternalReplayTextAtMs = 0L
|
||||
lastExternalReplayDeletedLength = 0
|
||||
}
|
||||
|
||||
fun deleteForShrinkingExternalReplay(nextTextLength: Int) {
|
||||
val currentTime = now()
|
||||
val deleteCount =
|
||||
if (
|
||||
lastExternalReplayTextLength > 0 &&
|
||||
nextTextLength < lastExternalReplayTextLength &&
|
||||
currentTime - lastExternalReplayTextAtMs <= IME_REPLAY_DELETE_WINDOW_MS
|
||||
) {
|
||||
lastExternalReplayTextLength - nextTextLength
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
if (deleteCount > 0) {
|
||||
recordDeleteIntent()
|
||||
deleteBefore(deleteCount)
|
||||
lastExternalReplayDeletedLength += deleteCount
|
||||
syntheticExternalDeleteAtMs = now()
|
||||
}
|
||||
|
||||
lastExternalReplayTextLength = nextTextLength
|
||||
lastExternalReplayTextAtMs = currentTime
|
||||
}
|
||||
|
||||
fun deleteRemainingExternalReplayText() {
|
||||
val currentTime = now()
|
||||
if (
|
||||
lastExternalReplayTextLength <= 0 ||
|
||||
currentTime - lastExternalReplayTextAtMs > IME_REPLAY_DELETE_WINDOW_MS
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
recordDeleteIntent()
|
||||
deleteBefore(lastExternalReplayTextLength)
|
||||
syntheticExternalDeleteAtMs = now()
|
||||
lastExternalReplayTextLength = 0
|
||||
lastExternalReplayTextAtMs = currentTime
|
||||
}
|
||||
|
||||
fun deleteRemainingExternalReplayTextAfterShrink() {
|
||||
if (lastExternalReplayDeletedLength <= 0) return
|
||||
deleteRemainingExternalReplayText()
|
||||
}
|
||||
|
||||
fun shouldDropNativeDeleteAfterSyntheticExternalDelete(
|
||||
beforeLength: Int,
|
||||
afterLength: Int,
|
||||
): Boolean {
|
||||
val currentTime = now()
|
||||
return beforeLength > 0 &&
|
||||
afterLength == 0 &&
|
||||
currentTime - syntheticExternalDeleteAtMs <=
|
||||
SYNTHETIC_EXTERNAL_DELETE_SUPPRESS_WINDOW_MS
|
||||
}
|
||||
|
||||
fun shouldDeleteExternalRegionOnEmptyCommit(
|
||||
committedText: String,
|
||||
isComposingTextActive: Boolean,
|
||||
): Boolean {
|
||||
if (committedText.isNotEmpty()) return false
|
||||
if (isComposingTextActive || externalRegionText.isEmpty()) return false
|
||||
if (lastExternalReplayTextLength <= 0) return false
|
||||
if (lastExternalReplayDeletedLength <= 0) return false
|
||||
|
||||
val currentTime = now()
|
||||
return currentTime - externalRegionAtMs <= IME_REPLAY_DELETE_WINDOW_MS &&
|
||||
hasRecentDeleteIntent(currentTime)
|
||||
}
|
||||
|
||||
private fun commonPrefixLength(left: String, right: String): Int {
|
||||
val maxLength = minOf(left.length, right.length)
|
||||
for (index in 0 until maxLength) {
|
||||
if (left[index] != right[index]) {
|
||||
return snapToCodePointBoundary(left, index)
|
||||
}
|
||||
}
|
||||
return snapToCodePointBoundary(left, maxLength)
|
||||
}
|
||||
|
||||
private fun snapToCodePointBoundary(text: String, index: Int): Int {
|
||||
return if (
|
||||
index > 0 &&
|
||||
index < text.length &&
|
||||
Character.isHighSurrogate(text[index - 1]) &&
|
||||
Character.isLowSurrogate(text[index])
|
||||
) {
|
||||
index - 1
|
||||
} else {
|
||||
index
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val EXTERNAL_REGION_REPLAY_WINDOW_MS = 500L
|
||||
const val SYNTHETIC_EXTERNAL_DELETE_SUPPRESS_WINDOW_MS = 120L
|
||||
const val MIN_REPLACEMENT_COMMON_PREFIX_LENGTH = 2
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -30,6 +31,7 @@ import app.affine.pro.service.WebService
|
||||
import app.affine.pro.utils.px2dp
|
||||
import app.affine.pro.utils.dp2px
|
||||
import com.getcapacitor.BridgeActivity
|
||||
import com.getcapacitor.WebViewListener
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -99,10 +101,26 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi
|
||||
|
||||
override fun load() {
|
||||
super.load()
|
||||
configureAndroidIMEBridge()
|
||||
AuthInitializer.initialize(bridge)
|
||||
configureEditorWebView()
|
||||
}
|
||||
|
||||
private fun configureAndroidIMEBridge() {
|
||||
val trustedOrigin = normalizeAffineOrigin(bridge.localUrl)
|
||||
bridge.setWebViewClient(AffineWebViewClient(bridge, trustedOrigin))
|
||||
bridge.addWebViewListener(object : WebViewListener() {
|
||||
override fun onPageCommitVisible(view: WebView?, url: String?) {
|
||||
(view as? AffineEditorWebView)?.updateAndroidIMEBridge(url, trustedOrigin)
|
||||
}
|
||||
})
|
||||
|
||||
(bridge.webView as? AffineEditorWebView)?.updateAndroidIMEBridge(
|
||||
bridge.webView.url ?: bridge.localUrl,
|
||||
trustedOrigin,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) {
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<!-- Derived from @capacitor/android 8.4.2; keep this override aligned with the Capacitor layout. -->
|
||||
<app.affine.pro.AffineEditorWebView
|
||||
android:id="@+id/webview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
Reference in New Issue
Block a user