diff --git a/app/build.gradle b/app/build.gradle index 7e8a5f89b..34416de57 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ android { applicationId "com.kazumaproject.markdownhelperkeyboard" minSdk 24 targetSdk 36 - versionCode 797 - versionName "1.7.104" + versionCode 798 + versionName "1.7.105" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/FastInputMatrixInstrumentedTest.kt b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/FastInputMatrixInstrumentedTest.kt index ae984026f..1ad462724 100644 --- a/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/FastInputMatrixInstrumentedTest.kt +++ b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/FastInputMatrixInstrumentedTest.kt @@ -22,6 +22,8 @@ import android.text.style.BackgroundColorSpan import android.text.style.UnderlineSpan import android.util.Log import android.view.InputDevice +import android.view.KeyCharacterMap +import android.view.KeyEvent import android.view.MotionEvent import android.view.accessibility.AccessibilityNodeInfo import android.view.inputmethod.InputMethodManager @@ -744,6 +746,253 @@ class FastInputMatrixInstrumentedTest { } } + @Test + fun composingTextCursorMoveAndCommitClearsCandidatesOnPhysicalDevice() { + runPhysicalDeviceSession("composing-selection-commit-candidate-clear") { session -> + val testCase = TestCase( + keyboard = TestKeyboard.TENKEY, + columns = 1, + candidateTabVisible = true, + toolbarVisible = false, + toolbarIntegrated = false, + orientation = TestOrientation.PORTRAIT + ) + var scenario: ActivityScenario? = null + try { + scenario = launchHost(session.context) + rotateAndVerify(TestOrientation.PORTRAIT) + applyCasePreferences(session.preferences, testCase) + ensureTargetImeSelected(session) + restartInput(scenario) + SystemClock.sleep(IME_LAYOUT_SETTLE_MS) + assertDeviceReady(session.context, session.targetIme, scenario) + prepareEmptyEditor(scenario) + + val geometry = awaitStableGeometry( + keyboard = TestKeyboard.TENKEY, + requireCandidateContent = false + ) + assertTrue( + "Ten-key composing input injection failed", + injectSequence( + first = geometry.first.center, + second = geometry.second.center, + repetitions = 1 + ) + ) + assertTrue( + "ComposingText did not reach the editor", + awaitEditorText(scenario) { it.isNotEmpty() }.isNotEmpty() + ) + awaitStableGeometry( + keyboard = TestKeyboard.TENKEY, + requireCandidateContent = true + ) + assertTrue( + "Candidates were not populated before cursor move", + findCandidateState().texts.isNotEmpty() + ) + + assertTrue( + "Editor cursor move injection failed", + injectKeyEvent(KeyEvent.KEYCODE_DPAD_LEFT) + ) + assertTrue( + "Editor commit injection failed", + injectKeyEvent(KeyEvent.KEYCODE_ENTER) + ) + awaitTextSettled(scenario) + awaitStableGeometry( + keyboard = TestKeyboard.TENKEY, + requireCandidateContent = false + ) + assertTrue( + "Candidate tab remained visible after ComposingText cursor-move commit", + findVisibleNodeById("candidate_tab_layout") == null + ) + assertTrue( + "Stale candidates remained after ComposingText cursor-move commit", + findCandidateState().texts.isEmpty() + ) + } finally { + scenario?.close() + } + } + } + + @Test + fun tenKeyLongPressStillWorksAfterRotationOnPhysicalDevice() { + runPhysicalDeviceSession("tenkey-long-press-after-rotation") { session -> + val testCase = TestCase( + keyboard = TestKeyboard.TENKEY, + columns = 1, + candidateTabVisible = false, + toolbarVisible = false, + toolbarIntegrated = false, + orientation = TestOrientation.LANDSCAPE + ) + var scenario: ActivityScenario? = null + try { + scenario = launchHost(session.context) + rotateAndVerify(TestOrientation.PORTRAIT) + applyCasePreferences(session.preferences, testCase) + check( + session.preferences.edit() + .putInt("long_press_timeout_preference", 100) + .putString("delete_long_press_conversion_behavior", "deferred") + .putBoolean("live_conversion_preference", false) + .commit() + ) + ensureTargetImeSelected(session) + restartInput(scenario) + SystemClock.sleep(IME_LAYOUT_SETTLE_MS) + assertDeviceReady(session.context, session.targetIme, scenario) + prepareEmptyEditor(scenario) + + val portrait = awaitStableGeometry( + keyboard = TestKeyboard.TENKEY, + requireCandidateContent = false + ) + assertTrue( + "Initial TenKey input injection failed", + injectSequence( + first = portrait.first.center, + second = portrait.second.center, + repetitions = 2 + ) + ) + val expectedBeforeRotation = buildString { + repeat(2) { + append(TestKeyboard.TENKEY.firstText) + append(TestKeyboard.TENKEY.secondText) + } + } + assertEquals( + expectedBeforeRotation, + awaitTextSettled(scenario) + ) + awaitStableGeometry( + keyboard = TestKeyboard.TENKEY, + requireCandidateContent = true + ) + + rotateAndVerify(TestOrientation.LANDSCAPE) + awaitScreenAlignedNodeBounds("key_delete") + val landscape = awaitStableGeometry( + keyboard = TestKeyboard.TENKEY, + requireCandidateContent = false + ) + val deleteBounds = findVisibleNodeById("key_delete")?.screenRect() + ?: throw SetupException("Delete key is not visible after rotation") + assertTrue( + "Delete long-press injection failed after rotation", + injectTapWithoutTrailingGap(deleteBounds.center, holdMs = 850L) + ) + val afterText = awaitTextSettled(scenario) + val afterCandidates = findCandidateState().texts + assertTrue( + "TenKey long press was not delivered after rotation; text=$afterText " + + "candidates=$afterCandidates landscape=$landscape", + afterText.isEmpty() + ) + } finally { + scenario?.close() + } + } + } + + @Test + fun sumireLongPressPopupStillWorksAfterRotationOnPhysicalDevice() { + runPhysicalDeviceSession("sumire-long-press-after-rotation") { session -> + val testCase = TestCase( + keyboard = TestKeyboard.SUMIRE, + columns = 1, + candidateTabVisible = false, + toolbarVisible = false, + toolbarIntegrated = false, + orientation = TestOrientation.LANDSCAPE + ) + var scenario: ActivityScenario? = null + try { + scenario = launchHost(session.context) + rotateAndVerify(TestOrientation.PORTRAIT) + applyCasePreferences(session.preferences, testCase) + check( + session.preferences.edit() + .putInt("long_press_timeout_preference", 100) + .putBoolean("flick_editor_preview_preference", false) + .putString("sumire_keyboard_style_preference", "default") + .putString("sumire_input_method_preference", "switch-mode-effective") + .putString("keyboard_touch_effect_type_preference", "none") + .commit() + ) + ensureTargetImeSelected(session) + restartInput(scenario) + SystemClock.sleep(IME_LAYOUT_SETTLE_MS) + assertDeviceReady(session.context, session.targetIme, scenario) + prepareEmptyEditor(scenario) + + val portrait = awaitStableGeometry( + keyboard = TestKeyboard.SUMIRE, + requireCandidateContent = false + ) + val portraitScreens = captureSumireLongPressScreens(portrait.prime.center) + val portraitChangedPixels = countChangedPixels( + portraitScreens.first, + portraitScreens.second, + ScreenRect( + 0, + 0, + portraitScreens.first.width, + portraitScreens.first.height + ), + channelTolerance = GUIDE_CHANNEL_TOLERANCE + ) + portraitScreens.first.recycle() + portraitScreens.second.recycle() + + rotateAndVerify(TestOrientation.LANDSCAPE) + val landscape = awaitStableGeometry( + keyboard = TestKeyboard.SUMIRE, + requireCandidateContent = false + ) + val landscapeScreens = captureSumireLongPressScreens(landscape.prime.center) + val landscapeChangedPixels = countChangedPixels( + landscapeScreens.first, + landscapeScreens.second, + ScreenRect( + 0, + 0, + landscapeScreens.first.width, + landscapeScreens.first.height + ), + channelTolerance = GUIDE_CHANNEL_TOLERANCE + ) + landscapeScreens.first.recycle() + landscapeScreens.second.recycle() + + Log.i( + TAG, + "SUMIRE_LONG_PRESS_ROTATION portraitChangedPixels=$portraitChangedPixels " + + "landscapeChangedPixels=$landscapeChangedPixels " + + "portrait=$portrait landscape=$landscape" + ) + assertTrue( + "Sumire long-press popup did not appear before rotation: " + + "changedPixels=$portraitChangedPixels", + portraitChangedPixels >= MIN_LONG_PRESS_POPUP_CHANGED_PIXELS + ) + assertTrue( + "Sumire long-press popup disappeared after landscape rotation: " + + "before=$portraitChangedPixels after=$landscapeChangedPixels", + landscapeChangedPixels >= MIN_LONG_PRESS_POPUP_CHANGED_PIXELS + ) + } finally { + scenario?.close() + } + } + } + @Test fun qwertyVariationPopupTwoFingerInputOnPhysicalDevice() { runPhysicalDeviceSession("qwerty-variation-popup-multitouch") { session -> @@ -753,12 +1002,12 @@ class FastInputMatrixInstrumentedTest { candidateTabVisible = false, toolbarVisible = false, toolbarIntegrated = false, - orientation = TestOrientation.PORTRAIT + orientation = TestOrientation.LANDSCAPE ) var scenario: ActivityScenario? = null try { scenario = launchHost(session.context) - rotateAndVerify(TestOrientation.PORTRAIT) + rotateAndVerify(TestOrientation.LANDSCAPE) applyCasePreferences(session.preferences, testCase) check( session.preferences.edit() @@ -1026,6 +1275,106 @@ class FastInputMatrixInstrumentedTest { } } + @Test + fun emptyCandidatePresentationStaysHiddenAfterImeReopenOnCustomKeyboard() { + runPhysicalDeviceSession("custom-empty-candidate-reopen") { session -> + var scenario: ActivityScenario? = null + try { + check( + session.preferences.edit() + .putString( + "keyboard_order_preference", + """["CUSTOM","TENKEY","SUMIRE","QWERTY","ROMAJI"]""" + ) + .putBoolean("save_last_used_keyboard", false) + .putString("candidate_column_preference", "1") + .putBoolean("candidate_tab_visibility_preference", true) + .putBoolean("shortcut_toolbar_visibility_preference", false) + .putBoolean("keyboard_floating_preference", false) + .putInt("candidate_view_height_dp_preference", 200) + .putInt("candidate_view_empty_height_dp_preference", 80) + .commit() + ) { "Failed to configure custom keyboard candidate regression" } + + ensureTargetImeSelected(session) + scenario = launchHost(session.context) + restartInput(scenario) + awaitVisibleNodeBounds("custom_layout_default") + assertTrue( + "Candidate tabs must be hidden for an empty Custom composition", + findVisibleNodeById("candidate_tab_layout") == null + ) + + scenario.onActivity { activity -> + activity.getSystemService(InputMethodManager::class.java) + .hideSoftInputFromWindow(activity.editText.windowToken, 0) + } + SystemClock.sleep(500) + scenario.onActivity { activity -> + activity.editText.requestFocus() + activity.getSystemService(InputMethodManager::class.java) + .showSoftInput(activity.editText, InputMethodManager.SHOW_IMPLICIT) + } + awaitVisibleNodeBounds("custom_layout_default") + assertTrue( + "Candidate tabs must stay hidden after Custom IME close/reopen", + findVisibleNodeById("candidate_tab_layout") == null + ) + } finally { + scenario?.close() + } + } + } + + @Test + fun customKeyboardAcceptsInputOnPhysicalDevice() { + runPhysicalDeviceSession("custom-input") { session -> + var scenario: ActivityScenario? = null + try { + check( + session.preferences.edit() + .putString( + "keyboard_order_preference", + """["CUSTOM","TENKEY","SUMIRE","QWERTY","ROMAJI"]""" + ) + .putBoolean("save_last_used_keyboard", false) + .putString("candidate_column_preference", "1") + .putBoolean("candidate_tab_visibility_preference", true) + .putBoolean("shortcut_toolbar_visibility_preference", false) + .putBoolean("keyboard_floating_preference", false) + .commit() + ) { "Failed to configure Custom keyboard input test" } + + ensureTargetImeSelected(session) + scenario = launchHost(session.context) + restartInput(scenario) + SystemClock.sleep(IME_LAYOUT_SETTLE_MS) + assertDeviceReady(session.context, session.targetIme, scenario) + val root = findVisibleNodeById("custom_layout_default") + ?: throw SetupException("Custom keyboard root is not visible") + val customKey = findDescendant(root) { node -> + node.isVisibleToUser && ( + node.text?.toString() == "q" || + node.contentDescription?.toString() == "q" + ) + } ?: throw SetupException("Custom q key is not exposed by accessibility") + val customKeyCenter = customKey.screenRect().center + + assertTrue( + "Custom q tap was not injected", + injectTap(customKeyCenter), + ) + assertEquals("q", awaitEditorText(scenario) { it == "q" }) + assertTrue( + "Custom keyboard root disappeared after q input", + findVisibleNodeById("custom_layout_default") != null, + ) + } finally { + scenario?.close() + } + } + } + @Test fun sumireThreeColumnRateSweepOnPhysicalDevice() { val arguments = InstrumentationRegistry.getArguments() @@ -1791,6 +2140,39 @@ class FastInputMatrixInstrumentedTest { throw SetupException("Timed out waiting for visible key id=$idName") } + private fun awaitScreenAlignedNodeBounds(idName: String): ScreenRect { + val deadline = SystemClock.uptimeMillis() + SETUP_TIMEOUT_MS + var previous: ScreenRect? = null + var stableSamples = 0 + while (SystemClock.uptimeMillis() < deadline) { + val nodeBounds = findVisibleNodeById(idName)?.screenRect() + val screenshot = uiAutomation.takeScreenshot() + val screenBounds = screenshot?.let { shot -> + val bounds = ScreenRect(0, 0, shot.width, shot.height) + shot.recycle() + bounds + } + if ( + nodeBounds != null && + screenBounds != null && + nodeBounds == nodeBounds.intersect(screenBounds) + ) { + if (nodeBounds == previous) { + stableSamples += 1 + } else { + stableSamples = 1 + } + if (stableSamples >= GEOMETRY_STABLE_SAMPLES) return nodeBounds + previous = nodeBounds + } else { + previous = null + stableSamples = 0 + } + SystemClock.sleep(GEOMETRY_SAMPLE_MS) + } + throw SetupException("Timed out waiting for screen-aligned node id=$idName") + } + private fun countChangedPixels( first: Bitmap, second: Bitmap, @@ -1883,6 +2265,39 @@ class FastInputMatrixInstrumentedTest { } } + private fun injectKeyEvent(keyCode: Int): Boolean { + val downTime = SystemClock.uptimeMillis() + val down = KeyEvent( + downTime, + downTime, + KeyEvent.ACTION_DOWN, + keyCode, + 0, + 0, + KeyCharacterMap.VIRTUAL_KEYBOARD, + 0, + 0, + InputDevice.SOURCE_KEYBOARD + ) + val upTime = SystemClock.uptimeMillis() + val up = KeyEvent( + downTime, + upTime, + KeyEvent.ACTION_UP, + keyCode, + 0, + 0, + KeyCharacterMap.VIRTUAL_KEYBOARD, + 0, + 0, + InputDevice.SOURCE_KEYBOARD + ) + val downInjected = uiAutomation.injectInputEvent(down, true) + val upInjected = uiAutomation.injectInputEvent(up, true) + SystemClock.sleep(TAP_GAP_MS) + return downInjected && upInjected + } + private fun injectFlick(start: PointF, end: PointF): Boolean { if (start == end) return injectTap(start) @@ -1986,6 +2401,30 @@ class FastInputMatrixInstrumentedTest { return downInjected && upInjected } + private fun captureSumireLongPressScreens(point: PointF): Pair { + val downTime = SystemClock.uptimeMillis() + check( + injectSinglePointerEvent( + downTime = downTime, + action = MotionEvent.ACTION_DOWN, + point = point + ) + ) { "Sumire long-press DOWN injection failed" } + SystemClock.sleep(25L) + val afterDown = checkNotNull(uiAutomation.takeScreenshot()) + SystemClock.sleep(240L) + val afterLongPress = checkNotNull(uiAutomation.takeScreenshot()) + check( + injectSinglePointerEvent( + downTime = downTime, + action = MotionEvent.ACTION_UP, + point = point + ) + ) { "Sumire long-press UP injection failed" } + SystemClock.sleep(TAP_GAP_MS) + return afterDown to afterLongPress + } + private fun injectOverlappingTapPair( point: PointF, olderPointerLiftsFirst: Boolean @@ -2924,6 +3363,7 @@ class FastInputMatrixInstrumentedTest { private const val GUIDE_CHANNEL_TOLERANCE = 12 private const val MAX_GUIDE_CHANGED_PIXELS = 80 private const val MIN_GUIDE_CONTROL_CHANGED_PIXELS = 120 + private const val MIN_LONG_PRESS_POPUP_CHANGED_PIXELS = 500 private const val QWERTY_LONG_PRESS_HOLD_MS = 350L private const val POLL_MS = 32L private const val GEOMETRY_SAMPLE_MS = 32L diff --git a/app/src/main/assets/system/token.dat.zip b/app/src/main/assets/system/token.dat.zip index 1610faabe..554d01bb4 100644 Binary files a/app/src/main/assets/system/token.dat.zip and b/app/src/main/assets/system/token.dat.zip differ diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/gemma/media/GemmaImeMediaPanelController.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/gemma/media/GemmaImeMediaPanelController.kt index 41903d972..46a170597 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/gemma/media/GemmaImeMediaPanelController.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/gemma/media/GemmaImeMediaPanelController.kt @@ -297,7 +297,9 @@ class GemmaImeMediaPanelController( context.getString(R.string.gemma_image_loading_clipboard), ) mediaLoadJob = controllerScope.launch { - val item = clipboardUtil.getPrimaryClipContent() as? ClipboardItem.Image + val item = withContext(Dispatchers.IO) { + clipboardUtil.getPrimaryClipContent() + } as? ClipboardItem.Image if (item == null) { if (isCurrentImageLoad(requestId)) { state = state.copy( diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CandidateStripPresentationPolicy.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CandidateStripPresentationPolicy.kt index 544c8fc3a..acc602755 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CandidateStripPresentationPolicy.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CandidateStripPresentationPolicy.kt @@ -36,6 +36,11 @@ internal fun resolveCandidateStripHeightDp( emptyHeightDp: Int ): Int = if (candidatesShown) candidateHeightDp else emptyHeightDp +internal fun isCandidateStripActive( + candidatesShown: Boolean, + inputStringEmpty: Boolean +): Boolean = candidatesShown && !inputStringEmpty + object CandidateStripPresentationPolicy { fun resolve(state: CandidateStripPresentationState): CandidateStripPresentation { @@ -52,10 +57,16 @@ object CandidateStripPresentationPolicy { symbolKeyboardShown = state.symbolKeyboardShown ) ) + val candidateStripActive = isCandidateStripActive( + candidatesShown = state.candidatesShown, + inputStringEmpty = state.inputStringEmpty + ) val hideShortcutForCandidates = - state.shortcutToolbarHiddenForCandidates && !state.symbolKeyboardShown + state.shortcutToolbarHiddenForCandidates && + candidateStripActive && + !state.symbolKeyboardShown return CandidateStripPresentation( - showCandidateTab = state.candidateTabVisible && state.candidatesShown, + showCandidateTab = state.candidateTabVisible && candidateStripActive, resetCandidateTabSelection = state.resetCandidateTabSelection, showIndependentShortcutToolbar = shortcutPresentation.showIndependentToolbar && !hideShortcutForCandidates, diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt index cb98a608e..1836e5baf 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt @@ -571,6 +571,12 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private var isClipboardHistoryFeatureEnabled: Boolean = false private val clipboardMutex = Mutex() + private val clipboardPreviewRequestId = AtomicLong(0L) + private var clipboardPreviewLoadJob: Job? = null + @Volatile + private var cachedClipboardPreviewItem: ClipboardItem = ClipboardItem.Empty + @Volatile + private var cachedClipboardPreviewSensitive: Boolean = false private val zenzModelPathMutex = Mutex() private var cachedZenzModelSource: String? = null private var cachedZenzModelPath: String? = null @@ -614,6 +620,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private var isFloatingQwertyConfigured: Boolean = false private var keyboardBackgroundPlayer: ExoPlayer? = null private var floatingKeyboardBackgroundPlayer: ExoPlayer? = null + private val keyboardBackgroundImageRequestId = AtomicLong(0L) + private val keyboardBackgroundImageRequestIds = java.util.WeakHashMap() private var floatingKeyboardBackgroundVideoConfig: KeyboardBackgroundVideoConfig? = null private val inkRootLocation = IntArray(2) private val inkTargetLocation = IntArray(2) @@ -632,6 +640,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private var bunsetsuSplitPatterns: List> = emptyList() private var bunsetsuConversionSession: BunsetsuConversionSession? = null private var pendingReconversionEntry: ReconversionEntry? = null + private var pendingReconversionValid: Boolean = false + private val reconversionValidationRequestId = AtomicLong(0L) private var bunsetsuReconversionDraft: BunsetsuReconversionDraft? = null private var preserveBunsetsuReconversionDraftOnNextProcessInput = false private var isRestoringReconversionInput = false @@ -690,6 +700,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } withContext(Dispatchers.Main.immediate) { + cachedClipboardPreviewItem = newItem + cachedClipboardPreviewSensitive = isSensitive if (newItem is ClipboardItem.Empty) { clearSelectedTextClipboardPreviewRefresh() } else { @@ -697,6 +709,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } updateClipboardPreview() } + refreshClipboardPreviewSnapshot() } } @@ -734,6 +747,18 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) private var selectedTextClipboardPreviewRefreshText: String? = null + /** + * Selection state used by candidate-strip rendering. + * + * InputConnection is a remote Binder connection for most editors. Reading selected text + * synchronously from the main thread can therefore stall the whole IME while the editor + * responds. Keep the range state immediately and refresh the text snapshot off-main. + */ + private var editorTextSelected: Boolean = false + private var selectedEditorText: String = "" + private val selectedEditorTextRequestId = AtomicLong(0L) + private val editorConnectionReadMutex = Mutex() + private var systemUserDictionaryLoadJob: Job? = null private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -795,6 +820,64 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } + private fun resetEditorSelectionSnapshot() { + selectedEditorTextRequestId.incrementAndGet() + editorTextSelected = false + selectedEditorText = "" + } + + private fun refreshClipboardPreviewSnapshot() { + val requestId = clipboardPreviewRequestId.incrementAndGet() + clipboardPreviewLoadJob?.cancel() + clipboardPreviewLoadJob = ioScope.launch { + val item = runCatching { + clipboardUtil.getPrimaryClipPreviewContent() + }.getOrDefault(ClipboardItem.Empty) + val isSensitive = runCatching { + clipboardUtil.isPrimaryClipSensitive() + }.getOrDefault(false) + withContext(Dispatchers.Main.immediate) { + if (clipboardPreviewRequestId.get() != requestId) return@withContext + cachedClipboardPreviewItem = item + cachedClipboardPreviewSensitive = isSensitive + updateClipboardPreview() + } + } + } + + private fun updateEditorSelectionSnapshot(newSelStart: Int, newSelEnd: Int) { + val hasSelection = + newSelStart >= 0 && newSelEnd >= 0 && newSelStart != newSelEnd + editorTextSelected = hasSelection + selectedEditorText = "" + val requestId = selectedEditorTextRequestId.incrementAndGet() + if (!hasSelection) return + + val connection = currentInputConnection ?: return + ioScope.launch { + val text = runCatching { + connection.getSelectedText(0)?.toString().orEmpty() + }.getOrDefault("") + runOnMainThread { + if (selectedEditorTextRequestId.get() != requestId || !editorTextSelected) { + return@runOnMainThread + } + selectedEditorText = text + if (text.isNotEmpty()) { + handleSelectedTextSelection(text) + } else { + clearSelectedTextClipboardPreviewRefresh() + if (selectedTextGemmaSession != null) { + clearSelectedTextGemmaSession( + clearSuggestions = hasSelectedTextGemmaActionCandidates() + ) + } + } + refreshCandidateStripContent() + } + } + } + private fun setSuggestionAdapterSuggestionsOnMain(candidates: List) { runOnMainThread { measureDebugSection("IMEService.setSuggestionAdapterSuggestionsOnMain") { @@ -822,10 +905,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private suspend fun updateSuggestionAdaptersOnMain( candidates: List, insertString: String, - fullCandidates: List = candidates + fullCandidates: List = candidates, + token: CandidateRequestToken? = null, ) = measureDebugStage("IMEService.updateSuggestionAdaptersOnMain") { withContext(Dispatchers.Main.immediate) { - if (!shouldApplyCandidateResult(insertString)) return@withContext + if (!shouldApplyCandidateResult(insertString, token)) return@withContext collapseShortcutEntryExpansion(refreshContent = false) currentCandidateStripCandidates = candidates currentCandidateStripFullCandidates = fullCandidates @@ -847,14 +931,22 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, return } + // CandidateShowFlag.Updating can be emitted once with an empty input while the + // editor/IME is being recreated (for example, after hiding and showing the keyboard). + // That event must not make the empty strip behave like an active conversion strip. + val effectiveCandidatesShown = isCandidateStripActive( + candidatesShown = candidatesShown, + inputStringEmpty = inputString.value.isEmpty() + ) + val content = resolveCandidateStripContent( candidates = currentCandidateStripCandidates, - candidatesShown = candidatesShown, + candidatesShown = effectiveCandidatesShown, includeZeroQuery = true ) val fullContent = resolveCandidateStripContent( candidates = currentCandidateStripFullCandidates, - candidatesShown = candidatesShown, + candidatesShown = effectiveCandidatesShown, includeZeroQuery = false ) currentCandidateStripContent = content @@ -866,7 +958,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, suggestionAdapter?.submitContent(content) suggestionAdapterFull?.submitContent(fullContent) val presentation = resolveCandidateStripPresentation( - candidatesShown = candidatesShown, + candidatesShown = effectiveCandidatesShown, resetCandidateTabSelection = resetCandidateTabSelection, content = content ) @@ -892,10 +984,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, includeZeroQuery: Boolean ): CandidateStripInputState { val clipboardPreview = resolveClipboardPreviewSnapshot() - val selectedEditorText = currentInputConnection?.getSelectedText(0)?.toString().orEmpty() val shouldSuppressClipboardPreviewForSelectedText = - selectedEditorText.isNotEmpty() && - selectedTextClipboardPreviewRefreshText != selectedEditorText + editorTextSelected && + (selectedEditorText.isEmpty() || + selectedTextClipboardPreviewRefreshText != selectedEditorText) val hasUndoHistory = isEditHistoryEnabled() && deletedBuffer.hasUndoHistory() val hasRedoHistory = isEditHistoryEnabled() && deletedBuffer.hasRedoHistory() return CandidateStripInputState( @@ -939,9 +1031,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } private fun resolveClipboardPreviewSnapshot(): ClipboardPreviewSnapshot { - return when (val item = clipboardUtil.getPrimaryClipContent()) { + return when (val item = cachedClipboardPreviewItem) { is ClipboardItem.Image -> { - if (clipboardUtil.isPrimaryClipSensitive()) { + if (cachedClipboardPreviewSensitive) { ClipboardPreviewSnapshot( text = getSensitiveClipboardPreviewText(), bitmap = null, @@ -1051,7 +1143,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, if (isCurrentInputTypePasswordOrEmailForZeroQuery()) return false if (isCustomLayoutPickerShownForCandidateStrip()) return false if (isSelectedTextGemmaActionsShownForCandidateStrip()) return false - if (currentInputConnection?.getSelectedText(0)?.toString().orEmpty().isNotEmpty()) { + if (editorTextSelected) { return false } return true @@ -1077,7 +1169,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, if (inputString.value.isNotEmpty()) return false if (stringInTail.get().isNotEmpty()) return false if (isCurrentInputTypePasswordOrEmailForZeroQuery()) return false - if (currentInputConnection?.getSelectedText(0)?.toString().orEmpty().isNotEmpty()) { + if (editorTextSelected) { return false } return true @@ -1142,11 +1234,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, return } - val selectedText = currentInputConnection?.getSelectedText(0)?.toString().orEmpty() val inputAndSelectionAreEmpty = inputString.value.isEmpty() && stringInTail.get().isEmpty() && - selectedText.isEmpty() && + !editorTextSelected && newSelStart == newSelEnd if (zeroQuerySelectionUpdateSuppressCount > 0 && inputAndSelectionAreEmpty) { zeroQuerySelectionUpdateSuppressCount -= 1 @@ -1155,7 +1246,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, val cursorMoved = oldSelStart != newSelStart || oldSelEnd != newSelEnd if ( - selectedText.isNotEmpty() || + editorTextSelected || newSelStart != newSelEnd || inputString.value.isNotEmpty() || stringInTail.get().isNotEmpty() || @@ -1258,6 +1349,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private var selectedTextGemmaSession: SelectedTextGemmaSession? = null private var mainLayoutBinding: MainLayoutBinding? = null + private var lastKeyboardLayoutRootView: View? = null + private var lastKeyboardLayoutOrientation: Int? = null private var gemmaMediaPanelController: GemmaImeMediaPanelController? = null private var gemmaHandwritingController: GemmaHandwritingController? = null private var handwritingModeActive: Boolean = false @@ -2256,6 +2349,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } else { scope.coroutineContext.cancelChildren() mainLayoutBinding?.let { mainView -> + rebindMainKeyboardInputListeners(mainView) + scheduleMainKeyboardInputListenerRebind(mainView) startScope(mainView) } } @@ -2264,6 +2359,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) { super.onStartInput(attribute, restarting) + resetEditorSelectionSnapshot() flickPreviewEditorSessionId += 1L flickInputPreviewCoordinator.resetForEditorSession() gemmaInputSessionId += 1L @@ -2290,6 +2386,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, startKanaKanjiConversionSession(preferences.conversionBackend) resetKeyboard() initializeMozcDictionaries(preferences) + refreshClipboardPreviewSnapshot() syncCustomKeyboardSuggestionPreference() refreshCandidateStripContent() } @@ -2773,13 +2870,28 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private fun initializeMozcDictionaries(@Suppress("UNUSED_PARAMETER") preferences: ImePreferencesSnapshot) { applyDictionaryOverrideRevisionIfNeeded() - if (!kanaKanjiEngine.isSystemUserDictionaryInitialized()) { - runCatching { - kanaKanjiEngine.loadSystemUserDictionaryFromFiles(applicationContext) + if (!kanaKanjiEngine.isSystemUserDictionaryInitialized() && + systemUserDictionaryLoadJob?.isActive != true + ) { + systemUserDictionaryLoadJob = ioScope.launch { + runCatching { + kanaKanjiEngine.loadSystemUserDictionaryFromFiles(applicationContext) + }.onFailure { + Timber.w(it, "Failed to load system user dictionary asynchronously") + } + withContext(Dispatchers.Main.immediate) { + if (isInputViewActive) { + requestCandidateRefresh(CandidateShowFlag.Updating) + } + } } } } + private suspend fun awaitSystemUserDictionaryLoad() { + systemUserDictionaryLoadJob?.join() + } + private fun updateIncognitoModeState(editorInfo: EditorInfo?) { val detected = incognitoModeDetectionPreference && editorInfo != null && @@ -2861,8 +2973,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } - private fun loadKeyboardBackgroundBitmap(): Bitmap? { - val uriString = appPreference.keyboard_background_image_uri + private fun loadKeyboardBackgroundBitmap(uriString: String): Bitmap? { if (uriString.isBlank()) return null val uri = runCatching { uriString.toUri() }.getOrNull() ?: return null return runCatching { @@ -2880,40 +2991,60 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, imageView.isVisible = false } - private fun applyKeyboardBackgroundImageToViewIfNeeded(imageView: ImageView): Boolean { - val bitmap = loadKeyboardBackgroundBitmap() - if (bitmap == null) { - clearKeyboardBackgroundImage(imageView) - return false - } - + private fun applyKeyboardBackgroundImageToViewIfNeeded( + imageView: ImageView, + onApplied: (Boolean) -> Unit = {} + ) { + assertMainThread("applyKeyboardBackgroundImageToViewIfNeeded") + val requestId = keyboardBackgroundImageRequestId.incrementAndGet() + keyboardBackgroundImageRequestIds[imageView] = requestId + val uriString = appPreference.keyboard_background_image_uri val displayMode = appPreference.keyboard_background_image_display_mode - when (displayMode) { - "center_crop" -> { - imageView.background = null - imageView.scaleType = ImageView.ScaleType.CENTER_CROP - imageView.setImageBitmap(bitmap) - } + clearKeyboardBackgroundImage(imageView) + if (uriString.isBlank()) { + onApplied(false) + return + } + ioScope.launch { + val bitmap = loadKeyboardBackgroundBitmap(uriString) + runOnMainThread { + if (keyboardBackgroundImageRequestIds[imageView] != requestId) return@runOnMainThread + if (bitmap == null) { + clearKeyboardBackgroundImage(imageView) + onApplied(false) + return@runOnMainThread + } - else -> { imageView.background = null - imageView.scaleType = ImageView.ScaleType.FIT_CENTER + imageView.scaleType = if (displayMode == "center_crop") { + ImageView.ScaleType.CENTER_CROP + } else { + ImageView.ScaleType.FIT_CENTER + } imageView.setImageBitmap(bitmap) + imageView.isVisible = true + onApplied(true) } } - imageView.isVisible = true - return true } - private fun applyKeyboardBackgroundImageIfNeeded(mainView: MainLayoutBinding): Boolean { - return applyKeyboardBackgroundImageToViewIfNeeded(mainView.keyboardBackgroundImage) + private fun applyKeyboardBackgroundImageIfNeeded(mainView: MainLayoutBinding) { + applyKeyboardBackgroundImageToViewIfNeeded(mainView.keyboardBackgroundImage) } private fun applyFloatingKeyboardBackgroundImageIfNeeded( floatingView: FloatingKeyboardLayoutBinding - ): Boolean { - return applyKeyboardBackgroundImageToViewIfNeeded(floatingView.floatingKeyboardBackgroundImage) + ) { + applyKeyboardBackgroundImageToViewIfNeeded( + imageView = floatingView.floatingKeyboardBackgroundImage, + onApplied = { applied -> + applyFloatingKeyboardContainerTransparencyForBackgroundMedia( + floatingView, + enabled = applied + ) + } + ) } private fun resolveVideoQualityMaxSize(quality: String): Pair { @@ -3151,12 +3282,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, enabled = true ) } else { - val isBackgroundImageApplied = - applyFloatingKeyboardBackgroundImageIfNeeded(floatingView) - applyFloatingKeyboardContainerTransparencyForBackgroundMedia( - floatingView, - enabled = isBackgroundImageApplied - ) + applyFloatingKeyboardBackgroundImageIfNeeded(floatingView) } } @@ -4188,10 +4314,17 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, syncQwertyEnglishDirectInputPreference() syncNgramDictionaryPreferences() isInputViewActive = true + // A hidden input view must not carry the previous candidate-display phase into + // the next render. The editor can restart the view without onStartInput(). + shortcutToolbarHiddenForCandidates = false collapseShortcutEntryExpansion() shortcutInputBehaviorOverride = null keyboardSelectionPopupWindow?.dismiss() addUserDictionaryPopup?.dismiss() + mainLayoutBinding?.let { mainView -> + rebindMainKeyboardInputListeners(mainView) + scheduleMainKeyboardInputListenerRebind(mainView) + } _keyboardSymbolViewState.update { SymbolKeyboardState() } _selectMode.update { false } _cursorMoveMode.update { false } @@ -4561,6 +4694,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, releaseFloatingKeyboardBackgroundVideoPlayer() stopVoiceInput() collapseShortcutEntryExpansion() + shortcutToolbarHiddenForCandidates = false floatingCandidateWindow?.dismiss() floatingDockWindow?.dismiss() floatingModeSwitchWindow?.dismiss() @@ -4581,6 +4715,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, override fun onDestroy() { flickInputPreviewCoordinator.cancel(restore = false) + resetEditorSelectionSnapshot() Timber.d("onUpdate onDestroy") if (runtimeInputPreferenceListenerRegistered) { runtimeInputSharedPreferences.unregisterOnSharedPreferenceChangeListener( @@ -5020,7 +5155,21 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, currentNightMode = newNightMode } + lastKeyboardLayoutRootView = null + lastKeyboardLayoutOrientation = null refreshKeyboardForCurrentOrientation() + mainLayoutBinding?.let { mainView -> + rebindMainKeyboardInputListeners(mainView) + scheduleMainKeyboardInputListenerRebind(mainView) + mainView.root.post { + if (mainLayoutBinding?.root === mainView.root) { + lastKeyboardLayoutRootView = null + lastKeyboardLayoutOrientation = null + updateKeyboardLayout(mainView) + rebindMainKeyboardInputListeners(mainView) + } + } + } } @@ -5629,7 +5778,6 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, windowInsets } setCandidateTabLayout(mainView) - setupCustomKeyboardListeners(mainView) setSuggestionRecyclerView( mainView, FlexboxLayoutManager(applicationContext).apply { flexDirection = FlexDirection.ROW @@ -5637,11 +5785,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, }) setShortCutAdapter(mainView) setSymbolKeyboard(mainView) - setQWERTYKeyboard(mainView) - if (isTablet == true) { - setTabletKeyListeners(mainView) - } - setTenKeyListeners(mainView) + rebindMainKeyboardInputListeners(mainView) hideAllKeyboards() setKeyboardSizeSwitchKeyboard(mainView) updateClipboardPreview() @@ -5651,6 +5795,37 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } + /** + * Reconnects input listeners after the IME input view is reused. + * + * InputMethodService may detach and reattach the same keyboard hierarchy during + * rotation or an input-view restart. TenKey intentionally clears its listeners when + * detached, while the service keeps reusing the existing hierarchy. Rebinding all + * main-surface keyboard listeners here keeps the view lifecycle and the service + * lifecycle synchronized. + */ + private fun rebindMainKeyboardInputListeners(mainView: MainLayoutBinding) { + setupCustomKeyboardListeners(mainView) + setQWERTYKeyboard(mainView) + if (isTablet == true) { + setTabletKeyListeners(mainView) + } + setTenKeyListeners(mainView) + } + + /** + * Configuration callbacks and input-view detach/attach callbacks can be delivered in + * either order. Run one more binding pass after the current main-loop turn so a detach + * that clears view-owned listeners cannot win the race against the service rebind. + */ + private fun scheduleMainKeyboardInputListenerRebind(mainView: MainLayoutBinding) { + mainView.root.post { + if (mainLayoutBinding?.root === mainView.root) { + rebindMainKeyboardInputListeners(mainView) + } + } + } + override fun onUpdateSelection( oldSelStart: Int, oldSelEnd: Int, @@ -5667,6 +5842,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, return } + updateEditorSelectionSnapshot( + newSelStart = newSelStart, + newSelEnd = newSelEnd, + ) + handleZeroQueryOnUpdateSelection( oldSelStart = oldSelStart, oldSelEnd = oldSelEnd, @@ -5680,41 +5860,17 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, return } - val selectedText = currentInputConnection?.getSelectedText(0)?.toString().orEmpty() - if (selectedText.isNotEmpty()) { - if (selectedTextClipboardPreviewRefreshText == selectedText) { - clearSelectedTextGemmaSession( - clearSuggestions = hasSelectedTextGemmaActionCandidates() - ) - updateClipboardPreview() - return - } - clearSelectedTextClipboardPreviewRefresh() - if (selectedTextGemmaSession?.selectedText != null && - selectedTextGemmaSession?.selectedText != selectedText - ) { - clearSelectedTextGemmaSession( - clearSuggestions = hasSelectedTextGemmaActionCandidates() - ) - } - if (AppVariantConfig.hasGemma && - appPreference.enable_gemma_translation_preference && - gemmaTranslationManager.isTranslationAvailable() - ) { - showSelectedTextGemmaActions(selectedText) - } else { - clearSelectedTextGemmaSession( - clearSuggestions = hasSelectedTextGemmaActionCandidates() - ) + if (editorTextSelected) { + if (selectedEditorText.isNotEmpty()) { + handleSelectedTextSelection(selectedEditorText) } return - } else { - clearSelectedTextClipboardPreviewRefresh() - if (selectedTextGemmaSession != null) { - clearSelectedTextGemmaSession( - clearSuggestions = hasSelectedTextGemmaActionCandidates() - ) - } + } + clearSelectedTextClipboardPreviewRefresh() + if (selectedTextGemmaSession != null) { + clearSelectedTextGemmaSession( + clearSuggestions = hasSelectedTextGemmaActionCandidates() + ) } val preservedPreEdit = preservePreEditOnNextSelectionUpdate @@ -5777,6 +5933,15 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, endBatchEdit() } } + + // A cursor move can finish the editor's ComposingText without going through one of the + // IME commit handlers. In that path inputString is already empty, but the candidate + // adapters and the last refresh request can still describe the old composing session. + // Clear that state before the next IME show/reopen so an empty editor cannot expose the + // previous conversion tab or candidates. + if (stringInTail.get().isEmpty() && _inputString.value.isEmpty()) { + clearSuggestionStateAfterEditorSelectionChange() + } refreshReconversionUi() } @@ -8100,6 +8265,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mainView: MainLayoutBinding ) { mainView.keyboardView.apply { + setOnAttachedToWindowListener { + rebindMainKeyboardInputListeners(mainView) + } setOnFlickTextPreviewListener(tenKeyFlickTextPreviewListener) applyKeyboardTheme( themeMode = keyboardThemeMode ?: "default", @@ -8540,19 +8708,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, /** 共有 **/ Key.KeyRA -> { - val selectedText = getSelectedText(0) - if (!selectedText.isNullOrEmpty()) { - val sendIntent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, selectedText.toString()) - } - val chooser: Intent = - Intent.createChooser(sendIntent, "Share text via").apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK - } - startActivity(chooser) - clearSelection() - } + shareSelectedTextAction() } /** その他 **/ else -> { @@ -8749,19 +8905,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, /** 共有 **/ Key.KeyRA -> { - val selectedText = getSelectedText(0) - if (!selectedText.isNullOrEmpty()) { - val sendIntent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, selectedText.toString()) - } - val chooser: Intent = - Intent.createChooser(sendIntent, "Share text via").apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK - } - startActivity(chooser) - clearSelection() - } + shareSelectedTextAction() } /** その他 **/ else -> { @@ -9144,6 +9288,34 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, return insertString.take(readingLength) } + private fun handleSelectedTextSelection(selectedText: String) { + if (selectedTextClipboardPreviewRefreshText == selectedText) { + clearSelectedTextGemmaSession( + clearSuggestions = hasSelectedTextGemmaActionCandidates() + ) + updateClipboardPreview() + return + } + clearSelectedTextClipboardPreviewRefresh() + if (selectedTextGemmaSession?.selectedText != null && + selectedTextGemmaSession?.selectedText != selectedText + ) { + clearSelectedTextGemmaSession( + clearSuggestions = hasSelectedTextGemmaActionCandidates() + ) + } + if (AppVariantConfig.hasGemma && + appPreference.enable_gemma_translation_preference && + gemmaTranslationManager.isTranslationAvailable() + ) { + showSelectedTextGemmaActions(selectedText) + } else { + clearSelectedTextGemmaSession( + clearSuggestions = hasSelectedTextGemmaActionCandidates() + ) + } + } + private fun showSelectedTextGemmaActions(selectedText: String) { clearZeroQueryAllState(refresh = false) if (!gemmaTranslationManager.isTranslationAvailable()) { @@ -9165,8 +9337,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) withContext(Dispatchers.Main) { if (selectedTextGemmaActionMenuRequestId.get() != requestId) return@withContext - val currentSelection = - currentInputConnection?.getSelectedText(0)?.toString().orEmpty() + val currentSelection = selectedEditorText if (currentSelection != selectedText) return@withContext val actions = buildList { @@ -9232,8 +9403,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private fun markClipboardPreviewRefreshAfterPrimaryClipChanged() { selectedTextClipboardPreviewRefreshText = - currentInputConnection?.getSelectedText(0)?.toString() - ?.takeIf { it.isNotEmpty() } + selectedEditorText.takeIf { it.isNotEmpty() } if (selectedTextClipboardPreviewRefreshText != null) { clearSelectedTextGemmaSession( clearSuggestions = hasSelectedTextGemmaActionCandidates() @@ -9337,36 +9507,45 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, originalText: String, transformedText: String ) { - val inputConnection = currentInputConnection - if (inputConnection == null) { - showToastMessage(getString(R.string.candidate_translation_cancelled_context_changed)) - clearSelectedTextGemmaSession(clearSuggestions = true) - return - } - val currentSelectedText = inputConnection.getSelectedText(0)?.toString().orEmpty() - if (currentSelectedText != originalText) { + val inputConnection = currentInputConnection ?: run { showToastMessage(getString(R.string.candidate_translation_cancelled_context_changed)) clearSelectedTextGemmaSession(clearSuggestions = true) return } - if (transformedText == originalText) { - clearSelectedTextGemmaSession(clearSuggestions = true) - return - } + ioScope.launch { + val currentSelectedText = runCatching { + inputConnection.getSelectedText(0)?.toString().orEmpty() + }.getOrDefault("") + runOnMainThread { + if (currentInputConnection !== inputConnection || + currentSelectedText != originalText + ) { + showToastMessage( + getString(R.string.candidate_translation_cancelled_context_changed) + ) + clearSelectedTextGemmaSession(clearSuggestions = true) + return@runOnMainThread + } + if (transformedText == originalText) { + clearSelectedTextGemmaSession(clearSuggestions = true) + return@runOnMainThread + } - beginBatchEdit() - try { - commitText(transformedText, 1) - } finally { - endBatchEdit() + beginBatchEdit() + try { + commitText(transformedText, 1) + } finally { + endBatchEdit() + } + pushEditHistoryEntry( + EditHistoryEntry.ReplaceCommittedText( + beforeText = originalText, + afterText = transformedText + ) + ) + clearSelectedTextGemmaSession(clearSuggestions = true) + } } - pushEditHistoryEntry( - EditHistoryEntry.ReplaceCommittedText( - beforeText = originalText, - afterText = transformedText - ) - ) - clearSelectedTextGemmaSession(clearSuggestions = true) } private fun isSelectedTextGemmaActionCandidate(candidate: Candidate): Boolean { @@ -11610,10 +11789,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } KeyAction.Copy -> { - val selectedText = getSelectedText(0) - if (!selectedText.isNullOrEmpty()) { - copySelectedTextToClipboard(selectedText) - } + copyAction() } KeyAction.Delete -> { @@ -12336,10 +12512,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, KeyAction.Backspace -> {} KeyAction.Copy -> { - val selectedText = getSelectedText(0) - if (!selectedText.isNullOrEmpty()) { - copySelectedTextToClipboard(selectedText) - } + copyAction() } KeyAction.Paste -> { @@ -12678,9 +12851,39 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } private fun copyAction() { - val selectedText = getSelectedText(0) - if (!selectedText.isNullOrEmpty()) { - copySelectedTextToClipboard(selectedText) + readSelectedTextOffMain { selectedText -> + if (selectedText.isNotEmpty()) { + copySelectedTextToClipboard(selectedText) + } + } + } + + private fun readSelectedTextOffMain(onRead: (String) -> Unit) { + val inputConnection = currentInputConnection ?: return + ioScope.launch { + val selectedText = runCatching { + inputConnection.getSelectedText(0)?.toString().orEmpty() + }.getOrDefault("") + runOnMainThread { + if (currentInputConnection === inputConnection) { + onRead(selectedText) + } + } + } + } + + private fun shareSelectedTextAction() { + readSelectedTextOffMain { selectedText -> + if (selectedText.isEmpty()) return@readSelectedTextOffMain + val sendIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, selectedText) + } + val chooser = Intent.createChooser(sendIntent, "Share text via").apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + startActivity(chooser) + clearSelection() } } @@ -12689,6 +12892,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, val isSensitive = currentInputType.isPassword() clipboardUtil.setClipBoard(text, isSensitive = isSensitive) appPreference.last_pasted_clipboard_text_preference = "" + editorTextSelected = text.isNotEmpty() + selectedEditorText = text markClipboardPreviewRefreshAfterPrimaryClipChanged() updateClipboardPreview() } @@ -12698,31 +12903,39 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, */ private fun pasteAction() { clearZeroQueryAllState(refresh = false) - when (val item = clipboardUtil.getPrimaryClipContent()) { - is ClipboardItem.Image -> { - commitBitmap(item.bitmap) + val inputConnection = currentInputConnection ?: return + scope.launch { + val item = withContext(Dispatchers.IO) { + clipboardUtil.getPrimaryClipContent() } + if (currentInputConnection !== inputConnection) return@launch + when (item) { + is ClipboardItem.Image -> { + commitBitmap(item.bitmap) + } - is ClipboardItem.Text -> { - if (item.text.isNotEmpty()) { - commitText(item.text, 1) - appPreference.last_pasted_clipboard_text_preference = item.text + is ClipboardItem.Text -> { + if (item.text.isNotEmpty()) { + commitText(item.text, 1) + appPreference.last_pasted_clipboard_text_preference = item.text + } } - } - is ClipboardItem.Empty -> { - // Do nothing + is ClipboardItem.Empty -> { + // Do nothing + } } + clearDeletedBufferWithoutResetLayout() + refreshEditHistoryUi() } - clearDeletedBufferWithoutResetLayout() - refreshEditHistoryUi() } private fun cutAction() { - val selectedText = getSelectedText(0) - if (!selectedText.isNullOrEmpty()) { - copySelectedTextToClipboard(selectedText) - sendDownUpKeyEvents(KeyEvent.KEYCODE_DEL) + readSelectedTextOffMain { selectedText -> + if (selectedText.isNotEmpty()) { + copySelectedTextToClipboard(selectedText) + sendDownUpKeyEvents(KeyEvent.KEYCODE_DEL) + } } } @@ -12895,21 +13108,33 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, // ここで処理を中断するか、別の形式(例: "image/jpeg")を試すか判断できます } - // 1. Bitmapをキャッシュディレクトリ内のファイルに保存 - val cachePath = File(cacheDir, "images") - cachePath.mkdirs() // ディレクトリが存在することを確認 - val imageFile = File(cachePath, "clipboard_image.png") - try { - FileOutputStream(imageFile).use { outputStream -> - bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) + ioScope.launch { + val imageFile = withContext(Dispatchers.IO) { + val cachePath = File(cacheDir, "images") + cachePath.mkdirs() + val file = File(cachePath, "clipboard_image.png") + runCatching { + FileOutputStream(file).use { outputStream -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) + } + file + }.onFailure { + Timber.e(it, "Bitmapのファイルへの保存に失敗しました") + }.getOrNull() + } ?: return@launch + + runOnMainThread { + if (currentInputConnection !== inputConnection) return@runOnMainThread + commitBitmapContent(inputConnection, editorInfo, imageFile) } - // ▼▼▼ ログ追加 ▼▼▼ - Timber.d("commitBitmap: Bitmapをファイルに保存しました: ${imageFile.absolutePath}") - } catch (e: IOException) { - Timber.e(e, "Bitmapのファイルへの保存に失敗しました") - return } + } + private fun commitBitmapContent( + inputConnection: InputConnection, + editorInfo: EditorInfo, + imageFile: File, + ) { // 2. FileProviderを使用してContent URIを取得 val contentUri: Uri try { @@ -14420,7 +14645,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, prevFlag, currentFlag, ) - if (prevFlag == CandidateShowFlag.Idle && currentFlag == CandidateShowFlag.Updating) { + if ( + prevFlag == CandidateShowFlag.Idle && + currentFlag == CandidateShowFlag.Updating && + insertString.isNotEmpty() + ) { clearZeroQueryAllState(refresh = false) shortcutToolbarHiddenForCandidates = true refreshCandidateStripContent(candidatesShown = true) @@ -14531,9 +14760,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } CandidateShowFlag.Updating -> { + val candidateStripActive = insertString.isNotEmpty() clearZeroQueryAllState(refresh = false) - shortcutToolbarHiddenForCandidates = true - refreshCandidateStripContent(candidatesShown = true) + shortcutToolbarHiddenForCandidates = candidateStripActive + refreshCandidateStripContent(candidatesShown = candidateStripActive) val softwareKeyboardVisible = physicalKeyboardEnable.replayCache.firstOrNull() != true val normalKeyboardSurfaceVisible = @@ -14542,12 +14772,15 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mainView.qwertyView.isVisible || mainView.customLayoutDefault.isVisible if ( + candidateStripActive && softwareKeyboardVisible && isKeyboardFloatingMode != true && normalKeyboardSurfaceVisible ) { // セッション最初の Updating でも候補欄の高さを保証する。 setKeyboardHeightWithAdditional(mainView) + } else if (!candidateStripActive && normalKeyboardSurfaceVisible) { + setKeyboardHeightDefault(mainView) } try { setSuggestionOnView(insertString, mainView) @@ -15312,23 +15545,35 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, leftContextOverride: String? = null ): ZenzContext { return try { - withContext(Dispatchers.Main) { - val lastCandidateLength = if (isLiveConversionEnable == true) { - lastCandidate?.length ?: 0 - } else { - insertString.length + val (inputConnection, lastCandidateLength, enableRightContext) = + withContext(Dispatchers.Main.immediate) { + Triple( + currentInputConnection, + if (isLiveConversionEnable == true) { + lastCandidate?.length ?: 0 + } else { + insertString.length + }, + enableZenzRightContextPreference == true, + ) } - - val leftContext = leftContextOverride ?: getLeftContext(inputLength = lastCandidateLength) - .dropLast(lastCandidateLength) + val leftContext = leftContextOverride ?: getLeftContext( + inputConnection = inputConnection, + inputLength = lastCandidateLength, + ).dropLast(lastCandidateLength) + val rawRightContext = if (enableRightContext) { + getRightContext( + inputConnection = inputConnection, + inputLength = lastCandidateLength, + ) + } else { + "" + } + withContext(Dispatchers.Main.immediate) { val resolvedContext = resolveZenzContext( leftContext = leftContext, - rawRightContext = if (enableZenzRightContextPreference == true) { - getRightContext(inputLength = lastCandidateLength) - } else { - "" - }, - enableRightContext = enableZenzRightContextPreference == true + rawRightContext = rawRightContext, + enableRightContext = enableRightContext ) ZenzContext( leftContext = resolvedContext.leftContext, @@ -15374,9 +15619,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private suspend fun updateDisplayedCandidates( insertString: String, - candidates: List + candidates: List, + token: CandidateRequestToken? = null, ) { - if (!shouldApplyCandidateResult(insertString)) { + if (!shouldApplyCandidateResult(insertString, token)) { return } val localCandidates = candidates.withoutZenzLiveSlot(insertString) @@ -15409,7 +15655,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, updateSuggestionAdaptersOnMain( candidates = displayedCandidates, insertString = insertString, - fullCandidates = localCandidates + fullCandidates = localCandidates, + token = token, ) } } @@ -15444,6 +15691,20 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, refreshReconversionUi() } + private fun clearSuggestionStateAfterEditorSelectionChange() { + val hasStaleCandidateState = + currentCandidateStripCandidates.isNotEmpty() || + currentCandidateStripFullCandidates.isNotEmpty() || + filteredCandidateList?.isNotEmpty() == true || + currentCandidateStripContent is CandidateStripContent.Candidates || + currentCandidateStripContent is CandidateStripContent.GemmaActions || + shortcutToolbarHiddenForCandidates || + candidateRefreshRequests.value.flag != CandidateShowFlag.Idle + if (hasStaleCandidateState) { + clearSuggestionStateAfterCommit() + } + } + private fun updateBunsetsuSpaceKeyIfNeeded( mainView: MainLayoutBinding, candidates: List, @@ -15465,7 +15726,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, insertString: String, baseCandidates: List, plan: ZenzRerankPlan, - mainView: MainLayoutBinding + mainView: MainLayoutBinding, + candidateToken: CandidateRequestToken, ) { zenzRerankJob = scope.launch { val reranked = try { @@ -15479,11 +15741,18 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, putCachedZenzRerank(plan.cacheKey, reranked) - if (requestToken != zenzRerankRequestToken || reranked == baseCandidates) { + if (requestToken != zenzRerankRequestToken || + reranked == baseCandidates || + !shouldApplyCandidateResult(insertString, candidateToken) + ) { return@launch } - updateDisplayedCandidates(insertString, reranked) + updateDisplayedCandidates( + insertString = insertString, + candidates = reranked, + token = candidateToken, + ) updateBunsetsuSpaceKeyIfNeeded(mainView, reranked, insertString) if ( @@ -15579,13 +15848,18 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) { // 1. 設定値の読み込み val prefs = getKeyboardSizePreferences() - val isPortrait = resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT + val orientation = resources.configuration.orientation + val isPortrait = orientation == Configuration.ORIENTATION_PORTRAIT val density = resources.displayMetrics.density val screenWidth = resources.displayMetrics.widthPixels val isSymbol = isSymbolOverride ?: keyboardSymbolViewState.value.isShown + val forceFullLayout = !addCandidateTabHeight && ( + lastKeyboardLayoutRootView !== mainView.root || + lastKeyboardLayoutOrientation != orientation + ) applyShortcutToolbarSize( mainView = mainView, - forceLayout = !addCandidateTabHeight + forceLayout = forceFullLayout ) // 2. ピクセル値の計算 @@ -15630,7 +15904,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } // 3. 最終的な高さ、幅、Gravity、マージンの決定 - val candidatesShown = addCandidateTabHeight || shortcutToolbarHiddenForCandidates + val candidatesShown = isCandidateStripActive( + candidatesShown = addCandidateTabHeight || shortcutToolbarHiddenForCandidates, + inputStringEmpty = inputString.value.isEmpty() + ) val presentation = resolveCandidateStripPresentation(candidatesShown = candidatesShown) val candidateStripHeightDp = resolveCandidateStripHeightDp( candidatesShown = candidatesShown, @@ -15710,7 +15987,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, finalBottomMargin = finalBottomMargin, finalStartMargin = finalStartMargin, finalEndMargin = finalEndMargin, - forceLayout = !addCandidateTabHeight + forceLayout = forceFullLayout ) if (isSymbol) { @@ -15736,6 +16013,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mainView.suggestionVisibility.layoutParams = params } } + + lastKeyboardLayoutRootView = mainView.root + lastKeyboardLayoutOrientation = orientation } /** @@ -18143,13 +18423,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, if (isKeyboardFloatingMode == true) return val binding = mainLayoutBinding ?: return measureDebugSection("IMEService.updateMainCandidateStripAfterListUpdated") { - setMainSuggestionColumn(binding) measureDebugSection("IMEService.scrollToPosition0") { binding.suggestionRecyclerView.scrollToPosition(0) } - measureDebugSection("IMEService.updateCandidateStripPresentation.afterListUpdated") { - updateCandidateStripPresentation(binding) - } } } @@ -18161,7 +18437,6 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, return@measureDebugSection } val binding = mainLayoutBinding ?: return@measureDebugSection - setMainSuggestionColumn(binding) binding.suggestionRecyclerView.scrollToPosition(0) } } @@ -20594,25 +20869,51 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, refreshCandidateStripContent() } - private fun canPerformPendingReconversion(entry: ReconversionEntry): Boolean { - if (entry.committedText.isEmpty() || entry.reading.isEmpty()) return false - val textBeforeCursor = currentInputConnection - ?.getTextBeforeCursor(entry.committedText.length, 0) - ?.toString() - .orEmpty() - return textBeforeCursor.endsWith(entry.committedText) - } - private fun shouldShowReconversionButton(): Boolean { if (!reconversionEnabledPreference) return false if (inputString.value.isNotEmpty() || stringInTail.get().isNotEmpty()) return false if (isHenkan.get()) return false - val entry = pendingReconversionEntry ?: return false - return canPerformPendingReconversion(entry) + return pendingReconversionEntry != null && pendingReconversionValid } private fun refreshReconversionUi() { + val entry = pendingReconversionEntry + val requestId = reconversionValidationRequestId.incrementAndGet() + pendingReconversionValid = false refreshCandidateStripContent() + if (!reconversionEnabledPreference || + inputString.value.isNotEmpty() || + stringInTail.get().isNotEmpty() || + isHenkan.get() || + entry == null || + entry.committedText.isEmpty() || + entry.reading.isEmpty() + ) { + return + } + + val inputConnection = currentInputConnection ?: return + ioScope.launch { + val valid = editorConnectionReadMutex.withLock { + withContext(Dispatchers.IO) { + val textBeforeCursor = inputConnection + .getTextBeforeCursor(entry.committedText.length, 0) + ?.toString() + .orEmpty() + textBeforeCursor.endsWith(entry.committedText) + } + } + withContext(Dispatchers.Main.immediate) { + if (reconversionValidationRequestId.get() != requestId || + pendingReconversionEntry !== entry || + currentInputConnection !== inputConnection + ) { + return@withContext + } + pendingReconversionValid = valid + refreshCandidateStripContent() + } + } } private fun clearPendingReconversionEntry() { @@ -20719,7 +21020,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, clearZeroQueryAllState(refresh = false) val entry = pendingReconversionEntry ?: return val mainView = mainLayoutBinding ?: return - if (!canPerformPendingReconversion(entry)) { + if (!pendingReconversionValid) { clearPendingReconversionEntry() return } @@ -20766,9 +21067,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private fun captureDeletedTextFromConnection(inputConnection: InputConnection?): String { val connection = inputConnection ?: return "" - val selectedText = connection.getSelectedText(0)?.toString().orEmpty() - if (selectedText.isNotEmpty()) { - return selectedText + if (editorTextSelected && selectedEditorText.isNotEmpty()) { + return selectedEditorText } return getLastCharacterAsString(connection) } @@ -21998,7 +22298,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) ) return } - updateDisplayedCandidates(insertString, displayedCandidates) + updateDisplayedCandidates( + insertString = insertString, + candidates = displayedCandidates, + token = token, + ) if (shouldApplyLiveConversion && !applyLiveConversionBeforeCandidateStrip) { delayBeforeApplyingLiveConversion() @@ -22028,7 +22332,14 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, updateBunsetsuSpaceKeyIfNeededOnMain(mainView, displayedCandidates, insertString) if (rerankPlan != null && cachedReranked == null) { - maybeLaunchZenzRerank(requestToken, insertString, filtered, rerankPlan, mainView) + maybeLaunchZenzRerank( + requestToken = requestToken, + insertString = insertString, + baseCandidates = filtered, + plan = rerankPlan, + mainView = mainView, + candidateToken = token, + ) } } @@ -22077,7 +22388,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) ) return } - updateDisplayedCandidates(insertString, displayedCandidates) + updateDisplayedCandidates( + insertString = insertString, + candidates = displayedCandidates, + token = token, + ) if (shouldApplyLiveConversion && !applyLiveConversionBeforeCandidateStrip) { delayBeforeApplyingLiveConversion() @@ -22107,7 +22422,14 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, updateBunsetsuSpaceKeyIfNeededOnMain(mainView, displayedCandidates, insertString) if (rerankPlan != null && cachedReranked == null) { - maybeLaunchZenzRerank(requestToken, insertString, filtered, rerankPlan, mainView) + maybeLaunchZenzRerank( + requestToken = requestToken, + insertString = insertString, + baseCandidates = filtered, + plan = rerankPlan, + mainView = mainView, + candidateToken = token, + ) } } @@ -22140,7 +22462,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } else { if (!suppressSuggestions) { - updateSuggestionAdaptersOnMain(filtered, insertString) + updateSuggestionAdaptersOnMain( + candidates = filtered, + insertString = insertString, + token = token, + ) } } @@ -22211,7 +22537,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } else { if (!suppressSuggestions) { - updateSuggestionAdaptersOnMain(filtered, insertString) + updateSuggestionAdaptersOnMain( + candidates = filtered, + insertString = insertString, + token = token, + ) } } @@ -22479,8 +22809,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, orderedCandidates } } - private fun getLeftContext(inputLength: Int): String { - val ic = currentInputConnection ?: return "" + private suspend fun getLeftContext( + inputConnection: InputConnection?, + inputLength: Int, + ): String = withContext(Dispatchers.IO) { + val ic = inputConnection ?: return@withContext "" val lengthToGetTextBeforeCursor = (8 + inputLength).coerceAtMost(64) // カーソル前のテキストを取得 val charSequence = ic.getTextBeforeCursor(lengthToGetTextBeforeCursor, 0) @@ -22489,11 +22822,14 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, Timber.d("getLeftContext: inputLength [$inputLength] text: [$text]") // 改行記号 '\n' があれば、それより後ろの部分だけを返す。 // 改行がない場合は、テキスト全体が返されます。 - return text.substringAfterLast('\n') + text.substringAfterLast('\n') } - private fun getRightContext(inputLength: Int): String { - val ic = currentInputConnection ?: return "" + private suspend fun getRightContext( + inputConnection: InputConnection?, + inputLength: Int, + ): String = withContext(Dispatchers.IO) { + val ic = inputConnection ?: return@withContext "" val lengthToGetTextAfterCursor = (8 + inputLength).coerceAtMost(64) // カーソル後のテキストを取得 @@ -22502,7 +22838,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, Timber.d("getRightContext: inputLength [$inputLength] text: [$text]") - return text.substringBefore('\n') + text.substringBefore('\n') } private suspend fun getSuggestionListWithoutPrediction( @@ -22651,6 +22987,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, typoCorrectionJapaneseFlickEnabled: Boolean = false, typoCorrectionQwertyEnglishEnabled: Boolean = false, ): KanaKanjiQueryResult { + awaitSystemUserDictionaryLoad() val session = kanaKanjiConversionSession ?: KanaKanjiConversionSession( engine = kanaKanjiEngine, backend = conversionBackend, @@ -22738,33 +23075,30 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, setComposingText("", 0) finishComposingText() } else { - val textBeforeCursor = inputConnection.getTextBeforeCursor(100, 0)?.toString() ?: "" - if (textBeforeCursor.isEmpty()) return - - val charsToDelete = deleteKeyFlickTargetChars + ALWAYS_DELETE_KEY_FLICK_BOUNDARIES - - var deleteCount = 0 - - // カーソル直前の1文字が指定記号かどうかをチェック - if (textBeforeCursor.last() in charsToDelete) { - // 記号の場合、1文字だけ削除する - deleteCount = 1 - } else { - // 記号でない場合、空白まで遡って単語の長さを数える - for (char in textBeforeCursor.reversed()) { - if (char.isWhitespace() || char in charsToDelete) { - // 単語の区切り(空白または記号)が見つかったら停止 - break + scope.launch { + val textBeforeCursor = editorConnectionReadMutex.withLock { + withContext(Dispatchers.IO) { + inputConnection.getTextBeforeCursor(100, 0)?.toString().orEmpty() } - deleteCount++ } - } + if (currentInputConnection !== inputConnection || isHenkan.get()) return@launch + if (textBeforeCursor.isEmpty()) return@launch - if (deleteCount > 0) { - val deletedText = textBeforeCursor.takeLast(deleteCount) - inputConnection.deleteSurroundingText(deleteCount, 0) - if (deletedText.isNotEmpty()) { - pushEditHistoryEntry(EditHistoryEntry.DeleteCommittedText(deletedText)) + val charsToDelete = deleteKeyFlickTargetChars + ALWAYS_DELETE_KEY_FLICK_BOUNDARIES + val deleteCount = if (textBeforeCursor.last() in charsToDelete) { + 1 + } else { + textBeforeCursor.reversed().takeWhile { + !it.isWhitespace() && it !in charsToDelete + }.length + } + + if (deleteCount > 0) { + val deletedText = textBeforeCursor.takeLast(deleteCount) + inputConnection.deleteSurroundingText(deleteCount, 0) + if (deletedText.isNotEmpty()) { + pushEditHistoryEntry(EditHistoryEntry.DeleteCommittedText(deletedText)) + } } } } @@ -22784,31 +23118,35 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, return } - val textAfterCursor = inputConnection.getTextAfterCursor(100, 0)?.toString() ?: "" - if (textAfterCursor.isEmpty()) return - - val charsToDelete = deleteKeyFlickTargetChars + ALWAYS_DELETE_KEY_FLICK_BOUNDARIES + scope.launch { + val textAfterCursor = editorConnectionReadMutex.withLock { + withContext(Dispatchers.IO) { + inputConnection.getTextAfterCursor(100, 0)?.toString().orEmpty() + } + } + if (currentInputConnection !== inputConnection || isHenkan.get()) return@launch + if (textAfterCursor.isEmpty()) return@launch - var deleteCount = 0 - if (textAfterCursor.first() in charsToDelete) { - deleteCount = 1 - } else { - for (char in textAfterCursor) { - if (char.isWhitespace() || char in charsToDelete) break - deleteCount++ + val charsToDelete = deleteKeyFlickTargetChars + ALWAYS_DELETE_KEY_FLICK_BOUNDARIES + val deleteCount = if (textAfterCursor.first() in charsToDelete) { + 1 + } else { + textAfterCursor.takeWhile { + !it.isWhitespace() && it !in charsToDelete + }.length } - } - if (deleteCount > 0) { - val deletedText = textAfterCursor.take(deleteCount) - inputConnection.deleteSurroundingText(0, deleteCount) - if (deletedText.isNotEmpty()) { - pushEditHistoryEntry( - EditHistoryEntry.DeleteCommittedText( - deletedText = deletedText, - direction = DeleteDirection.AfterCursor + if (deleteCount > 0) { + val deletedText = textAfterCursor.take(deleteCount) + inputConnection.deleteSurroundingText(0, deleteCount) + if (deletedText.isNotEmpty()) { + pushEditHistoryEntry( + EditHistoryEntry.DeleteCommittedText( + deletedText = deletedText, + direction = DeleteDirection.AfterCursor + ) ) - ) + } } } } @@ -23576,27 +23914,35 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } private fun moveCursorLeftBySelection() { - if (currentInputConnection == null) return - - beginBatchEdit() - try { - val req = ExtractedTextRequest() - req.token = 0 - req.flags = 0 - val extractedText = getExtractedText(req, 0) + val inputConnection = currentInputConnection ?: return + scope.launch { + val start = editorConnectionReadMutex.withLock { + withContext(Dispatchers.IO) { + val req = ExtractedTextRequest().apply { + token = 0 + flags = 0 + } + runCatching { + inputConnection.getExtractedText(req, 0)?.selectionStart + }.getOrNull() + } + } + if (currentInputConnection !== inputConnection) return@launch - if (extractedText != null) { - val start = extractedText.selectionStart - if (start > 0) { - setSelection(start - 1, start - 1) + beginBatchEdit() + try { + if (start != null) { + if (start > 0) { + setSelection(start - 1, start - 1) + } + } else { + sendDpadLeftIfPossible() } - } else { - sendDpadLeftIfPossible() + } catch (e: Exception) { + Timber.e(e) + } finally { + endBatchEdit() } - } catch (e: Exception) { - Timber.e(e) - } finally { - endBatchEdit() } } @@ -24022,41 +24368,57 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } - private fun isCursorAtBeginning(): Boolean { - val extractedText = runCatching { - currentInputConnection?.getExtractedText(ExtractedTextRequest(), 0) - }.getOrNull() - extractedText?.selectionStart?.let { return it <= 0 } - val textBeforeCursor = runCatching { - currentInputConnection?.getTextBeforeCursor(1, 0) - }.getOrNull() - return textBeforeCursor.isNullOrEmpty() + private suspend fun isCursorAtBeginning(): Boolean { + val inputConnection = currentInputConnection ?: return true + return editorConnectionReadMutex.withLock { + withContext(Dispatchers.IO) { + val extractedText = runCatching { + inputConnection.getExtractedText(ExtractedTextRequest(), 0) + }.getOrNull() + extractedText?.selectionStart?.let { return@withContext it <= 0 } + val textBeforeCursor = runCatching { + inputConnection.getTextBeforeCursor(1, 0) + }.getOrNull() + textBeforeCursor.isNullOrEmpty() + } + } } - private fun isCursorAtEnd(): Boolean { - val extractedText = runCatching { - currentInputConnection?.getExtractedText(ExtractedTextRequest(), 0) - }.getOrNull() - extractedText?.let { - val textLength = it.text?.length ?: 0 - val cursorPosition = it.selectionEnd - return cursorPosition >= textLength + private suspend fun isCursorAtEnd(): Boolean { + val inputConnection = currentInputConnection ?: return true + return editorConnectionReadMutex.withLock { + withContext(Dispatchers.IO) { + val extractedText = runCatching { + inputConnection.getExtractedText(ExtractedTextRequest(), 0) + }.getOrNull() + extractedText?.let { + val textLength = it.text?.length ?: 0 + val cursorPosition = it.selectionEnd + return@withContext cursorPosition >= textLength + } + val textAfterCursor = runCatching { + inputConnection.getTextAfterCursor(1, 0) + }.getOrNull() + textAfterCursor.isNullOrEmpty() + } } - val textAfterCursor = runCatching { - currentInputConnection?.getTextAfterCursor(1, 0) - }.getOrNull() - return textAfterCursor.isNullOrEmpty() } private fun sendDpadLeftIfPossible() { - if (!isCursorAtBeginning()) { - sendDownUpKeyEvents(KeyEvent.KEYCODE_DPAD_LEFT) + val inputConnection = currentInputConnection ?: return + scope.launch { + if (!isCursorAtBeginning() && currentInputConnection === inputConnection) { + sendDownUpKeyEvents(KeyEvent.KEYCODE_DPAD_LEFT) + } } } private fun sendDpadRightIfPossible() { - if (!isCursorAtEnd()) { - sendDownUpKeyEvents(KeyEvent.KEYCODE_DPAD_RIGHT) + val inputConnection = currentInputConnection ?: return + scope.launch { + if (!isCursorAtEnd() && currentInputConnection === inputConnection) { + sendDownUpKeyEvents(KeyEvent.KEYCODE_DPAD_RIGHT) + } } } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/clipboard/ClipboardUtil.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/clipboard/ClipboardUtil.kt index e354c6550..3b5ff7bab 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/clipboard/ClipboardUtil.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/clipboard/ClipboardUtil.kt @@ -41,12 +41,21 @@ class ClipboardUtil(private val context: Context) { * @return 取得したコンテンツを表す [ClipboardItem]。([ClipboardItem.Image]、[ClipboardItem.Text]、または [ClipboardItem.Empty]) */ fun getPrimaryClipContent(): ClipboardItem { + return getPrimaryClipContent(maxImageDimension = null) + } + + /** + * クリップボードの主要なコンテンツを取得します。 + * [maxImageDimension] が指定された場合、画像は指定サイズ以下になるよう縮小します。 + * 候補欄のプレビューなど、元画像の解像度を必要としない用途で使用します。 + */ + fun getPrimaryClipContent(maxImageDimension: Int?): ClipboardItem { if (!clipboard.hasPrimaryClip()) { return ClipboardItem.Empty } // 1. 画像の取得を最優先で試みる - getClipboardImageBitmap()?.let { bitmap -> + getClipboardImageBitmap(maxImageDimension)?.let { bitmap -> return ClipboardItem.Image(id = 0, bitmap = bitmap) } @@ -61,6 +70,10 @@ class ClipboardUtil(private val context: Context) { return ClipboardItem.Empty } + fun getPrimaryClipPreviewContent(maxImageDimension: Int = 256): ClipboardItem { + return getPrimaryClipContent(maxImageDimension = maxImageDimension) + } + /** * クリップボードにテキストが存在するかどうかを確認します。 * @return テキストが存在しない場合はtrue、存在する場合はfalse。 @@ -127,7 +140,7 @@ class ClipboardUtil(private val context: Context) { * * @return 成功した場合はBitmapオブジェクト、失敗した場合はnull。 */ - fun getClipboardImageBitmap(): Bitmap? { + fun getClipboardImageBitmap(maxImageDimension: Int? = null): Bitmap? { if (!clipboard.hasPrimaryClip()) { return null } @@ -141,8 +154,31 @@ class ClipboardUtil(private val context: Context) { val uri = item.uri if (uri != null) { try { + if (maxImageDimension == null || maxImageDimension <= 0) { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + return BitmapFactory.decodeStream(inputStream) + } + } + + val bounds = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + context.contentResolver.openInputStream(uri)?.use { inputStream -> + BitmapFactory.decodeStream(inputStream, null, bounds) + } + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) continue + + var sampleSize = 1 + val largestDimension = maxOf(bounds.outWidth, bounds.outHeight) + val targetDimension = checkNotNull(maxImageDimension) + while (largestDimension / sampleSize > targetDimension) { + sampleSize = sampleSize shl 1 + } + val options = BitmapFactory.Options().apply { + inSampleSize = sampleSize + } context.contentResolver.openInputStream(uri)?.use { inputStream -> - return BitmapFactory.decodeStream(inputStream) + return BitmapFactory.decodeStream(inputStream, null, options) } } catch (e: Exception) { Timber.e("ClipboardUtil", "URIからのBitmapデコードに失敗しました: $uri", e) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveRenderer.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveRenderer.kt index c362822be..291095c2d 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveRenderer.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveRenderer.kt @@ -13,8 +13,6 @@ import android.os.Process import android.os.SystemClock import android.view.Surface import timber.log.Timber -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import kotlin.math.exp @@ -113,7 +111,7 @@ internal class CinematicWaveRenderer( } override fun detachSurface() { - runBlockingOnRendererThread(maxWaitMillis = 120L) { + postOnRenderer { handler.removeCallbacks(frameRunnable) frameScheduled = false paused = true @@ -162,8 +160,8 @@ internal class CinematicWaveRenderer( } override fun release() { - runBlockingOnRendererThread(maxWaitMillis = 250L) { - if (released) return@runBlockingOnRendererThread + postOnRenderer { + if (released) return@postOnRenderer released = true handler.removeCallbacks(frameRunnable) frameScheduled = false @@ -174,8 +172,8 @@ internal class CinematicWaveRenderer( simulation = null releaseEglSurfaceOnly() surfaceTexture = null + rendererThread.quitSafely() } - rendererThread.quitSafely() } override fun isRendererThreadAliveForTesting(): Boolean { @@ -395,22 +393,6 @@ internal class CinematicWaveRenderer( } } - private fun runBlockingOnRendererThread(maxWaitMillis: Long, action: () -> Unit) { - if (Looper.myLooper() == handler.looper) { - action() - return - } - if (!rendererThread.isAlive) return - val latch = CountDownLatch(1) - handler.post { - runCatching(action).onFailure { - Timber.w(it, "Failed to run cinematic wave renderer cleanup.") - } - latch.countDown() - } - latch.await(maxWaitMillis, TimeUnit.MILLISECONDS) - } - private fun runRendererCatching(operation: String, action: () -> Unit) { runCatching(action).onFailure { throwable -> Timber.w(throwable, "Cinematic wave renderer failed during %s", operation) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/FluidInkRenderer.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/FluidInkRenderer.kt index c5842bc02..e83df5ec9 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/FluidInkRenderer.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/FluidInkRenderer.kt @@ -12,8 +12,6 @@ import android.os.Looper import android.os.Process import android.view.Surface import timber.log.Timber -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger internal class FluidInkRenderer( @@ -122,7 +120,7 @@ internal class FluidInkRenderer( } override fun detachSurface() { - runBlockingOnRendererThread(maxWaitMillis = 120L) { + postOnRenderer { handler.removeCallbacks(frameRunnable) frameScheduled = false paused = true @@ -163,8 +161,8 @@ internal class FluidInkRenderer( } override fun release() { - runBlockingOnRendererThread(maxWaitMillis = 250L) { - if (released) return@runBlockingOnRendererThread + postOnRenderer { + if (released) return@postOnRenderer released = true handler.removeCallbacks(frameRunnable) frameScheduled = false @@ -173,8 +171,8 @@ internal class FluidInkRenderer( simulation?.release() simulation = null releaseEglSurfaceOnly() + rendererThread.quitSafely() } - rendererThread.quitSafely() } override fun isRendererThreadAliveForTesting(): Boolean { @@ -278,22 +276,6 @@ internal class FluidInkRenderer( } } - private fun runBlockingOnRendererThread(maxWaitMillis: Long, action: () -> Unit) { - if (Looper.myLooper() == handler.looper) { - action() - return - } - if (!rendererThread.isAlive) return - val latch = CountDownLatch(1) - handler.post { - runCatching(action).onFailure { - Timber.w(it, "Failed to run fluid renderer cleanup.") - } - latch.countDown() - } - latch.await(maxWaitMillis, TimeUnit.MILLISECONDS) - } - private fun runRendererCatching(operation: String, action: () -> Unit) { runCatching(action).onFailure { throwable -> Timber.w(throwable, "Suminagashi fluid renderer failed during %s", operation) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LiquidRippleRenderer.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LiquidRippleRenderer.kt index 1cb90c9b1..cdabe0463 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LiquidRippleRenderer.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LiquidRippleRenderer.kt @@ -12,8 +12,6 @@ import android.os.Looper import android.os.Process import android.view.Surface import timber.log.Timber -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger internal class LiquidRippleRenderer( @@ -119,7 +117,7 @@ internal class LiquidRippleRenderer( } override fun detachSurface() { - runBlockingOnRendererThread(maxWaitMillis = 120L) { + postOnRenderer { handler.removeCallbacks(frameRunnable) frameScheduled = false paused = true @@ -160,8 +158,8 @@ internal class LiquidRippleRenderer( } override fun release() { - runBlockingOnRendererThread(maxWaitMillis = 250L) { - if (released) return@runBlockingOnRendererThread + postOnRenderer { + if (released) return@postOnRenderer released = true handler.removeCallbacks(frameRunnable) frameScheduled = false @@ -170,8 +168,8 @@ internal class LiquidRippleRenderer( simulation?.release() simulation = null releaseEglSurfaceOnly() + rendererThread.quitSafely() } - rendererThread.quitSafely() } override fun isRendererThreadAliveForTesting(): Boolean { @@ -276,22 +274,6 @@ internal class LiquidRippleRenderer( } } - private fun runBlockingOnRendererThread(maxWaitMillis: Long, action: () -> Unit) { - if (Looper.myLooper() == handler.looper) { - action() - return - } - if (!rendererThread.isAlive) return - val latch = CountDownLatch(1) - handler.post { - runCatching(action).onFailure { - Timber.w(it, "Failed to run liquid ripple renderer cleanup.") - } - latch.countDown() - } - latch.await(maxWaitMillis, TimeUnit.MILLISECONDS) - } - private fun runRendererCatching(operation: String, action: () -> Unit) { runCatching(action).onFailure { throwable -> Timber.w(throwable, "Liquid ripple renderer failed during %s", operation) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobRenderer.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobRenderer.kt index 1f4afade4..26f39c95a 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobRenderer.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobRenderer.kt @@ -12,8 +12,6 @@ import android.os.Looper import android.os.Process import android.view.Surface import timber.log.Timber -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger internal class LuminousBlobRenderer( @@ -118,7 +116,7 @@ internal class LuminousBlobRenderer( } override fun detachSurface() { - runBlockingOnRendererThread(maxWaitMillis = 120L) { + postOnRenderer { handler.removeCallbacks(frameRunnable) frameScheduled = false paused = true @@ -161,8 +159,8 @@ internal class LuminousBlobRenderer( } override fun release() { - runBlockingOnRendererThread(maxWaitMillis = 250L) { - if (released) return@runBlockingOnRendererThread + postOnRenderer { + if (released) return@postOnRenderer released = true handler.removeCallbacks(frameRunnable) frameScheduled = false @@ -172,8 +170,8 @@ internal class LuminousBlobRenderer( simulation?.release() simulation = null releaseEglSurfaceOnly() + rendererThread.quitSafely() } - rendererThread.quitSafely() } override fun isRendererThreadAliveForTesting(): Boolean { @@ -372,22 +370,6 @@ internal class LuminousBlobRenderer( } } - private fun runBlockingOnRendererThread(maxWaitMillis: Long, action: () -> Unit) { - if (Looper.myLooper() == handler.looper) { - action() - return - } - if (!rendererThread.isAlive) return - val latch = CountDownLatch(1) - handler.post { - runCatching(action).onFailure { - Timber.w(it, "Failed to run luminous blob renderer cleanup.") - } - latch.countDown() - } - latch.await(maxWaitMillis, TimeUnit.MILLISECONDS) - } - private fun runRendererCatching(operation: String, action: () -> Unit) { runCatching(action).onFailure { throwable -> Timber.w(throwable, "Luminous blob renderer failed during %s", operation) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/SprayPaintRenderer.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/SprayPaintRenderer.kt index 1f9872ccb..86d2e1059 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/SprayPaintRenderer.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/SprayPaintRenderer.kt @@ -12,8 +12,6 @@ import android.os.Looper import android.os.Process import android.view.Surface import timber.log.Timber -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import kotlin.math.sqrt @@ -121,7 +119,7 @@ internal class SprayPaintRenderer( } override fun detachSurface() { - runBlockingOnRendererThread(maxWaitMillis = 120L) { + postOnRenderer { handler.removeCallbacks(frameRunnable) frameScheduled = false paused = true @@ -162,8 +160,8 @@ internal class SprayPaintRenderer( } override fun release() { - runBlockingOnRendererThread(maxWaitMillis = 250L) { - if (released) return@runBlockingOnRendererThread + postOnRenderer { + if (released) return@postOnRenderer released = true handler.removeCallbacks(frameRunnable) frameScheduled = false @@ -172,8 +170,8 @@ internal class SprayPaintRenderer( simulation?.release() simulation = null releaseEglSurfaceOnly() + rendererThread.quitSafely() } - rendererThread.quitSafely() } override fun isRendererThreadAliveForTesting(): Boolean { @@ -308,22 +306,6 @@ internal class SprayPaintRenderer( } } - private fun runBlockingOnRendererThread(maxWaitMillis: Long, action: () -> Unit) { - if (Looper.myLooper() == handler.looper) { - action() - return - } - if (!rendererThread.isAlive) return - val latch = CountDownLatch(1) - handler.post { - runCatching(action).onFailure { - Timber.w(it, "Failed to run spray paint renderer cleanup.") - } - latch.countDown() - } - latch.await(maxWaitMillis, TimeUnit.MILLISECONDS) - } - private fun runRendererCatching(operation: String, action: () -> Unit) { runCatching(action).onFailure { throwable -> Timber.w(throwable, "Spray paint renderer failed during %s", operation) diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CandidateStripPresentationPolicyTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CandidateStripPresentationPolicyTest.kt index 81502a9bb..ab2ad0bac 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CandidateStripPresentationPolicyTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CandidateStripPresentationPolicyTest.kt @@ -180,16 +180,58 @@ class CandidateStripPresentationPolicyTest { @Test fun candidateTabVisibilityTrueShowsCandidateTabWhenCandidatesAreShown() { val presentation = CandidateStripPresentationPolicy.resolve( - baseState(candidateTabVisible = true, candidatesShown = true) + baseState( + candidateTabVisible = true, + candidatesShown = true, + inputStringEmpty = false + ) ) assertTrue(presentation.showCandidateTab) } + @Test + fun candidateTabIsHiddenWhenInputStringIsEmptyEvenIfCandidatesAreMarkedShown() { + val presentation = CandidateStripPresentationPolicy.resolve( + baseState( + candidateTabVisible = true, + candidatesShown = true, + inputStringEmpty = true + ) + ) + + assertFalse(presentation.showCandidateTab) + } + + @Test + fun candidateStripUsesEmptyStateWhenCandidatesAreMarkedShownForEmptyInput() { + assertFalse( + isCandidateStripActive( + candidatesShown = true, + inputStringEmpty = true + ) + ) + assertEquals( + 80, + resolveCandidateStripHeightDp( + candidatesShown = isCandidateStripActive( + candidatesShown = true, + inputStringEmpty = true + ), + candidateHeightDp = 160, + emptyHeightDp = 80 + ) + ) + } + @Test fun visibleCandidateTabAlwaysReservesItsHeight() { val presentation = CandidateStripPresentationPolicy.resolve( - baseState(candidateTabVisible = true, candidatesShown = true) + baseState( + candidateTabVisible = true, + candidatesShown = true, + inputStringEmpty = false + ) ) assertEquals(36, resolveCandidateTabOffsetPx(presentation, 36)) @@ -247,6 +289,7 @@ class CandidateStripPresentationPolicyTest { baseState( shortcutToolbarIntegratedInSuggestion = false, candidatesShown = true, + inputStringEmpty = false, shortcutToolbarHiddenForCandidates = true ) ) diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceClipboardPreviewRegressionContractTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceClipboardPreviewRegressionContractTest.kt index c67a50939..dd16cbe50 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceClipboardPreviewRegressionContractTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceClipboardPreviewRegressionContractTest.kt @@ -20,7 +20,7 @@ class IMEServiceClipboardPreviewRegressionContractTest { @Test fun onUpdateSelectionRefreshesClipboardPreviewBeforeSelectedTextEarlyReturnAfterCopy() { - val body = functionBody(imeServiceLines(), "onUpdateSelection").joinToString("\n") + val body = functionBody(imeServiceLines(), "handleSelectedTextSelection").joinToString("\n") val selectedCopyRefresh = body.indexOf("selectedTextClipboardPreviewRefreshText == selectedText") val updateClipboard = body.indexOf("updateClipboardPreview()", startIndex = selectedCopyRefresh) @@ -52,6 +52,8 @@ class IMEServiceClipboardPreviewRegressionContractTest { fun clipboardPreviewRefreshFlagControlsEditorSelectionSuppression() { val body = functionBody(imeServiceLines(), "buildCandidateStripInputState").joinToString("\n") + assertFalse(body.contains("currentInputConnection?.getSelectedText(0)")) + assertTrue(body.contains("editorTextSelected")) assertTrue(body.contains("selectedTextClipboardPreviewRefreshText != selectedEditorText")) assertTrue(body.contains("editorTextSelected = shouldSuppressClipboardPreviewForSelectedText")) } @@ -60,6 +62,8 @@ class IMEServiceClipboardPreviewRegressionContractTest { fun clipboardPreviewSnapshotKeepsImageBitmapAndLastPastedCondition() { val body = functionBody(imeServiceLines(), "resolveClipboardPreviewSnapshot").joinToString("\n") + assertFalse(body.contains("clipboardUtil.getPrimaryClipContent()")) + assertTrue(body.contains("cachedClipboardPreviewItem")) assertTrue(body.contains("bitmap = item.bitmap")) assertTrue(body.contains("getClipboardPreviewText(item.text)")) assertTrue(body.contains("appPreference.last_pasted_clipboard_text_preference == item.text")) diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceSelectionCleanupContractTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceSelectionCleanupContractTest.kt new file mode 100644 index 000000000..afaa9006c --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceSelectionCleanupContractTest.kt @@ -0,0 +1,43 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class IMEServiceSelectionCleanupContractTest { + + @Test + fun emptySelectionUpdateClearsCandidateStateAfterComposingTextFinishes() { + val source = imeServiceSource() + val updateSelection = source.functionBody( + start = "override fun onUpdateSelection(", + end = "override fun onKeyDown(" + ) + + assertTrue( + updateSelection.contains("clearSuggestionStateAfterEditorSelectionChange()") + ) + + val cleanup = source.functionBody( + start = "private fun clearSuggestionStateAfterEditorSelectionChange", + end = "private fun updateBunsetsuSpaceKeyIfNeeded" + ) + assertTrue(cleanup.contains("currentCandidateStripFullCandidates.isNotEmpty()")) + assertTrue(cleanup.contains("candidateRefreshRequests.value.flag")) + assertTrue(cleanup.contains("clearSuggestionStateAfterCommit()")) + } + + private fun imeServiceSource(): String = + listOf( + File("app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt"), + File("src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt") + ).first { it.isFile }.readText() + + private fun String.functionBody(start: String, end: String): String { + val startIndex = indexOf(start) + require(startIndex >= 0) { "Missing start marker: $start" } + val endIndex = indexOf(end, startIndex + start.length) + require(endIndex >= 0) { "Missing end marker: $end" } + return substring(startIndex, endIndex) + } +} diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt index 094ceb49a..fe2d520bd 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt @@ -181,6 +181,7 @@ class FlickKeyboardView @JvmOverloads constructor( private val canonicalGuideLabels = IdentityHashMap() private var currentLayout: KeyboardLayout? = null + private var controllerRebindPending = false private var keyboardRenderRevision: Int = 0 private var renderedKeyboardRenderRevision: Int = -1 private var keyCharacterCase: KeyCharacterCase = KeyCharacterCase.AS_DEFINED @@ -569,6 +570,7 @@ class FlickKeyboardView @JvmOverloads constructor( childCount == expectedChildCount ) { Log.d("FlickKeyboardView", "setKeyboard (Reuse Existing Views)") + cancelTrackedTouchState() return } Log.d("FlickKeyboardView", "setKeyboard (Full Rebuild)") @@ -3062,7 +3064,26 @@ class FlickKeyboardView @JvmOverloads constructor( return super.onTouchEvent(event) } + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (!controllerRebindPending) return + + controllerRebindPending = false + post { + if (isAttachedToWindow) { + Log.d( + "FlickKeyboardView", + "Rebuilding input controllers after window reattach" + ) + rebuildCurrentKeyboard() + } else { + controllerRebindPending = true + } + } + } + override fun onDetachedFromWindow() { + controllerRebindPending = true doubleTapActionDispatcher.cancel() cancelTextPreview() cancelTrackedTouchState() diff --git a/tenkey/src/main/java/com/kazumaproject/tenkey/TenKey.kt b/tenkey/src/main/java/com/kazumaproject/tenkey/TenKey.kt index 72aaea24f..ea40e8575 100644 --- a/tenkey/src/main/java/com/kazumaproject/tenkey/TenKey.kt +++ b/tenkey/src/main/java/com/kazumaproject/tenkey/TenKey.kt @@ -138,6 +138,7 @@ class TenKey(context: Context, attributeSet: AttributeSet) : private var keyTouchCancelListener: KeyTouchCancelListener? = null private var inputModeChangedListener: ((InputMode) -> Unit)? = null private var qwertyNumberModeRequestedListener: (() -> Unit)? = null + private var attachedToWindowListener: (() -> Unit)? = null private val flickTextPreviewEmitter = FlickTextPreviewEmitter() private var flickSensitivity: Int = 100 @@ -1344,6 +1345,10 @@ class TenKey(context: Context, attributeSet: AttributeSet) : qwertyNumberModeRequestedListener = listener } + fun setOnAttachedToWindowListener(listener: (() -> Unit)?) { + attachedToWindowListener = listener + } + /** Padding setters for side keys (symbol, cursors, delete, enter, previous char) **/ fun setPaddingToSideKeySymbol(paddingSize: Int) { binding.sideKeySymbolModeContainer.setIconPadding(paddingSize) @@ -1385,18 +1390,14 @@ class TenKey(context: Context, attributeSet: AttributeSet) : /** Clean up references when view is detached **/ private fun release() { cancelActiveTouch(KeyTouchCancelReason.DetachedFromWindow) - flickListener = null - longPressListener = null - keyTouchCancelListener = null - inputModeChangedListener = null - qwertyNumberModeRequestedListener = null flickTextPreviewEmitter.cancel() flickTextPreviewEmitter.listener = null longPressJob?.cancel() longPressJob = null isCursorMode = false - // ← CANCEL the observing coroutine when the view is detached - //scope.coroutineContext.cancelChildren() + // The IME service reuses this view across input-view detach/attach cycles, including + // rotation. Keep service-owned listeners until the service replaces the view; only + // transient touch state is cancelled here. } fun cancelTenKeyScope() { @@ -1405,10 +1406,14 @@ class TenKey(context: Context, attributeSet: AttributeSet) : override fun onDetachedFromWindow() { super.onDetachedFromWindow() - Log.d("TenKey: onDetachedFromWindow", "called") release() } + override fun onAttachedToWindow() { + super.onAttachedToWindow() + attachedToWindowListener?.invoke() + } + override fun onVisibilityChanged(changedView: View, visibility: Int) { super.onVisibilityChanged(changedView, visibility) if (changedView == this && visibility != View.VISIBLE) {