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:
qiaoyanfei
2026-08-21 13:55:14 +08:00
committed by GitHub
parent 3feb17cde3
commit 591f874dad
14 changed files with 1142 additions and 17 deletions
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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,
)
}
}
@@ -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
}
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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) {
@@ -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>