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/docs/custom-key-special-key-icon-color-design.md b/docs/custom-key-special-key-icon-color-design.md deleted file mode 100644 index ca269ab1e..000000000 --- a/docs/custom-key-special-key-icon-color-design.md +++ /dev/null @@ -1,270 +0,0 @@ -# カスタムキーボード特殊キーアイコン/色設計 - -## 目的 - -カスタムキーボードの特殊キーについて、次を既存機能を壊さずに実現する。 - -- 濁点/小文字系アクションの既定アイコンを、現在より読みやすいサイズで表示する。 -- 特殊キーごとに、キーの見た目色を「特殊キー色」または「通常キー色」から選べるようにする。 - -ここでいう濁点/小文字系アクションは次を対象にする。 - -- `KeyAction.ToggleDakuten` -- `KeyAction.ToggleDakutenOnly` -- `KeyAction.ToggleHandakutenOnly` -- `KeyAction.ToggleCase` - -## 現状 - -`custom_keyboard` 側では `KeyData.isSpecialKey` が、入力処理上の特殊キー判定と見た目上の特殊キー色判定を兼ねている。 - -代表例: - -- `KeyIconResolver.hasIcon()` は `isSpecialKey` かつ `drawableResId`/`icon` がある場合に `AppCompatImageButton` として描画する。 -- `FlickKeyboardView.createKeyView()` は `keyData.isSpecialKey` を見て、side 系背景、`customSpecialKeyColor`、`customSpecialKeyTextColor` を使う。 -- `FlickKeyboardView.getSpecialIconTargetSizePx()` は全特殊キーアイコンへ共通サイズを使い、入力モード切替系だけ `INPUT_MODE_SWITCH_ICON_SIZE_MULTIPLIER` で大きくしている。 -- `KeyActionMapper` と `KeyboardRepository.drawableResIdForAction()` は濁点/小文字系アクションに `kana_small` / `kana_small_custom` / `english_small` を割り当てている。 - -このため、濁点/小文字系アクションは他の特殊キーと同じアイコンサイズになり、視認上小さく見える。また、特殊キーの動作を保ったまま通常キー色で表示する選択肢がない。 - -## アイコンが小さい根本原因 - -濁点/小文字系アイコンの小ささは、`FlickKeyboardView` のスケール計算だけが原因ではない。 - -`FlickKeyboardView.updateImageButtonMatrix()` は drawable の `intrinsicWidth` / `intrinsicHeight` を使って、drawable 全体が目標サイズに収まるようにスケールしている。これは一般的な `ImageView` として自然な処理であり、通常の 24dp アイコンでは問題になりにくい。 - -問題は、対象 drawable の有効描画範囲が viewport に対して極端に小さいこと。 - -- `kana_small.xml`: `width/height = 100dp`, `viewport = 100x100`。実際の path はおおむね x=37〜64、y=36〜62 に集中しており、描画範囲は viewport の約 27% 四方しかない。 -- `kana_small_custom.xml`: `width/height = 42dp`, `viewport = 100x100`。path の座標範囲は `kana_small.xml` と同系統なので、intrinsic size を変えても余白比率は残る。 -- `english_small.xml`: `width/height = 100dp`, `viewport = 100x100`。path はおおむね x=34〜66、y=42〜58 で、特に縦方向の描画範囲が約 15% しかない。 -- 比較対象の `backspace_24px.xml` は viewport 960 に対して x=80〜880、y=160〜800 程度を使っており、表示面積が大きい。 - -つまり、現在の濁点/小文字系 drawable は「大きな透明余白を含むアイコン」として定義されている。ビュー側で個別倍率を足すと見た目は改善するが、壊れた asset framing を描画側で補正する形になり、同じ drawable を使う別表示や将来の icon sizing でも同じ問題が残る。 - -## 設計方針 - -1. `isSpecialKey` は動作判定として維持する。 -2. キーの色だけを切り替える描画専用プロパティを追加する。 -3. 既存データはすべて従来通り「特殊キー色」で復元する。 -4. 濁点/小文字系アイコンは、drawable の有効描画範囲を正規化した action default icon を用意して根本から改善する。 -5. `FlickKeyboardView` には濁点/小文字専用倍率を追加しない。ビューは「同じ optical size に整った drawable を同じ規則で描画する」責務に留める。 - -## データモデル - -`custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KeyModels.kt` に特殊キー色スタイルを追加する。 - -```kotlin -enum class SpecialKeyColorStyle(val dbValue: String) { - SPECIAL("SPECIAL"), - NORMAL("NORMAL"); - - companion object { - fun fromDbValue(value: String?): SpecialKeyColorStyle = - entries.firstOrNull { it.dbValue == value } ?: SPECIAL - } -} -``` - -`KeyData` には末尾パラメータとして追加し、既存コンストラクタ呼び出しを壊さない。 - -```kotlin -data class KeyData( - ... - val keyType: KeyType = if (isFlickable) KeyType.CIRCULAR_FLICK else KeyType.NORMAL, - val specialKeyColorStyle: SpecialKeyColorStyle = SpecialKeyColorStyle.SPECIAL -) -``` - -通常キーではこの値を無視する。特殊キーだけ、`SPECIAL` なら従来通り特殊キー色、`NORMAL` なら通常キー色として描画する。 - -## 保存形式 - -Room entity `KeyDefinition` に列を追加する。 - -```kotlin -val specialKeyColorStyle: String = SpecialKeyColorStyle.SPECIAL.dbValue -``` - -DB は現在 version 38 なので、実装時は 39 に上げ、次の migration を追加する。 - -```sql -ALTER TABLE key_definitions -ADD COLUMN specialKeyColorStyle TEXT NOT NULL DEFAULT 'SPECIAL' -``` - -対応箇所: - -- `AppDatabase`: `version = 39`、`MIGRATION_38_39` 追加。 -- `AppModule`: `MIGRATION_38_39` を import し `addMigrations()` に追加。 -- `KeyboardRepository.toDbEntities()`: `KeyData.specialKeyColorStyle.dbValue` を保存。 -- `KeyboardRepository.toUiLayout()`: `SpecialKeyColorStyle.fromDbValue(dbKey.specialKeyColorStyle)` で復元。 -- `KeyboardLayoutExportDtos.KeyDefinitionDto`: nullable な `specialKeyColorStyle: String?` を追加。 -- `KeyboardLayoutJsonExporter`: export に含める。 -- `KeyboardBackupPipeline`: import 時に `null`/未知値は `SPECIAL` へ正規化する。 - -import/export の schemaVersion は必須で上げなくてもよい。新フィールドは optional で、欠損時は `SPECIAL` にする。上げる場合でも、v2 以前の JSON は `SPECIAL` として読み込む。 - -## 描画設計 - -`FlickKeyboardView` に、動作判定ではなく見た目色を解決する小さな helper を置く。 - -```kotlin -private data class KeyVisualPalette( - val usesSpecialSurface: Boolean, - val baseColor: Int, - val textColor: Int, - val highlightColor: Int -) - -private fun resolveKeyVisualPalette(keyData: KeyData): KeyVisualPalette -``` - -解決ルール: - -- `!keyData.isSpecialKey`: 通常キー色。 -- `keyData.isSpecialKey && keyData.specialKeyColorStyle == SPECIAL`: 特殊キー色。 -- `keyData.isSpecialKey && keyData.specialKeyColorStyle == NORMAL`: 通常キー色。 - -ただし、余白、行列サイズ、特殊キー用のアクション dispatch は `isSpecialKey` のまま判定する。色変更によってタッチ領域や入力挙動が変わらないようにする。 - -置き換え対象: - -- `createKeyView()` の通常/side 背景選択。 -- custom theme の `targetBaseColor` / `targetTextColor` / `targetHighlightColor`。 -- `AppCompatImageButton` の背景色と tint。 -- `getGuideTextColor()`。 -- custom theme 時の popup color 設定。 - -default theme では次のように扱う。 - -- `SPECIAL`: 既存の `ten_keys_side_bg_material(_light)`。 -- `NORMAL`: 既存の `ten_keys_center_bg_material(_light)`。 - -custom theme では次のように扱う。 - -- `SPECIAL`: `customSpecialKeyColor` / `customSpecialKeyTextColor`。 -- `NORMAL`: `customKeyColor` / `customKeyTextColor`。 - -## アイコン改善設計 - -濁点/小文字系アクションの既定アイコンは、正規化済み vector drawable を新規追加して使う。 - -既存の `kana_small` / `kana_small_custom` / `english_small` を直接置き換える案もあるが、これらは tenkey レイアウトや設定画面、既存ユーザーの内蔵 drawable 選択でも参照されている。今回の要望はカスタムキーボードの action default icon なので、既存 resource を破壊的に変更するより、新しい正規化 resource を追加して action mapping を差し替える方が安全。 - -追加 resource 案: - -```text -core/src/main/res/drawable/custom_key_kana_case_24.xml -core/src/main/res/drawable/custom_key_english_case_24.xml -``` - -正規化の基準: - -- `android:width` / `android:height` は `24dp`。 -- `viewportWidth` / `viewportHeight` は `24`。 -- 実際の path bbox が横方向で 18〜20 viewport units 程度を使う。 -- 上下左右の透明余白は optical balance 用の最小限だけにする。 -- `fillColor` は `@color/keyboard_icon_color`。tint と `FlickKeyboardView.applyImageButtonTint()` の既存挙動を保つ。 -- `ToggleDakuten` 系は、濁点/半濁点/小文字を表す glyph を現行と同等の意味で再構成する。 -- `ToggleCase` は `a/A` の意味を維持しつつ、縦方向にも読みやすい太さと高さにする。 - -差し替え対象: - -- `KeyActionMapper.getDisplayActions()` - - `ToggleDakuten`, `ToggleDakutenOnly`, `ToggleHandakutenOnly` は `custom_key_kana_case_24`。 - - `ToggleCase` は `custom_key_english_case_24`。 -- `KeyActionMapper.iconResIdForAction()` - - 同上。 -- `KeyboardRepository.drawableResIdForAction()` - - `ToggleDakuten` と `ToggleCase` を新 resource にする。 -- `KeyIconBuiltInDrawable.allowList` - - 新 resource 名を追加する。 - - 既存の `kana_small` / `kana_small_custom` / `english_small` は互換性のため削除しない。 - -`FlickKeyboardView.getSpecialIconTargetSizePx()` は、濁点/小文字系専用倍率を持たせない。入力モード切替系の既存特別扱いは別件として維持するが、今回の修正では触らない。 - -既存 layout / DB に古い `drawableResId` が入っていた場合は、そのまま表示できる。編集画面で該当 action を保存し直すと新 resource に更新される。既存データの一括 migration は不要。 - -## 編集 UI - -`app/src/main/res/layout/fragment_key_editor.xml` の特殊キー設定に、色スタイル選択を追加する。 - -配置は `specialCategoryChipGroup` の下、または `keyIconOverrideGroup` の上にする。 - -```text -特殊キーの見た目色 -[特殊キー色] [通常キー色] -``` - -UI 要素案: - -- `TextView`: `text_special_key_color_style_title` -- `ChipGroup`: `special_key_color_style_chip_group` -- `Chip`: `chip_special_key_color_style_special` -- `Chip`: `chip_special_key_color_style_normal` - -表示条件: - -- `chip_special` 選択時のみ表示。 -- `chip_normal` 選択時は非表示。 - -初期値: - -- 既存キー: `key.specialKeyColorStyle`。 -- 新規/旧データ: `SPECIAL`。 - -保存: - -- 特殊キーとして保存する場合だけ選択値を `updatedKey.specialKeyColorStyle` に入れる。 -- 通常キーとして保存する場合は `SPECIAL` に戻すか、値を保持しても描画では無視する。実装はデータの意味を明確にするため `SPECIAL` へ戻す。 - -## 動的キーとの関係 - -`FlickKeyboardView.updateDynamicKey()` は `info.keyData.copy(label/action/drawableResId)` で動的状態を反映している。新フィールドは copy 元から保持されるため、Sumire 特殊キーや dynamicStates の表示更新でも色スタイルは失われない。 - -`KeyActionMapper` の action mapping のうち、保存文字列と `KeyAction` の意味は変更しない。変更するのは濁点/小文字系アクションの既定 icon resource だけにする。 - -## テスト方針 - -単体テスト: - -- `SpecialKeyColorStyle.fromDbValue(null/unknown)` が `SPECIAL` を返す。 -- `KeyData` の default が `SPECIAL` である。 -- `KeyActionMapper.iconResIdForAction()` が濁点/小文字系アクションで正規化済み drawable を返す。 -- `KeyboardRepository.drawableResIdForAction()` が濁点/小文字系アクションで正規化済み drawable を返す。 -- `KeyIconBuiltInDrawable` が新旧 resource 名の両方を許可する。 -- 通常キーは `specialKeyColorStyle = NORMAL/SPECIAL` に関係なく通常キー色として解決される。 -- 特殊キー `SPECIAL` は特殊キー色、`NORMAL` は通常キー色として解決される。 - -Repository/import-export テスト: - -- 旧 DB/旧 JSON 相当で `specialKeyColorStyle` 欠損時、復元結果は `SPECIAL`。 -- `NORMAL` を保存した特殊キーが DB round-trip で `NORMAL` のまま戻る。 -- JSON export/import で `specialKeyColorStyle` が保持される。 - -手動確認: - -- カスタムキーボードで `ToggleDakuten` / `ToggleCase` の既定アイコンが従来より大きく見える。 -- action picker と特殊キーの実表示で、濁点/小文字系アイコンの透明余白が他の action icon と同程度になっている。 -- 既存 layout の古い drawable icon も引き続き表示される。 -- 特殊キー色/通常キー色を切り替えても、タップ、フリック、長押し、MoveToCustomKeyboard、ユーザー画像アイコンが従来通り動作する。 -- custom theme、default light、default dark で色の切り替わりが破綻しない。 - -## 実装順序 - -1. `SpecialKeyColorStyle` と `KeyData` のフィールドを追加する。 -2. Room entity、migration、Repository、import/export を対応する。 -3. 正規化済みの濁点/小文字系 drawable を追加する。 -4. `KeyActionMapper`、`KeyboardRepository.drawableResIdForAction()`、`KeyIconBuiltInDrawable.allowList` を新 drawable へ対応させる。 -5. `FlickKeyboardView` に visual palette helper を追加し、直接 `isSpecialKey` で色を選ぶ箇所を置き換える。 -6. `KeyEditorFragment` と `fragment_key_editor.xml` に色スタイル選択 UI を追加する。 -7. 単体テストを追加し、既存テストを通す。 - -## 非対象 - -- `KeyAction` の保存文字列や入力処理の変更。 -- 既存の `kana_small` / `kana_small_custom` / `english_small` resource の削除や破壊的変更。 -- `FlickKeyboardView` への濁点/小文字専用サイズ倍率追加。 -- 通常キーごとの任意色指定。 -- 全体テーマ設定の追加。 diff --git a/docs/dev-branch-operation.md b/docs/dev-branch-operation.md new file mode 100644 index 000000000..f614e7fcc --- /dev/null +++ b/docs/dev-branch-operation.md @@ -0,0 +1,415 @@ +# dev ブランチ運用ガイド + +この文書では、このリポジトリを `dev` ブランチ中心で開発し、`main` ブランチからリリースするための手順を説明します。 + +## 1. ブランチの役割 + +~~~text +feature/*、fix/*、docs/*、chore/* + ↓ Pull Request + dev + ↓ リリース用Pull Request + main + ↓ vX.Y.Z タグ + GitHub Release +~~~ + +### `main` + +- リリース可能な状態を保つブランチです。 +- 通常の開発では直接コミットしません。 +- リリース時のタグは `main` のコミットに付けます。 +- `v*` タグをpushすると、GitHub ActionsがリリースAPKをビルドしてGitHub Releaseを作成します。 + +### `dev` + +- 次のリリースに含める変更を統合するブランチです。 +- 通常の機能追加・バグ修正のPull Requestの送信先です。 +- `dev` に直接コミットせず、作業ブランチからPull Requestを作成します。 + +### 作業ブランチ + +作業内容に応じて、次のような名前を使用します。 + +~~~text +feature/image-button +fix/popup-input-behavior +docs/dev-branch-operation +chore/update-gradle +hotfix/release-crash +~~~ + +## 2. 最初の準備 + +すでにリポジトリをclone済みの場合は、リモートの情報を更新します。 + +~~~bash +git fetch origin +git switch dev +git pull --ff-only origin dev +~~~ + +`dev` がローカルにない場合は、次のように作成します。 + +~~~bash +git fetch origin +git switch --track origin/dev +~~~ + +現在のブランチと作業ツリーを確認します。 + +~~~bash +git status --short --branch +~~~ + +作業開始時には、次の状態になっていることを確認してください。 + +- 現在のブランチが `dev` +- `origin/dev` と同期している +- 未コミットの変更がない + +## 3. 通常の開発手順 + +### 3.1 `dev` を最新化する + +作業ブランチを作る前に、必ず `dev` を最新化します。 + +~~~bash +git switch dev +git pull --ff-only origin dev +~~~ + +### 3.2 作業ブランチを作る + +~~~bash +git switch -c feature/変更内容 +~~~ + +例: + +~~~bash +git switch -c feature/add-image-button +~~~ + +作業ブランチは、原則として1つの目的に限定します。機能追加、無関係なリファクタリング、フォーマット変更などを1つのPull Requestに混在させないでください。 + +### 3.3 変更を確認する + +変更前後に、次のコマンドで対象ファイルと差分を確認します。 + +~~~bash +git status +git diff +git diff --check +~~~ + +不要な生成物、署名ファイル、`local.properties`、モデルファイルをコミットしないでください。 + +### 3.4 ローカルで確認する + +現在のPull Request用CIは、処理時間を短くするため `check` のみを実行します。コードのビルドやテストは自動では行われないため、変更内容に応じてローカルで確認します。 + +通常のアプリ変更では、軽量版Debugビルドを実行します。 + +~~~bash +./gradlew :app:assembleLiteStandardDebug +~~~ + +Kotlin・Javaのロジックを変更した場合は、単体テストも実行します。 + +~~~bash +./gradlew :app:testLiteStandardDebugUnitTest +~~~ + +AndroidリソースやManifest、Lint対象の変更を行った場合は、Lintを実行します。 + +~~~bash +./gradlew :app:lintLiteStandardDebug +~~~ + +複数の確認をまとめて実行する場合は、次のコマンドを使用します。 + +~~~bash +./gradlew \ + :app:assembleLiteStandardDebug \ + :app:testLiteStandardDebugUnitTest \ + :app:lintLiteStandardDebug \ + --stacktrace \ + --no-daemon \ + --max-workers=2 +~~~ + +Full版、Zenz、入力処理、IMEサービスに関係する変更では、必要に応じて追加確認を行います。 + +~~~bash +./gradlew :app:assembleFullStandardDebug +~~~ + +Full版のビルドではZenzモデルの準備とネイティブコードのビルドが行われます。初回はモデルの取得に時間がかかる場合があります。 + +入力処理の回帰確認は、GitHub Actionsの `Fast Input Regression` ワークフローを手動実行します。全ケースの実行には時間がかかるため、入力処理に関係する変更やリリース前に実行してください。 + +### 3.5 コミットする + +変更内容が確認できたら、意図したファイルだけをstageします。 + +~~~bash +git status +git add path/to/changed-file +git diff --cached +git commit -m "Add image button" +~~~ + +コミットメッセージは、変更内容を短い英語の命令形で記述します。例: + +~~~text +Add image button to markdown toolbar +Fix popup input behavior +Update F-Droid release metadata +~~~ + +### 3.6 リモートへpushする + +~~~bash +git push -u origin feature/add-image-button +~~~ + +2回目以降のpushは、通常次のコマンドで行えます。 + +~~~bash +git push +~~~ + +共有済みの作業ブランチに対してforce pushを行わないでください。 + +## 4. Pull Requestの作成 + +GitHubでPull Requestを作成するときは、次の設定にします。 + +- base repository: `KazumaProject/JapaneseKeyboard` +- base branch: `dev` +- compare branch: 自分の作業ブランチ + +通常の開発でbaseを `main` にしてはいけません。`main` へのPull Requestはリリースまたは緊急修正のときだけ作成します。 + +### Pull Request本文に書く内容 + +最低限、次の項目を記載します。 + +~~~markdown +## 変更内容 +- 何を変更したか +- なぜ変更したか + +## 確認内容 +- [ ] ./gradlew :app:assembleLiteStandardDebug +- [ ] ./gradlew :app:testLiteStandardDebugUnitTest +- [ ] ./gradlew :app:lintLiteStandardDebug + +## 補足 +- 画面変更がある場合はスクリーンショット +- 関連Issueや既知の制限 +~~~ + +### Pull Request作成後 + +1. CIの `up-to-date / check` が完了することを確認します。 +2. レビュー指摘に対応します。 +3. 修正を同じ作業ブランチへpushします。 +4. CIが再実行されたことを確認します。 +5. 承認後、`dev` へマージします。 + +CIは現在、リポジトリのcheckoutと `echo "ok"` のみを実行します。CIが成功しても、ビルドやテストが成功したことを意味しません。必要なローカル確認結果をPull Request本文に記載してください。 + +### マージ方法 + +通常の機能追加や修正は、Pull Request画面の **Squash and merge** を使用します。これにより、`dev` の履歴を機能単位で整理できます。 + +マージ後は、不要になったリモート作業ブランチを削除します。ローカルでは次のように更新します。 + +~~~bash +git switch dev +git pull --ff-only origin dev +git branch -d feature/add-image-button +~~~ + +## 5. リリース手順 + +### 5.1 リリース前の確認 + +`dev` に次のリリースに含める変更がすべて入っていることを確認します。 + +- 必要なPull Requestがすべてマージ済み +- 既知の重大な不具合がない +- 必要なローカルビルド・テストが完了している +- 入力処理に関係する場合はFast Input Regressionを実行済み +- `app/build.gradle` の `versionCode` と `versionName` が更新済み + +バージョン更新も通常の変更として、まず `dev` へPull Requestを作成します。 + +### 5.2 `dev` から `main` へPull Requestを作成する + +GitHubで次のPull Requestを作成します。 + +~~~text +base: main +compare: dev +~~~ + +タイトル例: + +~~~text +Release v1.7.105 +~~~ + +このPull Requestで、リリース対象の変更内容とバージョン番号を最終確認します。 + +### 5.3 `main` にマージする + +レビュー後、`dev` から `main` へマージします。マージ後、ローカルの `main` を最新化します。 + +~~~bash +git fetch origin +git switch main +git pull --ff-only origin main +~~~ + +### 5.4 リリースタグを作成する + +タグは必ず最新の `origin/main` を確認した後に作成します。 + +~~~bash +git tag -a v1.7.105 -m "Release v1.7.105" +git push origin v1.7.105 +~~~ + +`v` で始まるタグをpushすると、`.github/workflows/android.yml` が次のRelease APKをビルドしてGitHub Releaseへアップロードします。 + +- Full Standard Release +- Lite Standard Release +- Lite F-Droid Release + +GitHub Actionsの実行結果とGitHub Releaseの添付ファイルを確認してください。署名情報やHugging Faceの設定が必要なため、リリースCIに必要なRepository Secretsが登録されていることも確認します。 + +### 5.5 リリース後に `dev` を同期する + +リリース後、`main` にだけ存在する変更があれば、`main` から `dev` へのPull Requestを作成して同期します。 + +~~~text +base: dev +compare: main +~~~ + +通常、リリース対象の変更はすでに `dev` に存在するため、追加の同期が不要な場合もあります。 + +## 6. 緊急修正の手順 + +リリース済みの `main` に対して緊急修正が必要な場合は、`main` から作業ブランチを作成します。 + +~~~bash +git fetch origin +git switch main +git pull --ff-only origin main +git switch -c hotfix/fix-crash +~~~ + +修正後は次の順番で進めます。 + +1. `hotfix/*` から `main` へPull Requestを作成する。 +2. レビュー後、`main` へマージする。 +3. バージョン番号を更新し、必要ならタグを作成する。 +4. 同じ修正を `dev` にも反映する。 +5. `main` から `dev` へのPull Request、または同じ修正内容のPull Requestを作成する。 + +`main` だけを修正して、`dev` へ戻し忘れないようにしてください。 + +## 7. 現在のGitHub Actions + +### `up-to-date.yml` + +- `dev` または `main` を対象とするPull Requestで実行されます。 +- `dev` または `main` へのpushでも実行されます。 +- 現在は `check` ジョブでcheckoutと `echo "ok"` のみを行います。 +- Androidのビルド、単体テスト、Lintは自動実行しません。 + +### `android.yml` + +- `v*` タグのpushで実行されます。 +- リリース用の署名付きAPKをビルドします。 +- GitHub Releaseを作成し、APKをアップロードします。 + +### `fast-input-regression.yml` + +- GitHub Actions画面から手動実行します。 +- Pixel 6 Pro、API 35のエミュレータで入力回帰テストを実行します。 +- 実行範囲、回数、スクリーンショット取得の有無を入力で指定できます。 +- 入力処理の変更時とリリース前に実行します。 + +## 8. ブランチ保護の推奨設定 + +GitHubのBranch protectionまたはRulesetで、`dev` と `main` に次の設定を行います。 + +### `dev` + +- Pull Request必須 +- `up-to-date / check` の成功を必須化 +- force push禁止 +- ブランチ削除禁止 + +### `main` + +- Pull Request必須 +- `up-to-date / check` の成功を必須化 +- レビュー1名以上を必須化 +- force push禁止 +- ブランチ削除禁止 + +設定後も、通常の開発者が直接 `dev` や `main` にpushできないことを確認してください。 + +## 9. やってはいけないこと + +- 通常の機能追加を `main` へ直接pushする +- 作業ブランチから `main` へ直接Pull Requestを送る +- `dev` や `main` をforce pushする +- 署名ファイル、`local.properties`、秘密情報をコミットする +- ローカル確認をせずに「CIが通ったので問題ない」と判断する +- リリースタグを `dev` の未確認コミットに付ける +- `main` だけに緊急修正を入れて、`dev` へ反映しない + +## 10. よく使うコマンド一覧 + +~~~bash +# devを最新化 +git fetch origin +git switch dev +git pull --ff-only origin dev + +# 作業ブランチを作成 +git switch -c feature/example + +# 差分を確認 +git status +git diff +git diff --check + +# 軽量版をビルド +./gradlew :app:assembleLiteStandardDebug + +# 単体テスト +./gradlew :app:testLiteStandardDebugUnitTest + +# Lint +./gradlew :app:lintLiteStandardDebug + +# 作業ブランチをpush +git push -u origin feature/example + +# mainを最新化 +git switch main +git pull --ff-only origin main + +# リリースタグを作成・push +git tag -a v1.7.105 -m "Release v1.7.105" +git push origin v1.7.105 +~~~ + diff --git a/docs/fast-input-fix-validation-2026-07-23.md b/docs/fast-input-fix-validation-2026-07-23.md deleted file mode 100644 index 284333707..000000000 --- a/docs/fast-input-fix-validation-2026-07-23.md +++ /dev/null @@ -1,152 +0,0 @@ -# 高速入力の誤キー修正・最終検証報告 - -実施日: 2026-07-23 - -## 結論 - -候補欄を初めて表示するレイアウト更新中に、IMEルートへ届く -`MotionEvent`のローカル座標原点と、現在のView階層の画面上原点が一時的に -異なる世代になることが根本原因だった。 - -この状態で通常のViewGroupヒットテストを行うと、画面上ではキーボード内の -タッチでも別の子Viewに当たる、またはキーボードへ届かない場合がある。 -Sumireではさらに、親ローカル座標で子キーを選んでいたため、 -「や」への2回目のDOWNが一段上の「な」と解釈され、 -報告された`やや`→`やな`が発生していた。 - -タッチを一貫した画面座標系で扱う構造へ変更した。固定待機、デバウンス、 -候補欄の高さ統一は行っていない。TenKey/QWERTYの無制限な最寄りキー判定も -変更していない。 - -最終ビルドをPixel 6実機とPixel 6 Proエミュレーターで検証し、 -指定された全144設定、各設定2フェーズ、合計288フェーズ/端末で -別キー入力、欠落、入力注入失敗は0件だった。 - -## 根本原因の実測 - -物理Pixel 6で、候補を初めて表示した直後の2回目のDOWNに次の不整合を記録した。 - -- イベントから逆算したIMEルート画面原点Y: 1477px -- 現在のView階層が返したIMEルート画面原点Y: 1250px -- 差: 227px - -タッチの`rawX/rawY`とキーボード自体の画面位置は正しかった。 -OSがDOWNを落としたのではなく、IME内で異なるレイアウト世代のローカル座標を -混在させたことが問題だった。 - -候補タブ非表示でも、未入力時候補高から列別候補高へ変われば同じ再レイアウトが -発生する。候補タブ表示時にはタブとマージン更新も加わるため、タブ有無は -列数とは独立した試験軸として扱った。 - -## 実装した修正 - -### IMEルートのタッチ配送 - -`InkTouchDispatchFrameLayout`で、イベントが表すルート原点と現在のルート原点を -画面座標で比較する。 - -- 同じレイアウト世代なら従来の通常配送を使う -- 世代が異なり、画面上のタッチが現在または直前のキーボード領域内なら、 - 一貫した画面座標変換でアクティブなキーボードへ直接配送する -- DOWNで決めた配送先と画面上原点をUP/CANCELまで固定する -- 通常配送がキーボード領域内のDOWNを処理できなかった場合も同じ経路で救済する - -候補欄や統合ツールバーが一時的に重なっても、キーボード用DOWNを先に消費する -ことがなくなる。 - -### Sumireのジェスチャー配送 - -`FlickKeyboardView`の子キーヒットテストを画面座標基準へ変更した。 - -- DOWN時に`rawX/rawY`と各キーの画面上矩形を比較する -- ポインタごとに対象キーとキーの画面上原点を保存する -- MOVE/UP/CANCELも保存した同じ原点でキー内座標へ変換する - -これにより、フリック途中にIMEのローカル原点が変わっても対象キーと移動量は -変わらない。 - -### 候補欄レイアウト - -候補表示時の更新経路を整理した。 - -- 未入力時と候補表示時のキーボード幾何計算を共通経路へ統合 -- 値が同じLayoutParamsを候補更新ごとに再設定しない -- 候補RecyclerViewのAdapterを更新ごとにdetach/attachしない -- 列数、向き、表示方式が実際に変わった場合だけLayoutManagerと装飾を更新 - -ユーザーが保存した未入力時、1列、2列、3列、縦横別の候補高はすべて -計算入力として保持している。 - -## 試験条件 - -設定数は次の直積で144通り。 - -- キーボード: TenKey / Sumire / QWERTY -- 向き: 縦 / 横 -- 候補列数: 1 / 2 / 3 -- 候補タブ: 非表示 / 表示 -- ショートカットツールバー: 非表示 / 表示 -- ショートカットツールバーの候補欄統合: しない / する - -各設定で次の2フェーズを別々に判定した。 - -1. cold: 候補未表示から最初の候補表示へ遷移しながら16文字を連続入力 -2. warm: 候補表示済みの安定状態で16文字を連続入力 - -タップは18ms押下、12ms間隔でスケジュールした。期待文字列と実際のEditor文字列、 -入力注入成否、キーボードルートと対象キーの画面矩形を毎フェーズ記録した。 - -## 試験結果 - -| 環境 | 設定 | 入力フェーズ | 誤キー/欠落 | 注入失敗 | 設定失敗 | -|---|---:|---:|---:|---:|---:| -| Pixel 6実機、Android 16/API 36 | 144/144 | 288/288 | 0 | 0 | 0 | -| Pixel 6 Proエミュレーター、Android API 35 | 144/144 | 288/288 | 0 | 0 | 0 | - -実機では、報告条件に対応する縦・候補3列・候補タブ表示・ツールバー表示・統合あり -の条件を先に10巡した。cold/warm合計20フェーズすべて成功した。 - -エミュレーターの全設定試験は、77番終了後にテスト制御のIME再選択が一度失敗した -ため1〜77番と78〜144番に分割した。停止までに実行済みの入力は全件成功していた。 -IME選択を再試行するようテストハーネスを直し、両範囲を改めて完走させた。 -製品の入力失敗ではない。 - -## 外観と保存済み高さ - -実機、エミュレーターとも全144設定について、未入力時と候補表示時の2枚、 -合計288枚/端末を保存した。 - -自動測定では、候補表示前後のキーボードルート上端と対象キー上端の差は -全設定で0pxだった。縦横の1/2/3列、タブ最大表示、独立ツールバー、 -候補欄統合を含む代表画像を目視し、次を確認した。 - -- 候補行数と候補タブの表示が設定どおり -- 独立/統合ツールバーが設定どおり -- キーの位置、寸法、文字、背景、角丸に意図しない変更なし -- 重なり、欠け、端のクリップなし - -試験で実際に使われた候補高は次のとおりで、統一されていない。 - -| 環境・向き | 未入力 | 1列 | 2列 | 3列 | -|---|---:|---:|---:|---:| -| 実機・縦 | 110dp | 110dp | 120dp | 160dp | -| 実機・横 | 52dp | 60dp | 119dp | 120dp | -| エミュレーター・縦 | 110dp | 110dp | 120dp | 160dp | -| エミュレーター・横 | 110dp | 60dp | 90dp | 120dp | - -未入力時と入力時の高さが大きく異なる条件を維持したまま合格している。 - -## ビルドと回帰確認 - -- `:app:assembleFullStandardDebug`: 成功 -- `:app:assembleFullStandardDebugAndroidTest`: 成功 -- `:app:testFullStandardDebugUnitTest`: 成功 -- 一時的な診断ログ: 削除済み -- UI XML、キー寸法、色、フォント: 変更なし -- TenKey/QWERTYの最寄りキー判定: 変更なし - -## 判定 - -報告されたSumire・候補3列での`やや`→`やな`は、実機とエミュレーターの -該当設定および全設定マトリクスで再発しなかった。候補タブ表示時の高速入力も -正常だった。今回の再現条件と試験範囲では、誤キー問題は改善済みと判定する。 diff --git a/docs/flick-editor-preview-design.md b/docs/flick-editor-preview-design.md deleted file mode 100644 index 919e0efef..000000000 --- a/docs/flick-editor-preview-design.md +++ /dev/null @@ -1,699 +0,0 @@ -# フリック選択文字の入力欄プレビュー設計 - -## 1. 結論 - -TenKey と Sumire の文字フリックに、指を離す前から選択中の文字を入力欄へ仮表示する機能を追加する。 - -- 設定は TenKey 用、Sumire 用に分けず、フリック系入力共通の 1 個の Switch とする。 -- 新設定画面と従来設定画面には、同じ SharedPreferences キーを持つ項目をそれぞれ配置する。 -- デフォルトは OFF とし、OFF の場合は既存の入力イベントと IME 処理を一切変更しない。 -- QWERTY は対象外とする。Sumire の英語モードが QWERTY 表示へ切り替わっている場合も対象外である。 -- 押下中の文字は `commitText()` せず、一時的な `setComposingText()` として表示する。 -- `_inputString`、候補検索、学習、IME 内部の編集履歴は指を離すまで更新しない。 - -入力欄へ一度 `commitText()` してから削除・置換する方式は採用しない。この方式は入力先アプリの Undo、TextWatcher、選択範囲、カーソル位置を壊す可能性があるためである。 - -`setComposingText()` による仮表示自体は入力先アプリから観測できる。検索欄の即時検索や独自 TextWatcher が MOVE 中の文字を受け取ることは、この機能の性質上避けられない。また、エディタによっては composing 更新も独自の Undo 単位として扱う可能性がある。このため本機能は opt-in、デフォルト OFF とし、代表的な EditText、Compose TextField、WebView で互換性を確認する。 - -## 2. 対象範囲 - -### 2.1 対象 - -初回実装では次を対象とする。 - -- TenKey - - 通常表示 - - フローティング表示 - - 日本語・英語・数字モードのうち、最終的に composing 入力を行う通常文字キー -- Sumire - - 通常表示 - - フローティング表示 - - 日本語・英語・数字モードのうち、最終的に composing 入力を行う通常文字キー - - Sumire の標準入力スタイルすべて - -| Sumire の設定値 | `KeyType` | コントローラー | -|---|---|---| -| `default` | `PETAL_FLICK` | `CrossFlickInputController` の TEXT モード | -| `circle` | `STANDARD_FLICK` | `StandardFlickInputController` | -| `second-flick` | `TWO_STEP_FLICK` | `TfbiInputController` | -| `third-flick` | `HIERARCHICAL_FLICK` | `TfbiHierarchicalFlickController` | -| `sumire` | `CIRCULAR_FLICK` | `CustomAngleFlickController` | - -通常文字キーとは、最終出力が `KeyAction.Text` または `FlickAction.Input` として表現されるキーを指す。出力は 1 文字に限定せず、Sumire の複数文字出力も対象にする。 - -### 2.2 初回実装の対象外 - -- QWERTY、QWERTY ローマ字、QWERTY 数字 -- Tablet 五十音キーボード -- ユーザー作成カスタムキーボードの実行モード - - `FlickKeyboardView` の共通イベント基盤は再利用できるように作るが、IME 側の適用判定では `TenKeyQWERTYMode.Custom` を除外する。 -- Sumire の特殊キー - - 削除、空白、変換、Enter、カーソル、濁点切替など - - 特殊キーの方向に文字列オーバーライドが設定されていても初回実装では対象外とする。 -- 変換中、文節選択中、選択モード、操作中のカーソル移動モード -- composing 内のカーソル編集で `stringInTail` が空でない場合も対象とする。DOWN 時点の tail を固定し、プレビュー表示へ連結する一方、入力 mutation からは除外する。 -- パスワード、電話番号、日時など、composing 表示を安全に保証できない入力欄 -- Direct Commit が選択されている入力動作 -- ダブルタップバインディングがあるキー - -対象外条件では設定を自動的に OFF へ書き戻さず、その 1 ジェスチャーだけ従来方式へフォールバックする。 - -## 3. ユーザー向け設定 - -### 3.1 Preference - -共通キーは次とする。 - -```text -flick_editor_preview_preference -``` - -値は Boolean、デフォルトは `false` とする。既存ユーザーの移行処理は不要である。 - -推奨表示文言は次のとおり。 - -```text -タイトル: -入力欄でフリック文字をプレビュー - -OFF の概要: -従来方式:指を離したときに文字を入力します - -ON の概要: -TenKey・Sumireで、選択中のタップ/フリック文字を入力欄に仮表示します -``` - -英語リソースも同時に追加する。 - -```text -Title: -Preview flick characters in the text field - -Off summary: -Enter the character when you release the key - -On summary: -Preview the selected tap or flick text while holding a TenKey or Sumire key -``` - -### 3.2 配置 - -新設定画面では `pref_operation_feedback.xml` に `category_flick_input_title`(表示名「フリック入力」)を設け、次を同じカテゴリにまとめる。 - -- `flick_input_only_preference` -- `flick_editor_preview_preference` -- `flick_sensitivity_preference` -- `flick_threshold_shape_preference` - -新しい Switch は `flick_input_only_preference` の直後へ配置する。現在の先頭 `category_function_title` にあるフリック以外の設定は、既存の `category_function_title` に残す。 - -従来設定画面でも `pref_common_legacy.xml` に同じ `category_flick_input_title` を設け、上記 4 項目を現在の `category_function_title` から移す。Preference キー、デフォルト値、保存先は変更せず、表示上の分類だけを揃える。 - -両方が同じキーを使うため、画面間の同期コードは追加しない。既存の `PreferenceManager.getDefaultSharedPreferences()` が唯一の保存先になる。 - -設定検索には XML から自動登録される。よく使う設定の候補にも表示できるよう、`frequentCandidatePreferenceKeys` に同じキーを追加する。 - -### 3.3 設定読み込み - -次を追加する。 - -- `AppPreference.FLICK_EDITOR_PREVIEW_KEY` -- `AppPreference.flick_editor_preview_preference: Boolean` -- `ImePreferencesSnapshot.flickEditorPreviewPreference: Boolean` -- `IMEService.flickEditorPreviewPreference: Boolean` -- `runtimeInputPreferenceKeys` へのキー登録 -- `syncRuntimeInputPreferences()` での再読み込み - -設定値はジェスチャー開始時にスナップショットする。押下中に設定が変更されても、そのジェスチャーは DOWN 時の設定で完了し、次の DOWN から新しい値を使う。 - -## 4. 現行処理と変更方針 - -### 4.1 TenKey - -現行の `TenKey.onTouch()` は次の流れになっている。 - -```text -ACTION_DOWN - -> FlickListener.onFlick(Down, key, null) - -ACTION_MOVE - -> setTapInActionMove() / setFlickInActionMove() - -> キーとポップアップの表示だけ変更 - -ACTION_UP - -> FlickListener.onFlick(Tap/Flick..., key, char) - -> IMEService.handleTapAndFlick() - -> _inputString 更新 -``` - -既存の `FlickListener.onFlick()` は確定通知の意味を維持し、MOVE からは呼ばない。別のプレビューイベントを追加する。 - -### 4.2 Sumire - -`FlickKeyboardView` は複数のコントローラーを `OnKeyboardActionListener` へ集約しているが、MOVE 中に共通して得られるのは一部の方向変更だけである。方向だけでは、2 段・階層・長押し出力・円形マップ切替後の最終文字を判断できない。 - -各コントローラーが、方向ではなく次を通知するようにする。 - -> 今この状態で指を離した場合に、通常の確定処理へ渡される文字列と `isFlick` - -特殊アクション、無効方向、まだ文字が決まっていない階層は `text = null` とする。 - -## 5. 共通プレビューイベント - -TenKey と Sumire が共有できる型を `core` モジュールへ追加する。 - -```kotlin -data class FlickTextSelection( - val text: String?, - val isFlick: Boolean, -) - -sealed interface FlickTextPreviewEvent { - val gestureId: Long - - data class Started( - override val gestureId: Long, - val selection: FlickTextSelection, - ) : FlickTextPreviewEvent - - data class Changed( - override val gestureId: Long, - val selection: FlickTextSelection, - ) : FlickTextPreviewEvent - - data class CommitPending( - override val gestureId: Long, - val selection: FlickTextSelection, - ) : FlickTextPreviewEvent - - data class Finished( - override val gestureId: Long, - ) : FlickTextPreviewEvent - - data class Canceled( - override val gestureId: Long, - ) : FlickTextPreviewEvent -} - -fun interface FlickTextPreviewListener { - fun onFlickTextPreview(event: FlickTextPreviewEvent) -} -``` - -`text = null` は「現時点では仮表示できる文字出力がない」ことを表す。空文字はイベント送出前に `null` へ正規化する。 - -### 5.1 イベント順序 - -通常確定は必ず次の順序にする。 - -```text -Started -Changed (0回以上) -CommitPending -既存の確定コールバック -Finished -``` - -キャンセルは次の順序にする。 - -```text -Started -Changed (0回以上) -Canceled -``` - -`CommitPending` と `Finished` の間で既存の確定コールバックがプレビュー済み入力計画を消費しなかった場合、`Finished` で元の composing 表示を復元する。これにより、特殊アクションへの変化、ダブルタップによる遅延、イベント不一致があっても仮文字が残らない。 - -### 5.2 Emitter - -イベント順序、gesture ID、同一選択の重複抑止を共通化するため、Android View に依存しない `FlickTextPreviewEmitter` を `core` に追加する。 - -```kotlin -class FlickTextPreviewEmitter { - var listener: FlickTextPreviewListener? = null - - fun begin(selection: FlickTextSelection) - fun update(selection: FlickTextSelection) - fun commit(selection: FlickTextSelection, dispatch: () -> Unit) - fun cancel() -} -``` - -`commit()` は `try/finally` で `CommitPending -> dispatch -> Finished` を保証する。`update()` は `text` と `isFlick` が直前と同じなら通知しない。 - -## 6. キーボード側の実装 - -### 6.1 TenKey - -`TenKey` に次を追加する。 - -```kotlin -fun setOnFlickTextPreviewListener(listener: FlickTextPreviewListener?) -``` - -通常文字キーについて、現在の `InputMode` と `KeyTapFlickInfo` から選択文字を解決する共通関数を作る。 - -```kotlin -private fun resolveTextSelection( - key: Key, - gestureType: GestureType, -): FlickTextSelection -``` - -呼び出し位置は次のとおり。 - -- `ACTION_DOWN`: TAP 出力で `emitter.begin()` -- `ACTION_MOVE`: `getGestureType()` 後、選択が変わったとき `emitter.update()` -- `ACTION_UP`: 最終選択を渡して `emitter.commit { 既存 FlickListener.onFlick() }` -- `ACTION_CANCEL`: `emitter.cancel()` -- `cancelActiveTouch()`、View 非表示、detach: `emitter.cancel()` -- 2 本目の指、カーソルモード、長押し特殊動作へ移行する場合: 確定処理の前に `emitter.cancel()` - -通常表示とフローティング表示は同じ `TenKey` クラスを使うため、IME 側でそれぞれ同じプレビューリスナーを登録する。 - -### 6.2 Sumire 共通 - -`FlickKeyboardView` に次を追加する。 - -```kotlin -fun setOnFlickTextPreviewListener(listener: FlickTextPreviewListener?) -``` - -各コントローラーの emitter は `FlickKeyboardView` の現在の listener へイベントを転送する。`FlickKeyboardView` は通常文字キーかつダブルタップバインディングなしの場合だけ emitter を接続する。 - -View の `onVisibilityChanged()`、`onDetachedFromWindow()`、`cancelTrackedTouchState()` はすべて controller の `cancel()` を経由し、必ず `Canceled` を送る。 - -### 6.3 Sumire 各入力スタイル - -#### PETAL_FLICK - -`CrossFlickInputController` の TEXT モードで、`resolveText(currentDirection, preferLongPress)` の結果を通知する。 - -- DOWN: TAP の通常文字 -- MOVE: 方向変更後の通常文字 -- 長押し成立: 長押し文字が存在すればその文字へ Changed -- UP: 実際に commit する通常/長押し文字 -- CANCEL: Canceled - -ACTION モードの特殊キーはプレビュー対象にしない。 - -#### STANDARD_FLICK - -`StandardFlickInputController.characterMap` から現在方向の文字列を通知する。 - -- DOWN は TAP -- MOVE は `calculateDirection()` の結果 -- UP は finalDirection - -現在の確定処理が TAP も `isFlick = true` として渡している場合、プレビューイベントも既存確定処理と同じ値を使う。ここで入力方式の意味を変更しない。 - -#### CIRCULAR_FLICK - -`CustomAngleFlickController` の現在マップと方向から `FlickAction` を解決する。 - -- `FlickAction.Input` は `char` を通知 -- `FlickAction.Action(KeyAction.Text)` は `text` を通知 -- それ以外の Action、マップ切替方向、無効方向は `text = null` -- マップが切り替わった場合は、新しいマップで同じ方向を再解決して Changed を送る - -#### TWO_STEP_FLICK - -`TfbiInputController` の `firstFlickDirection` と `currentSecondFlickDirection` を provider に渡し、現時点の最終文字を通知する。 - -- DOWN は `TAP/TAP` -- 1 段目確定時にも Changed -- 2 段目のハイライト変更時にも Changed -- 中央へ戻って状態がリセットされた場合は `TAP/TAP` へ Changed -- 長押し出力が成立した場合は longPressProvider の文字へ Changed -- UP は、既存処理が最終的に選んだ first/second の組を使用 - -#### HIERARCHICAL_FLICK - -`TfbiHierarchicalFlickController` は現在の `currentMap` と `currentHighlight` から「UP した場合の selectedNode」を解決する純粋関数を持つ。 - -```kotlin -private fun resolveCurrentOutput(): String? -``` - -- 終端 `Input`: その文字 -- `SubMenu`: 現行 UP 処理と同様に `nextMap[TAP]` が Input ならその文字 -- 無効方向、出力を持たない submenu: `null` -- 階層 push/pop、ハイライト変更、内部モード変更のたびに再解決して Changed -- UP でも同じ resolver を使用し、プレビューと確定の分岐を重複させない - -## 7. IME 側の状態設計 - -### 7.1 FlickInputPreviewCoordinator - -`IMEService` へロジックを直接追加し続けず、`ime_service/flick_preview/FlickInputPreviewCoordinator.kt` を追加する。 - -```kotlin -data class ActiveFlickPreview( - val gestureId: Long, - val editorSessionId: Long, - val baseInput: String, - val baseCanonicalRevision: Long, - val settingEnabledAtDown: Boolean, - val lastSelection: FlickTextSelection?, - val lastMutation: FlickTextMutation?, -) -``` - -リスナー登録時に `FlickPreviewSource.TENKEY` または `FlickPreviewSource.SUMIRE` を含む `FlickPreviewContext` を組み立てる。イベント型自体はキーボード固有 enum を持たず、適用対象の判定は IME 境界で行う。 - -主な API は次とする。 - -```kotlin -fun onEvent(event: FlickTextPreviewEvent, context: FlickPreviewContext) -fun consumePendingCommit(text: String, isFlick: Boolean): FlickTextMutation? -fun cancel(reason: FlickPreviewCancelReason, restore: Boolean) -``` - -`editorSessionId` は `onStartInput()` ごとに増加させる。別の InputConnection から届いた古いイベントは無視する。 - -### 7.2 ComposingTextArbiter - -プレビュー中には、旧候補計算や live conversion の非同期結果が `setComposingText()` を呼ぶ可能性がある。その書き込みでプレビューが上書きされないよう、canonical 表示と preview 表示を調停する。 - -```kotlin -sealed interface CanonicalComposingState { - data class Text(val value: CharSequence, val cursorPosition: Int) : CanonicalComposingState - data object Finished : CanonicalComposingState -} - -class ComposingTextArbiter { - fun setCanonical(text: CharSequence?, cursorPosition: Int): Boolean - fun showPreview(text: CharSequence, cursorPosition: Int): Boolean - fun suspendPreviewAndRestore(): Boolean - fun releasePreview(leaveDisplayedText: Boolean) - fun cancelPreviewAndRestore(): Boolean - fun finishCanonical(): Boolean -} -``` - -規則は次のとおり。 - -- プレビューなし: canonical 書き込みをそのまま InputConnection へ転送する。 -- プレビュー中: canonical 書き込みは最新値を保存するが、エディタには転送しない。 -- preview 書き込み: InputConnection へ直接転送するが canonical 状態は変更しない。 -- `text = null` への移動: preview 表示だけを停止して canonical を復元するが、ジェスチャーセッションは保持する。文字方向へ戻ったら同じ `baseInput` から preview を再開する。 -- CANCEL: 最新 canonical 状態を復元する。DOWN 時点の古い状態へ固定的に戻さない。 -- 正常 UP: preview を表示したまま所有権だけ解放し、直後の `_inputString` 更新による canonical 描画へ接続する。 -- `commitText()`、`finishComposingText()`、削除、selection 変更など文字プレビュー以外の編集操作が入る場合: 先に preview をキャンセルしてから操作する。 - -`IMEService.setComposingText()` は canonical 経路として arbiter を通す。現在 `updateComposingText()` にある `currentInputConnection?.setComposingText()` の直接呼び出しも canonical 経路へ統一する。preview だけが arbiter の専用 bypass API から実 InputConnection を呼ぶ。 - -`CharSequence` は後から Span が変化しないよう `SpannableString` へコピーして保存する。 - -`showPreview()` が `false` を返した場合、そのジェスチャーでは preview を中止して従来方式へフォールバックする。InputConnection の戻り値が `true` でも独自実装が表示を無視する場合までは自動判定できないため、設定は best effort とする。 - -### 7.3 候補と非同期処理 - -MOVE 中は次を行わない。 - -- `_inputString` 更新 -- `requestCandidateRefresh()` -- Zenz リクエスト -- 学習状態更新 -- `finishComposingText()` -- `commitText()` -- `deleteSurroundingText()` - -DOWN 前から動作していた候補処理は継続してよい。結果の canonical composing 書き込みだけ arbiter が保留する。CANCEL なら最新結果を復元でき、UP なら正式入力後の新しい candidate token によって古い結果が無効化される。 - -## 8. 入力結果の予測と正式反映 - -### 8.1 純粋な mutation resolver - -プレビュー文字を単純に `baseInput + selection.text` としてはならない。TenKey と Sumire の 1 文字タップにはトグル入力があるため、例えば既に「あ」がある状態で「あ」キーをタップすると、結果は「ああ」ではなく「い」になる場合がある。 - -`sendCharTap()`、`sendCharFlick()`、`handleOnKeyForSumire()` に散らばる通常 composing 入力の判断を、純粋な resolver と適用処理へ分ける。 - -```kotlin -sealed interface FlickTextMutation { - data class ReplaceComposingInput( - val resultInput: String, - val effects: FlickInputEffects, - ) : FlickTextMutation - - data class Unsupported(val reason: FlickPreviewUnsupportedReason) : FlickTextMutation -} -``` - -resolver の入力には次を含める。 - -- DOWN 時点の `baseInput` -- 選択文字列 -- `isFlick` -- `isFlickOnlyMode` -- `isContinuousTapInputEnabled` -- `lastFlickConvertedNextHiragana` -- 現在の InputType と `ResolvedInputBehavior` -- TenKey / Sumire のモード -- 変換、選択、cursor、tail の各状態 - -1 文字出力は既存の `getNextInputChar()` と同じ規則を使い、複数文字出力は既存 Sumire 処理と同様に末尾へ追加する。 - -### 8.2 プレビューと確定で同じ計画を使う - -`Started` / `Changed` では resolver の結果だけを preview composing として表示する。 - -`CommitPending` では最後の mutation を pending として保持する。直後の既存文字確定コールバックが `text` と `isFlick` の一致する pending mutation を取得し、`_inputString` と既存フラグを一度だけ更新する。 - -これにより次を保証する。 - -- プレビューが「い」なのに UP 後「ああ」にならない。 -- MOVE 回数だけ文字が増えない。 -- プレビュー中にトグル入力フラグが変わらない。 -- 候補検索は最終文字に対して 1 回だけ開始される。 - -pending mutation が一致しない場合は preview を復元して、既存の確定処理へフォールバックする。 - -## 9. 詳細な状態遷移 - -### 9.1 通常例 - -入力前が「か」、押したキーが「あ」の場合: - -```text -Idle - -> DOWN/TAP - editor preview = "かあ" - _inputString = "か" - -> MOVE/UP direction - editor preview = "かう" - _inputString = "か" - -> MOVE/back to TAP - editor preview = "かあ" - _inputString = "か" - -> UP - pending mutation = "かあ" - existing final callback consumes mutation - _inputString = "かあ" - canonical composing/candidate refresh runs - -> Idle -``` - -### 9.2 無効方向・特殊方向 - -```text -text selection - -> MOVE to text = null - canonical composing を復元して preview を一時停止 - -> MOVE back to text selection - 同じ baseInput から preview を再表示 -``` - -`text = null` へ移動した時点で gesture 自体は終了させない。後から文字方向へ戻れるためである。 - -### 9.3 CANCEL - -次では canonical composing を復元し、IME 内部入力を変更しない。 - -- `ACTION_CANCEL` -- キーボード View 非表示 -- View detach / rebuild -- フローティングキーボード dismiss -- 入力モード・キーボード種類変更 -- `onFinishInputView()` -- 新しい `onStartInput()` -- editor session ID 不一致 -- 2 本目の指による既存特殊操作 -- プレビュー中の commit/delete/selection 操作 - -InputConnection が終了済みの場合は `restore = false` で状態だけ破棄する。 - -## 10. 適用判定 - -`FlickPreviewEligibilityPolicy` を pure class として追加し、次をすべて満たす場合だけ有効にする。 - -```text -setting enabled at DOWN -AND source is TenKey or Sumire -AND QWERTY surface is not active -AND ordinary text-producing key -AND no double-tap binding -AND input behavior is composing -AND not password/numeric/phone/date/time direct field -AND !isHenkan -AND !selectMode -AND !cursorMoveMode -AND stringInTail is captured as an immutable preview tail -AND currentInputConnection exists -AND editor session matches -``` - -`stringInTail` がある場合、背景 Span はカーソル前の入力プレビューまで、下線 Span は tail を含む composing 全体まで適用する。CANCEL では元の composing 全体を復元し、UP の mutation は tail を含めず、選択文字だけをカーソル前へ確定する。 - -Sumire については `TenKeyQWERTYMode.Sumire` と、Sumire の数字レイアウトとして表示している `TenKeyQWERTYMode.Number` の composing 文字キーを許可する。`TenKeyQWERTYMode.Custom` は初回実装では拒否する。 - -## 11. 性能要件 - -- MotionEvent ごとに `setComposingText()` しない。 -- 選択文字列または `isFlick` が変化した場合だけ更新する。 -- MOVE 処理で coroutine を起動しない。 -- MOVE 処理で候補検索・辞書検索・DB 書き込みを行わない。 -- `SpannableString` は選択変化時に 1 個だけ生成する。 -- 1 ジェスチャー中に保持する preview state は 1 件だけとする。 - -通常の 4 方向 TenKey では、指が同じ方向にいる限り MOVE が何回来ても editor 呼び出しは増えない。 - -## 12. 変更予定ファイル - -### core - -- `core/src/main/java/com/kazumaproject/core/domain/flick/FlickTextPreviewEvent.kt` -- `core/src/main/java/com/kazumaproject/core/domain/flick/FlickTextPreviewEmitter.kt` -- emitter の unit test - -### tenkey - -- `tenkey/src/main/java/com/kazumaproject/tenkey/TenKey.kt` -- TenKey selection resolver の unit test -- DOWN/MOVE/UP/CANCEL の instrumented test - -### custom_keyboard - -- `FlickKeyboardView.kt` -- `CrossFlickInputController.kt` -- `StandardFlickInputController.kt` -- `CustomAngleFlickController.kt` -- `TfbiInputController.kt` -- `TfbiHierarchicalFlickController.kt` -- 各 controller の selection/commit/cancel test - -### app - -- `AppPreference.kt` -- `ImePreferencesSnapshot.kt` -- `IMEService.kt` -- `ime_service/flick_preview/FlickInputPreviewCoordinator.kt` -- `ime_service/flick_preview/ComposingTextArbiter.kt` -- `ime_service/flick_preview/FlickTextMutationResolver.kt` -- `ime_service/flick_preview/FlickPreviewEligibilityPolicy.kt` -- `pref_operation_feedback.xml` -- `pref_common_legacy.xml` -- `values/strings.xml` -- `values-ja/strings.xml` -- coordinator、arbiter、resolver、eligibility の unit test -- TenKey/Sumire の instrumented test - -## 13. テスト計画 - -### 13.1 Unit test - -#### Emitter - -- Started は 1 回だけ -- 同じ selection の Changed は抑止 -- CommitPending -> callback -> Finished の順序 -- callback が例外でも Finished -- CANCEL 後の Changed/Finished は無視 - -#### Coordinator - -- stale gesture ID を無視 -- TenKey と Sumire の source context を識別 -- setting OFF、QWERTY、Custom のイベントを editor へ転送しない -- pending mutation が既存確定コールバックで消費されなかった場合に復元 - -#### Mutation resolver - -- 空入力 + 「あ」TAP -- 「あ」+ 同じキー TAP が「い」へ置換 -- Flick は末尾追加 -- フリックのみ設定 ON の TAP は末尾追加 -- Sumire 複数文字出力 -- unsupported InputType -- henkan/select/操作中の cursor 状態を拒否し、安定した tail 状態を許可 -- tail 付き DOWN/MOVE/CANCEL/UP の表示、Span 範囲、mutation 分離 -- resolver 結果と既存確定結果の一致 - -#### Arbiter - -- preview 中の canonical write は editor へ出さず最新値だけ保存 -- CANCEL は最新 canonical を復元 -- 正常 release は preview 表示を消さない -- finish/commit/delete 前に preview を復元 -- editor session 変更後の古い CANCEL を無視 - -### 13.2 Controller test - -各 Sumire スタイルについて次を確認する。 - -- DOWN の TAP selection -- 方向変更の Changed -- 同方向 MOVE の重複抑止 -- 中央へ戻る -- 無効方向の `text = null` -- UP の final selection と既存確定出力が同じ -- ACTION_CANCEL / cancel() の Canceled -- 2 段、階層、map switch、長押し出力 - -### 13.3 Instrumented test - -Fake editor またはテスト Activity の EditText で InputConnection 呼び出しを記録する。 - -- OFF: DOWN/MOVE では editor 書き込みなし、UP は従来どおり -- ON: DOWN/MOVE は `setComposingText()` のみ -- ON: MOVE 中に `commitText()`、delete、candidate request がない -- UP: `_inputString` 更新と候補 request は 1 回だけ -- CANCEL: 入力前の composing と `_inputString` に戻る -- rapid input: 前の canonical 更新が次の preview を上書きしない -- normal/floating の両方 -- TenKey と Sumire 5 スタイル -- live conversion ON/OFF -- flick-only ON/OFF -- 候補欄 1/2/3 列、候補タブ表示 ON/OFF -- 縦/横 -- password、number、direct mode の fallback -- View hide、keyboard switch、IME restart - -Undo 対応エディタでは、1 ジェスチャーが中間文字ごとの複数 Undo 履歴にならないことも手動確認する。 - -## 14. 受け入れ条件 - -1. 設定 OFF では既存の文字列、候補、振動、音、ポップアップ、長押し、フリック判定が変わらない。 -2. 設定 ON では、対象キーの DOWN 直後に TAP 文字が入力欄へ表示される。 -3. 方向を変えると、前の仮文字が増殖せず現在選択文字へ置き換わる。 -4. 中央へ戻す、2 段目を変える、階層を戻る操作でも最終出力と表示が一致する。 -5. UP 後の `_inputString` と preview 最終表示が一致する。 -6. MOVE 中には候補生成、学習、commit、delete が走らない。 -7. CANCEL 後に仮文字が残らない。 -8. TenKey の通常・フローティングと、Sumire の通常・フローティングおよび 5 スタイルで同じ原則が成立する。 -9. QWERTY と対象外入力欄は従来動作を維持する。 -10. 新設定画面と従来設定画面の Switch が同じ値を表示する。 - -## 15. 実装順序 - -1. Preference、snapshot、runtime 同期を追加する。ただしまだ挙動には接続しない。 -2. core の event/emitter と unit test を追加する。 -3. TenKey へ event を追加し、OFF 時回帰 test を通す。 -4. Sumire 5 controller へ selection event を追加し、controller test を通す。 -5. mutation resolver と eligibility policy を追加し、既存確定処理を共通 mutation へ寄せる。 -6. composing arbiter を導入し、すべての canonical `setComposingText()` を経由させる。 -7. preview coordinator を TenKey/Sumire の通常・フローティングへ接続する。 -8. CANCEL/lifecycle/direct-operation の統合 test を追加する。 -9. rapid input、live conversion、候補欄レイアウトを含む instrumented matrix を実行する。 - -この順序により、キーボードイベント、入力結果計算、Editor 境界を分けて検証でき、設定 OFF の既存動作を各段階で確認できる。 diff --git a/docs/flick-editor-preview-implementation-report-2026-08-02.md b/docs/flick-editor-preview-implementation-report-2026-08-02.md deleted file mode 100644 index 1ad647766..000000000 --- a/docs/flick-editor-preview-implementation-report-2026-08-02.md +++ /dev/null @@ -1,93 +0,0 @@ -# フリック文字プレビュー 実装・実機試験報告 - -試験日: 2026-08-02(背景Span・stringInTail対応の追試: 2026-08-03) - -## 結論 - -`onDown` と `onMove` で、選択中の TenKey/Sumire の文字を入力先アプリの composing text に仮表示する設定を実装した。設定は新規設定画面と従来設定画面の両方に追加し、デフォルトは OFF とした。OFF の場合は従来どおり `onUp` または二本目の指を検知した時点で入力へ反映する。 - -Pixel 6 実機では、候補変換の開始を `onUp` まで遅延したまま、Down 表示、Move 置換、既存PreEditと同じ背景・文字色・下線Span、Up 確定、Cancel 復元が正常に動作した。候補表示時間に有意と判断できる悪化は観測されなかった。プレビューONでは Down/Move/Cancel ごとに入力先アプリとの composing-text 通信が発生するため、合成ジェスチャ試験の同期処理時間と一時割り当て量は増えたが、GC後PSSの増加は観測されなかった。 - -## 実装内容 - -- 共通設定キー: `flick_editor_preview_preference` -- デフォルト: `false` -- 設定画面: 新規設定の「フリック入力」カテゴリ、従来設定の「フリック入力」カテゴリ -- 対象: TenKey と Sumire のテキスト入力キー -- Sumire確認スタイル: `default`、`circle`、`second-flick`、`third-flick`、`sumire` -- 通常表示とフローティング表示の両方へ同じリスナーを接続 -- `onDown`: タップ位置の文字を仮表示 -- `onMove`: 現在選択中の文字で仮表示を置換 -- Down/Moveとも通常入力のPreEditと同じ `BackgroundColorSpan`、任意の `ForegroundColorSpan`、`UnderlineSpan` を適用し、カスタム入力色設定にも追従 -- カーソル移動後に `stringInTail` がある場合は、カーソル前のプレビュー結果へtailを連結して表示する。背景Spanはカーソル前まで、下線Spanはtailを含む全文まで適用し、確定mutationにはtailを含めない -- `onUp`: プレビューと同一の入力変換を一度だけ確定し、その後に従来どおり候補生成 -- `ACTION_CANCEL`、ビュー非表示、入力セッション切替: 仮表示を破棄して正規の composing text を復元 -- パスワード、数値、直接入力、変換中、範囲選択中、カーソル移動中など、安全にプレビューできない状態では無効 -- プレビュー処理中は `_inputString` とトグル入力状態を変更せず、確定時だけ反映 - -実機試験で、composing text が存在しない状態へ `finishComposingText()` で復元すると、Pixel 6では表示中のプレビューが確定されることを検出した。復元処理は空の composing region を設定する方式に変更し、Cancel時に文字が残らないことを確認した。 - -また、端末に復元されていた既存のキーボード順序JSONに未知の列挙値が含まれると起動時に `null` が混入する問題を検出したため、未知値を除外し、空になった場合は TenKey/QWERTYへフォールバックするようにした。 - -## 実機機能試験 - -端末は USB 接続した Pixel 6 (`oriole`)、Android 16、API 36。`liteStandardDebug` を使用した。 - -以下を自動操作で確認し、最終差分に対する試験は成功した。 - -- 設定ON: TenKeyおよびSumire 5スタイルで Down直後に文字が表示される -- 設定ON: TenKeyおよびSumire 5スタイルのDownで、デフォルト背景色の `BackgroundColorSpan` が全文範囲に `SPAN_COMPOSING` として存在する -- 設定ON: Up前は候補欄が空であり、変換処理が開始されない -- 設定ON: Cancel後に仮表示が消え、元の composing stateへ戻る -- 設定ON: TenKeyおよびSumire `default` で Moveにより文字が変化する -- 設定ON: TenKeyおよびSumire `default` のMove後も、カスタム設定した背景色Spanと下線Spanが変更後の全文範囲に存在する -- 設定ON: Up後の確定結果がMove時のプレビューと一致する -- TenKeyおよびSumireで「あな」入力後に左カーソル移動し、Downで「ああな」、Moveで「カーソル前の変更文字+な」が表示される -- 上記tail状態でCancelすると「あな」へ復元され、Upすると選択文字が「な」の直前へ一度だけ挿入される -- tailを伴うDown/Moveでは、背景Spanがカーソル前まで、下線Spanがtailを含む全文まで設定される -- 設定OFF: TenKeyおよびSumireでDown時は未反映、Up時に従来どおり入力される - -## 変換時間 - -測定区間は `ACTION_UP` 注入開始から最初の候補が表示されるまで。各条件で3回ウォームアップ後、15回測定した。 - -| 条件 | 平均 | P50 | P95 | -|---|---:|---:|---:| -| OFF | 126.130 ms | 102.658 ms | 162.988 ms | -| ON | 127.674 ms | 128.309 ms | 148.719 ms | -| ON − OFF | +1.545 ms (+1.22%) | +25.651 ms (+24.99%) | -14.269 ms (-8.75%) | - -平均はONで1.545 ms長く、P50は長い一方でP95は短かった。サンプル数15かつ端末上のスケジューリングを含み、分位点の方向も一致しないため、明確な悪化とは判定しない。Up前に候補生成が開始されないことは各試行で継続して検証している。 - -## プレビュー処理時間とメモリ - -Down→Move→Cancelを1ジェスチャとして30回ウォームアップ後、500回連続実行した。時間は3個の同期タッチイベント注入、IME処理、入力先アプリへの更新を含む。割り当て量は ART の `art.gc.bytes-allocated`、PSSは `Debug.getPss()` で取得した。 - -| 条件 | 1ジェスチャ平均 | 一時割り当て/ジェスチャ | PSS開始 | PSS直後 | GC後PSS | -|---|---:|---:|---:|---:|---:| -| OFF | 54.350 ms | 71,223 bytes | 239,456 KB | 274,512 KB | 252,187 KB | -| ON | 79.657 ms | 103,803 bytes | 239,436 KB | 234,479 KB | 242,947 KB | -| ON − OFF | +25.306 ms (+46.56%) | +32,580 bytes (+45.74%) | -20 KB | -40,033 KB | -9,240 KB | - -時間と割り当て量の増加は、ON時だけ Down、Move、Cancelで装飾済みSpannableを生成して `InputConnection.setComposingText()` を実行し、入力先アプリとの同期処理および一時オブジェクト生成が追加されるためである。この51–75 msは1回のコールバックそのものではなく、UiAutomationによる3イベントの同期注入を含む試験全体の壁時計時間であり、そのままユーザーが感じる遅延とは解釈できない。 - -stringInTail対応後の一時割り当て増加は1ジェスチャあたり32,580 bytesだった。一方、最終試験のGC後PSSはONの方が9,240 KB低かった。測定順序はOFF→ONで固定され、JIT、ヒープ拡張、GCタイミングの影響を受けるため、ONでメモリが減るとは判断しない。結論は「装飾を含むプレビュー操作ごとの一時割り当ては約33 KB増えるが、保持メモリの増加は今回の試験では検出されなかった」である。 - -## 検証コマンド - -```text -./gradlew :core:testDebugUnitTest :app:testLiteStandardDebugUnitTest \ - :app:assembleLiteStandardDebug :app:assembleLiteStandardDebugAndroidTest - -./gradlew :app:connectedLiteStandardDebugAndroidTest \ - -Pandroid.testInstrumentationRunnerArguments.class=com.kazumaproject.markdownhelperkeyboard.FastInputMatrixInstrumentedTest#flickEditorPreviewFunctionalAndPerformanceOnPhysicalDevice -``` - -単体テスト、APK/instrumentation APKの組み立て、Pixel 6のinstrumentation testはいずれも成功した。 - -## 制約 - -- 物理端末はPixel 6の1機種、デバッグビルドのみ。 -- 候補表示時間は各条件15サンプルの探索的測定であり、正式なMacrobenchmarkではない。 -- 自動機能試験は通常表示で実施した。フローティング表示は同じプレビューリスナーへ接続し、コンパイル対象として検証したが、今回の実機自動操作マトリクスには含めていない。 -- PSS比較は単一プロセス内かつOFF→ONの固定順であり、小さい保持メモリ差を判定する用途には不十分。 diff --git a/docs/flick-selected-long-press-yoon-design.md b/docs/flick-selected-long-press-yoon-design.md deleted file mode 100644 index 3b4f15445..000000000 --- a/docs/flick-selected-long-press-yoon-design.md +++ /dev/null @@ -1,305 +0,0 @@ -# フリック長押し KeyType 設計 - -## 要望の意味 - -要望者が言っていることは、長押しを「キーを押した直後」だけでなく「フリック方向を選んだ後」にも判定したい、という意味です。 - -たとえば `u` を上フリックして `un` を選び、その状態で指を離さず一定時間待ったら `yun` に切り替えたい、という操作です。 - -別の例では、`c` のあとに `a` を左上フリックした時点で、短く離せば `かく`、そのまま保持すれば `きゃく` を出したい、ということです。つまり、同じ方向操作に対して「短く離す」と「保持して離す」で別の出力を持たせたい、という要望です。 - -## 想定する出力数 - -はい。新しい KeyType では、次の合計18出力を設定・出力できる想定にします。 - -- 通常出力: 9方向 -- 長押し出力: 9方向 - -9方向は `TfbiFlickDirection` の全方向です。 - -```text -TAP -UP -DOWN -LEFT -RIGHT -UP_LEFT -UP_RIGHT -DOWN_LEFT -DOWN_RIGHT -``` - -出力値は「1文字」に限定せず、既存の `FlickAction.Input.char` と同じく `String` として扱います。したがって `か` のような1文字だけでなく、`かく`、`きゃく`、`un`、`yun` のような複数文字も出せます。 - -## 新 KeyType の名前 - -既存の `TWO_STEP_FLICK`、`STICKY_TWO_STEP_FLICK`、`HIERARCHICAL_FLICK` は変更しません。 - -新しい KeyType を追加します。 - -```kotlin -FLICK_LONG_PRESS -``` - -画面表示名は次にします。 - -```text -フリック長押し -``` - -意味は「フリック方向ごとに、通常出力と長押し出力を設定できる入力方式」です。 - -ユーザーには方向数よりも「フリックの長押しを設定できる」ことが重要なので、コード名も画面表示名も `FLICK_LONG_PRESS` / `フリック長押し` に寄せます。 - -## データ構造 - -既存の `FlickDirection` は7方向相当なので、この KeyType では使いません。9方向を表せる `TfbiFlickDirection` を使います。 - -実装では `KeyboardLayout` に新しい保存フィールドは追加しません。 - -既存の `twoStepFlickKeyMaps` と `twoStepLongPressKeyMaps` を、`first == second` の self-pair として再利用します。 - -```kotlin -// 通常出力: 方向 UP の出力を UP -> UP として保存 -twoStepFlickKeyMaps[keyId][TfbiFlickDirection.UP][TfbiFlickDirection.UP] = "un" - -// 長押し出力: 同じ方向の長押し出力も self-pair として保存 -twoStepLongPressKeyMaps[keyId][TfbiFlickDirection.UP][TfbiFlickDirection.UP] = "yun" -``` - -新しい KeyType の分岐でだけ self-pair として解釈するため、既存の `TWO_STEP_FLICK` の意味は変えません。新しい Room table や migration を増やさずに済むのも、この方式の利点です。 - -## 未設定方向の扱い - -未設定方向は「無効方向」として扱います。 - -ここでいう未設定方向とは、通常出力と長押し出力の両方が空の方向です。 - -```text -normal[direction].isNullOrEmpty() -longPress[direction].isNullOrEmpty() -``` - -無効方向の予定挙動: - -- ポップアップやガイドには表示しない。 -- 方向判定の候補に入れない。 -- その方向へフリックしても、その方向の文字は出さない。 -- 指を離した時点で有効な方向が選択されていなければ、入力はキャンセルする。 - -ただし、タップだけは別扱いです。 - -- 指の移動量がフリック閾値未満なら `TAP` と判定する。 -- `TAP` の通常出力があれば短押しで出力する。 -- `TAP` の長押し出力があれば、移動せず保持した時に出力する。 -- `TAP` も未設定なら何も出力しない。 - -つまり、「未設定方向へ大きくフリックしたのに TAP が出る」という挙動にはしません。誤入力を避けるため、未設定方向はキャンセル扱いにします。 - -## 片方だけ設定された方向 - -通常出力だけ設定されている方向: - -- 短く離すと通常出力。 -- 保持しても長押し出力はないため、通常出力のまま。 - -長押し出力だけ設定されている方向: - -- その方向は有効方向として扱う。 -- 短く離すと何も出力しない。 -- 長押し時間を超えてから離すと長押し出力。 - -通常出力と長押し出力が両方ある方向: - -- 短く離すと通常出力。 -- 長押し時間を超えてから離すと長押し出力。 - -## 専用コントローラ - -新規ファイル: - -```text -custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/FlickLongPressInputController.kt -``` - -主な責務: - -1. `ACTION_DOWN` で `TAP` 候補を開始する。 -2. 指の移動に応じて9方向から有効方向だけを対象に現在候補を更新する。 -3. 現在候補に長押し出力がある場合、候補選択時点から長押しタイマーを開始する。 -4. 候補が変わったら古いタイマーをキャンセルし、新候補で張り直す。 -5. 長押しタイマー発火時点でまだ同じ候補なら、長押し出力を確定候補にする。 -6. `ACTION_UP` で通常出力または長押し出力を通知する。 - -内部状態案: - -```kotlin -private var currentDirection: TfbiFlickDirection? = null -private var longPressDirection: TfbiFlickDirection? = null -private var isLongPressActive = false -private var hasExceededFlickThreshold = false -private val longPressRunnable = Runnable { ... } -``` - -方向の有効判定: - -```kotlin -private fun isEnabled(direction: TfbiFlickDirection): Boolean { - return normalMap[direction].orEmpty().isNotEmpty() || - longPressMap[direction].orEmpty().isNotEmpty() -} -``` - -確定出力: - -```kotlin -private fun outputFor(direction: TfbiFlickDirection): String { - val longPress = longPressMap[direction].orEmpty() - if (isLongPressActive && longPress.isNotEmpty()) return longPress - return normalMap[direction].orEmpty() -} -``` - -## FlickKeyboardView の分岐 - -`FlickKeyboardView.attachKeyBehavior` に `KeyType.FLICK_LONG_PRESS` の分岐を追加します。 - -取得するマップ: - -```kotlin -val normalMap = extractFlickLongPressMap( - layout.twoStepFlickKeyMaps[keyData.keyId] - ?: layout.twoStepFlickKeyMaps[keyData.label] -) - -val longPressMap = extractFlickLongPressMap( - layout.twoStepLongPressKeyMaps[keyData.keyId] - ?: layout.twoStepLongPressKeyMaps[keyData.label] -) -``` - -`normalMap` と `longPressMap` の両方が空なら、キーは何も出力しないキーとして扱います。クラッシュや既存 fallback はさせません。 - -## キー編集 UI - -キー編集画面には、既存の入力スタイルとは別に次のチップを追加します。 - -```text -フリック長押し -``` - -既存の `outputModeChipGroup` はそのまま使います。つまり、ユーザーは「通常出力」と「長押し出力」を切り替えながら同じ3x3グリッドを編集します。 - -```text -入力方式: フリック / ドーナツ / 2段フリック / フリック長押し -出力設定: 通常 / 長押し -``` - -`フリック長押し` を選んだ時のグリッドは、タップを含む9方向すべてを表示します。 - -```text -┌────────┬────┬────────┐ -│ 左上 │ 上 │ 右上 │ -├────────┼────┼────────┤ -│ 左 │ Tap│ 右 │ -├────────┼────┼────────┤ -│ 左下 │ 下 │ 右下 │ -└────────┴────┴────────┘ -``` - -編集の流れ: - -1. 入力方式で `フリック長押し` を選ぶ。 -2. `通常` を選んで、3x3グリッドの各方向に通常出力を設定する。 -3. `長押し` を選んで、同じ3x3グリッドの各方向に長押し出力を設定する。 -4. セルをタップすると下の入力欄に現在値を表示し、文字列を編集する。 - -入力欄のラベル: - -- 通常モード: `通常出力` -- 長押しモード: `長押し出力` - -グリッド内の表示: - -- 通常モードでは通常出力の値を表示する。 -- 長押しモードでは長押し出力の値を表示する。 -- 未設定セルは空欄または薄いプレースホルダー表示にする。 - -この方式なら、画面上に18個の入力欄を一度に並べずに済みます。既存の「通常 / 長押し」切り替えに合わせて、同じ9方向グリッドを2枚編集する感覚になります。 - -### FlickGridEditorView の拡張 - -既存の `FlickGridEditorView` にはすでに `TfbiFlickDirection` の3x3配置があります。新しい `GridMode` を追加して使います。 - -```kotlin -enum class GridMode { - PETAL, - TWO_STEP, - SPECIAL_FLICK, - FLICK_LONG_PRESS -} -``` - -新しいセルモード: - -```kotlin -sealed class CellMode { - data class FlickLongPress(val direction: TfbiFlickDirection) : CellMode() -} -``` - -新しい表示データ: - -```kotlin -data class FlickLongPressMappingItem( - val direction: TfbiFlickDirection, - val output: String -) -``` - -`KeyEditorFragment` では、通常用と長押し用の2リストを持ちます。 - -```kotlin -private var currentFlickLongPressNormalItems = - mutableListOf() - -private var currentFlickLongPressHoldItems = - mutableListOf() -``` - -`outputModeChipGroup` が `通常` なら normal list、`長押し` なら hold list を `FlickGridEditorView` に渡します。 - -## import/export と DB - -新しい KeyType は保存・復元対象に追加します。出力マップは既存の `twoStepFlickKeyMaps` / `twoStepLongPressKeyMaps` に self-pair として保存するため、DB schema 変更と Room migration は不要です。 - -確認対象: - -- `KeyboardLayoutJsonExporter` -- `KeyboardBackupPipeline` -- `ImportableKeyboardLayout` -- keyId 付け替え処理 -- キー削除時の関連マップ削除 - -既存の `flickKeyMaps`、`hierarchicalFlickMaps` は変更しません。`twoStepFlickKeyMaps` は保存領域として再利用しますが、`KeyType.FLICK_LONG_PRESS` のときだけ self-pair として平坦化します。 - -## テスト観点 - -新規コントローラの単体テストで確認します。 - -- `TAP` を短く離すと通常 `TAP` 出力になる。 -- `TAP` を長押しして離すと `TAP` 長押し出力になる。 -- `UP` へフリックしてすぐ離すと通常 `UP` 出力になる。 -- `UP` へフリックして保持すると `UP` 長押し出力になる。 -- 長押し出力が未設定の方向は、保持しても通常出力になる。 -- 通常出力が未設定で長押し出力だけある方向は、短く離すと無出力、保持すると長押し出力になる。 -- 通常・長押しの両方が未設定の方向へフリックした場合、入力はキャンセルされる。 -- 有効方向から未設定方向へ移動した場合、長押しタイマーはキャンセルされる。 -- `ACTION_CANCEL` でタイマーがキャンセルされる。 -- 既存 KeyType の挙動が変わらない。 - -## 結論 - -想定としては「9方向の通常出力 + 9方向の長押し出力 = 最大18個の文字列」を設定・出力できる KeyType で間違いありません。 - -設定されていないフリック方向は無効方向として扱い、ポップアップにも方向判定にも含めません。未設定方向へフリックして離した場合は、TAP にフォールバックせずキャンセル扱いにするのが安全です。 diff --git a/docs/ime-service-refactoring-design.md b/docs/ime-service-refactoring-design.md deleted file mode 100644 index c3e991b9b..000000000 --- a/docs/ime-service-refactoring-design.md +++ /dev/null @@ -1,603 +0,0 @@ -# IMEService リファクタリング設計メモ - -## 目的 - -`IMEService.kt` は現在 13,000 行を超えており、以下の責務が 1 クラスに集中している。 - -- IME のライフサイクル制御 -- 入力状態の保持 -- 候補生成と変換制御 -- 通常キーボードとフローティングキーボードの UI 制御 -- 各種設定の読み込み -- クリップボード、音声入力、物理キーボード、Zenz 連携 -- `InputConnection` の委譲 - -この状態では、ある機能を変更したときに別の機能へ副作用が波及しやすい。 -本メモの目的は、現在の機能を壊さずに責務を分割し、変更しやすい構造へ段階的に移行するための設計方針を定めること。 - -## 現状の問題 - -### 1. 状態が分散している - -- `var` -- `AtomicBoolean` -- `MutableStateFlow` -- `MutableSharedFlow` - -が `IMEService` に大量に散在しており、どの状態がどの機能の所有物かが分かりにくい。 - -### 2. UI とドメインロジックが密結合 - -- 候補生成 -- 変換確定 -- カーソル移動 -- Enter / Space / Delete の振る舞い - -の中で、直接 View 更新や Drawable 切り替えが行われている。 - -### 3. 通常 UI とフローティング UI の重複が多い - -以下のような通常版と Floating 版のペアが多数存在する。 - -- `handleTapAndFlick` / `handleTapAndFlickFloating` -- `handleTap` / `handleTapFloating` -- `handleFlick` / `handleFlickFloating` -- `handleLongPress` / `handleLongPressFloating` -- `handleSpaceKeyClick` / `handleSpaceKeyClickFloating` -- `handleJapaneseModeSpaceKey` / `handleJapaneseModeSpaceKeyFloating` -- `handleNonEmptyInputEnterKey` / `handleNonEmptyInputEnterKeyFloating` - -この構造では、仕様変更時に両方へ修正が必要になり、修正漏れが起こりやすい。 - -### 4. 候補生成ロジックが重複している - -候補生成には少なくとも以下の系統がある。 - -- 通常候補 -- Original 候補 -- prediction なし候補 -- 英数かな候補 -- Zenz / Zenzai 補助候補 - -それぞれに、辞書検索、学習辞書、NG ワード除外、ローマ字変換候補の組み立てが似た形で重複している。 - -### 5. 設定読み込みが肥大化している - -`onStartInput()` で `AppPreference` から大量の値を読み出してメンバに展開しているため、 - -- 設定の追加変更に弱い -- どの設定がどこで使われるか追いにくい -- テストしにくい - -という問題がある。 - -## リファクタリング方針 - -### 方針 1. `IMEService` を薄いオーケストレータにする - -`IMEService` は Android フレームワーク境界に専念し、実際の入力処理や候補生成は別コンポーネントへ委譲する。 - -### 方針 2. 状態と処理をセットで分割する - -単にメソッドを別ファイルへ移すのではなく、以下のように「状態の所有者」と「処理の責務」を揃えて分割する。 - -- 候補に関する状態と処理 -- キーボードモードに関する状態と処理 -- エディタ操作に関する状態と処理 -- 表示面に関する状態と処理 - -### 方針 3. 通常 UI とフローティング UI の差分を表現する - -ロジックは共通化し、描画差分だけを抽象化する。 -`Floating` 専用の処理を増やすのではなく、同一ロジックを別 Surface に適用する方向に寄せる。 - -### 方針 4. 一気に分解しない - -最初から大規模な構造変更を行うと、IME のような副作用の多い機能では壊れやすい。 -そのため、小さな PR 単位で段階的に移行する。 - -## 目標アーキテクチャ - -## 全体像 - -```text -IMEService - |- ImeSessionState - |- ImePreferencesSnapshot - |- EditorGateway - |- CandidateService - |- InputActionDispatcher - |- KeyboardModeController - |- KeyboardSurfaceCoordinator - |- HardwareKeyboardCoordinator - |- ExternalFeatureCoordinator -``` - -## コンポーネント設計 - -### `ImeSessionState` - -役割: - -- 入力セッション中の状態を集約する - -保持対象の例: - -- `inputString` -- `stringInTail` -- `isHenkan` -- `hasConvertedKatakana` -- `suggestionClickNum` -- `currentInputType` -- `current keyboard mode` -- `selectMode` -- `cursorMoveMode` -- `bunsetsuPositionList` - -ポイント: - -- `AtomicBoolean` と `var` の乱立をやめる -- UI 状態と入力状態を最低限分離する -- まずは単純なデータ集約から始める - -### `ImePreferencesSnapshot` - -役割: - -- `AppPreference` から現在セッションで使う設定値を読み出し、不変オブジェクトにまとめる - -例: - -```kotlin -data class ImePreferencesSnapshot( - val isLiveConversionEnabled: Boolean, - val nBest: Int, - val qwertyShowCursorButtons: Boolean, - val candidateTabVisibility: Boolean, - val keyboardThemeMode: String, -) -``` - -ポイント: - -- `onStartInput()` で 1 回ロードする -- 以降は `appPreference.xxx` ではなく snapshot を参照する -- テスト時に設定差し替えしやすくなる - -### `EditorGateway` - -役割: - -- `currentInputConnection` へのアクセスを一元化する - -責務: - -- `commitText` -- `setComposingText` -- `finishComposingText` -- `deleteSurroundingText` -- `getTextBeforeCursor` -- `getTextAfterCursor` -- `setSelection` -- `sendKeyEvent` - -ポイント: - -- `IMEService` 自体が `InputConnection` を直接抱えない形へ寄せる -- null 判定や API 差異を gateway に閉じ込める -- 入力先アプリとの境界を明確にする - -### `CandidateService` - -役割: - -- 候補生成の組み立てを担当する - -構成案: - -- `CandidateRequest` -- `CandidateSources` -- `CandidateFilters` -- `CandidateAssembler` - -イメージ: - -```kotlin -data class CandidateRequest( - val input: String, - val mode: CandidateMode, - val qwertyMode: TenKeyQWERTYMode, - val usePrediction: Boolean, - val useBunsetsu: Boolean -) -``` - -責務: - -- ユーザー辞書候補取得 -- テンプレート候補取得 -- 学習候補取得 -- エンジン候補取得 -- ローマ字変換候補生成 -- NG ワード除外 -- 重複除去 -- Zenz 補助候補との連携 - -ポイント: - -- 現在の `getSuggestionList*` 系を 1 つの API へ寄せる -- `Original`, `WithoutPrediction`, `EnglishKana` を mode 差分として扱う - -### `InputActionDispatcher` - -役割: - -- キー入力イベントをドメイン操作へ変換する - -責務: - -- Tap / Flick / LongPress の解釈 -- Enter / Space / Delete の分岐 -- カーソル移動 -- 範囲選択 -- コピペ -- 濁点、小文字、カタカナ切り替え - -ポイント: - -- `handle*` メソッド群の入口を整理する -- 「入力イベントをどう解釈するか」を 1 箇所に寄せる - -### `KeyboardModeController` - -役割: - -- キーボード種別と表示モードの遷移を担当する - -責務: - -- TenKey / QWERTY / Romaji / Sumire / Custom / Number の切り替え -- 現在モードに応じた入力モード変更 -- Sumire 動的キー状態更新 -- カスタムレイアウト切り替え - -ポイント: - -- `showKeyboard()` -- `updateKeyboardLayout()` -- `createNewKeyboardLayoutForSumire()` - -周辺をこの層へ寄せる。 - -### `KeyboardSurfaceCoordinator` - -役割: - -- 通常キーボード UI とフローティング UI の表示差分を吸収する - -インターフェース案: - -```kotlin -interface KeyboardSurface { - fun showSuggestions(items: List) - fun hideSuggestions() - fun updateHenkanUi(input: String) - fun updateEnterKey(inputType: InputTypeForIME) - fun setKeyboardVisible(visible: Boolean) - fun setSymbolKeyboardVisible(visible: Boolean) -} -``` - -実装候補: - -- `MainKeyboardSurface` -- `FloatingKeyboardSurface` - -ポイント: - -- `*Floating` の重複ロジックを減らす -- ロジックは共通で、描画だけ差し替える - -### `HardwareKeyboardCoordinator` - -役割: - -- 物理キーボード接続時の表示モード切り替えを担当する - -責務: - -- `InputManager` による接続状態監視 -- 物理キーボード判定 -- 接続中フラグの管理 -- `requestCursorUpdates()` の開始と停止 -- floating candidate の表示制御 -- dock 表示制御 -- 物理キーボード接続時の insets / window ポリシー反映 - -状態の例: - -- `hasHardwareKeyboardConnected` -- `physicalKeyboardEnable` -- `physicalKeyboardFloatingXPosition` -- `physicalKeyboardFloatingYPosition` -- `initialCursorDetectInFloatingCandidateView` -- `initialCursorXPosition` - -ポイント: - -- 物理キーボード接続は単なるデバイス検知ではなく、IME の表示モード切り替えとして扱う -- 候補表示先が通常候補ビューではなく floating candidate へ切り替わる点を設計上明示する -- `onInputDeviceAdded` / `onInputDeviceChanged` / `onInputDeviceRemoved` と `onUpdateCursorAnchorInfo` を同じ責務で管理する - -### `ExternalFeatureCoordinator` - -役割: - -- 外部機能連携の窓口 - -対象: - -- 音声入力 -- クリップボード履歴 -- Zenz / Zenzai 初期化と実行 - -ポイント: - -- IME 入力の中核ロジックから副作用の強い機能を切り離す -- 初期化失敗時のフォールバックをまとめる - -## データフロー案 - -### 入力から候補表示まで - -```text -KeyEvent / FlickEvent - -> InputActionDispatcher - -> ImeSessionState 更新 - -> CandidateService.requestCandidates() - -> HardwareKeyboardCoordinator が表示先を判定 - -> KeyboardSurfaceCoordinator.showSuggestions() - -> EditorGateway.setComposingText() -``` - -### 物理キーボード接続時 - -```text -InputManager event - -> HardwareKeyboardCoordinator - -> hasHardwareKeyboardConnected / physicalKeyboardEnable 更新 - -> requestCursorUpdates on/off - -> dock 表示切り替え - -> floating candidate 表示切り替え - -> IMEService / Surface へ状態通知 -``` - -### Enter / Space / Delete の処理 - -```text -InputActionDispatcher - -> EnterActionHandler / SpaceActionHandler / DeleteActionHandler - -> EditorGateway - -> ImeSessionState - -> KeyboardSurfaceCoordinator -``` - -## まず分けるべき責務 - -優先度の高い順に以下を切り出す。 - -### 1. `ImePreferencesSnapshot` - -理由: - -- 副作用が比較的小さい -- `onStartInput()` を短くできる -- 後続のクラス切り出しで設定参照が楽になる - -### 2. `CandidateService` - -理由: - -- 重複が多く効果が大きい -- 候補生成ロジックをテストしやすくなる -- 将来的に Zenz 連携を分離しやすい - -### 3. `EditorGateway` - -理由: - -- `currentInputConnection` 直接参照を減らせる -- カーソル操作や commit 系の安全性を高められる - -### 4. `KeyboardSurfaceCoordinator` - -理由: - -- Floating 系重複を減らす土台になる -- UI 差分の閉じ込め先を作れる - -### 5. `HardwareKeyboardCoordinator` - -理由: - -- 物理キーボード分岐が候補表示、dock、cursor update、window 制御にまたがっている -- UI 差分ではなく、表示モード切り替えの責務として独立させた方が見通しがよい -- フローティング候補や `CursorAnchorInfo` 追従を 1 箇所に集約できる - -## 後回しにすべきもの - -以下は依存範囲が広いため、初手で触らない方が安全。 - -- `onKeyDown()` / `onKeyUp()` の全面再設計 -- `InputConnection` 実装の完全置換 -- 音声入力、画像貼り付け、クリップボード履歴の同時整理 -- Sumire / Custom keyboard の仕様変更 - -## 段階的移行プラン - -### Phase 0. 現状固定 - -やること: - -- スモークテスト観点の整理 -- 現状挙動を壊してはいけない操作を列挙 - -優先テスト対象: - -- 日本語入力の通常変換 -- live conversion -- Enter / Space / Delete -- QWERTY とテンキー切り替え -- フローティング時の候補表示 -- クリップボード貼り付け -- 物理キーボード接続時の挙動 - -### Phase 1. `ImePreferencesSnapshot` 導入 - -やること: - -- 設定値群を data class 化 -- `onStartInput()` で snapshot をロード -- 参照先を順次 `preferences.xxx` に置換 - -完了条件: - -- `onStartInput()` の設定代入ブロックが大幅に短くなる - -### Phase 2. `CandidateService` 導入 - -やること: - -- `getSuggestionListOriginal` -- `getSuggestionList` -- `getSuggestionListWithoutPrediction` -- `getSuggestionListEnglishKana` - -を新クラスへ移管する。 - -完了条件: - -- 候補生成の大半が `IMEService` 外にある -- mode 切り替えで候補取得できる - -### Phase 3. `EditorGateway` 導入 - -やること: - -- 入力先アプリへの書き込み API を gateway 化 -- 主要な commit / delete / selection 操作を移す - -完了条件: - -- `currentInputConnection` 直接参照箇所が大幅に減る - -### Phase 4. `KeyboardSurfaceCoordinator` 導入 - -やること: - -- 通常 UI / Floating UI の共通操作を interface 化 -- `updateUIinHenkan*` などの重複関数を統合 - -完了条件: - -- `Floating` サフィックス関数が減り始める - -### Phase 5. `HardwareKeyboardCoordinator` 導入 - -やること: - -- `isDevicePhysicalKeyboard()` -- `checkForPhysicalKeyboard()` -- `onInputDeviceAdded()` / `Changed()` / `Removed()` -- `onUpdateCursorAnchorInfo()` - -周辺の責務を集約する。 - -完了条件: - -- 物理キーボード接続時の表示切り替えと候補追従の責務が `IMEService` 外へ移る -- `physicalKeyboardEnable` の解釈が 1 箇所に集約される - -### Phase 6. `InputActionDispatcher` / `KeyboardModeController` 分離 - -やること: - -- `handle*` 群の責務整理 -- キーボード遷移と入力解釈の分離 - -完了条件: - -- `IMEService` は Android イベント受け取りと委譲に集中する - -## テスト戦略 - -### 1. まず回帰観点を文章で固定する - -IME は UI テストや端末依存が強いため、いきなり完全自動化を目指すより、まず回帰観点を明文化する。 - -### 2. 分離後に単体テストを増やす - -単体テストを書きやすい対象: - -- `ImePreferencesSnapshot` -- `CandidateService` -- `EditorGateway` の判定ロジック -- `HardwareKeyboardCoordinator` -- `KeyboardModeController` - -### 3. 結合テストは代表操作に絞る - -最低限の代表ケース: - -- ひらがな入力から候補表示 -- Space で変換 -- Enter で確定 -- Delete 長押し -- フローティングで候補選択 -- 物理キーボード接続時の候補表示 - -## 実装ルール - -- 1 PR で責務を 1 つだけ分離する -- リネームと仕様変更を同時にしない -- まず移設、その後に整理する -- 通常 UI と Floating UI の統合は interface 導入後に行う -- テスト不能な処理は最低でも回帰チェックリストを付ける - -## 初手の具体案 - -最初の PR では、以下だけを行うのが安全。 - -1. `ImePreferencesSnapshot` を追加する -2. `AppPreference` から snapshot を作る mapper を追加する -3. `onStartInput()` の設定代入を snapshot 利用に置き換える -4. 挙動変更は一切しない - -その次の PR で `CandidateService` を導入し、その後に `HardwareKeyboardCoordinator` を分離する。 - -## 非目標 - -今回のリファクタリングでは、以下は目的に含めない。 - -- 新機能追加 -- キーボード仕様変更 -- 候補順位アルゴリズムの見直し -- Zenz の機能改善 -- UI デザイン刷新 - -## まとめ - -このリファクタリングで重要なのは、`IMEService` を一度に分解し切ることではない。 -先に境界を作り、 - -- 設定 -- 状態 -- 候補生成 -- エディタ操作 -- UI surface -- 物理キーボード表示モード - -を分離できる形に変えることが最優先である。 - -最初の成功条件は「クラスを大量に増やすこと」ではなく、 -`IMEService` を読んだときに変更点の入口が予測できる状態を作ることとする。 diff --git a/docs/ngram-performance-report-2026-07-11.md b/docs/ngram-performance-report-2026-07-11.md deleted file mode 100644 index 24d80e1fc..000000000 --- a/docs/ngram-performance-report-2026-07-11.md +++ /dev/null @@ -1,140 +0,0 @@ -# N-gram 2〜5ノード性能測定レポート - -測定日: 2026-07-11 - -## 結論 - -- 4/5ノード対応そのものは、ルール0件時の保持メモリを増やしていない。 -- current word の索引が分散している通常ケースでは、1000件保持しても `score()` は p50 10〜26nsだった。総ルール数だけでは変換時間はほとんど増えない。 -- current word がワイルドカードで全ルールを毎回走査する最悪ケースでは、1000件で `score()` が p50 2.87〜6.41µs、実変換が p50 78.1〜86.2msまで増えた。件数に対してほぼ線形である。 -- 実機上の1000件の追加保持メモリは約388〜544KiB。Nが大きいほどノード特徴量が増えるため、概ね増加する。 -- 実変換の割り当て量はルール0件で約611KiB/回、ルールありで約639〜643KiB/回だった。件数を1000件へ増やしても、変換1回の割り当て量は増えていない。 -- 全ケースでGC回数は0、候補fingerprintは同一だった。今回の負荷ルールは意図的に一致しないため、性能測定中に変換結果は変化していない。 - -実運用上の主要リスクはN=4/5自体ではなく、`nodes[1].word` がワイルドカード、または同じcurrent wordへ大量のルールが集中することである。 - -## 測定環境 - -### JVMスコアラー - -- ホスト: arm64 -- Java: OpenJDK 17.0.12 / JBR-17 -- variant: LiteStandard Debug unit test -- warm-up: 10,000回 -- 測定: 100,000回、20バッチのp50/p95 -- 件数: 0、10、100、500、1000 -- 分布: current word分散、同一current word集中、current wordワイルドカード集中 -- 保持メモリ: 同条件のスコアラーを8個保持し、GC後差分を1個あたりへ換算 - -### 実変換 - -- 端末: Google Pixel 6 -- OS: Android 16 / API 36 -- ABI: arm64-v8a -- variant: LiteStandard Debug AndroidTest -- 入力: `わたしはきのうともだちとえきまえであいました` -- 共通warm-up: 30回 -- 各条件warm-up: 5回 -- 各条件測定: 20回 -- ルール分布: current wordワイルドカード、先頭ノードで不一致となる最悪走査ケース - -## JVMスコアラー結果 - -### 通常の索引分散ケース、1000件 - -現在語に該当するバケットがないケース。全1000件を走査せず、Map lookupで終了する。 - -| N | 保持メモリ | score p50 | score p95 | 1回の割り当て | -|---:|---:|---:|---:|---:| -| 2 | 352.1KiB | 10ns | 10ns | 0B | -| 3 | 383.4KiB | 14ns | 14ns | 0B | -| 4 | 406.8KiB | 20ns | 20ns | 0B | -| 5 | 438.1KiB | 26ns | 26ns | 0B | - -1000件時でもlookup時間は非常に小さい。一方、ルールが保持するノード数に応じてメモリは増え、N=2からN=5で約86KiB増加した。 - -### ワイルドカード集中ケース - -全ルールが毎回候補バケットへ入り、線形走査されるケース。 - -| N | 件数 | score p50 | score p95 | -|---:|---:|---:|---:| -| 2 | 100 | 0.421µs | 0.521µs | -| 2 | 500 | 2.131µs | 2.265µs | -| 2 | 1000 | 6.411µs | 9.083µs | -| 3 | 100 | 0.198µs | 0.230µs | -| 3 | 500 | 0.837µs | 0.945µs | -| 3 | 1000 | 3.355µs | 4.394µs | -| 4 | 100 | 0.207µs | 0.222µs | -| 4 | 500 | 0.916µs | 2.588µs | -| 4 | 1000 | 2.868µs | 2.947µs | -| 5 | 100 | 0.230µs | 0.266µs | -| 5 | 500 | 1.161µs | 1.223µs | -| 5 | 1000 | 3.037µs | 3.121µs | - -N=2がN=3〜5より遅い箇所は、固定順で測った単一JVM実行におけるJIT最適化時期の影響を含む。Nによる差より、同一バケットの件数による差の方が支配的である。 - -1000件時の保持メモリはN=2で177.1KiB、N=3で208.3KiB、N=4で231.8KiB、N=5で263.0KiBだった。分散ケースより小さいのは、Mapのキー文字列と多数のバケットを保持しないためである。 - -## Pixel 6 実変換結果 - -以下の保持メモリは、空ルール状態との差分。時間の単位はms。 - -| N | 件数 | 保持メモリ | allocated/変換 | p50 | p95 | GC | -|---:|---:|---:|---:|---:|---:|---:| -| 2 | 0 | 0KiB | 611,123B | 16.47 | 18.68 | 0 | -| 2 | 10 | 4KiB | 643,481B | 10.15 | 15.78 | 0 | -| 2 | 100 | 24KiB | 643,481B | 15.81 | 16.07 | 0 | -| 2 | 500 | 200KiB | 643,481B | 47.40 | 49.64 | 0 | -| 2 | 1000 | 388KiB | 643,481B | 86.20 | 89.31 | 0 | -| 3 | 0 | 0KiB | 611,123B | 7.90 | 10.22 | 0 | -| 3 | 10 | 0KiB | 642,457B | 8.70 | 10.40 | 0 | -| 3 | 100 | 44KiB | 642,457B | 15.33 | 17.97 | 0 | -| 3 | 500 | 132KiB | 644,096B | 44.96 | 46.36 | 0 | -| 3 | 1000 | 436KiB | 642,457B | 83.83 | 87.84 | 0 | -| 4 | 0 | 0KiB | 611,123B | 7.65 | 9.42 | 0 | -| 4 | 10 | 4KiB | 642,048B | 8.35 | 9.58 | 0 | -| 4 | 100 | 32KiB | 642,048B | 14.87 | 15.54 | 0 | -| 4 | 500 | 248KiB | 642,048B | 45.03 | 48.16 | 0 | -| 4 | 1000 | 496KiB | 642,048B | 82.08 | 85.28 | 0 | -| 5 | 0 | 0KiB | 611,123B | 7.42 | 9.56 | 0 | -| 5 | 10 | 8KiB | 638,976B | 8.35 | 10.24 | 0 | -| 5 | 100 | 32KiB | 638,976B | 15.11 | 16.19 | 0 | -| 5 | 500 | 160KiB | 638,976B | 42.28 | 43.67 | 0 | -| 5 | 1000 | 544KiB | 638,976B | 78.08 | 81.14 | 0 | - -N=2のbaselineは測定順の先頭であり、N=3〜5のbaselineより高い。10/100件との小差はノイズを含むため、N=2の低件数について性能改善とは解釈しない。500/1000件の悪化は全Nで大きく、測定ノイズでは説明できない。 - -## 評価 - -### メモリ - -影響はある。ルール数とNに概ね比例する。ただし1000件でも今回の実機差分は1MiB未満だった。通常の利用で数十件なら数KiB〜数十KiB程度と見込まれる。 - -### 変換時間 - -影響はルールの分布に強く依存する。 - -- current wordが具体的で分散: 1000件でも単体lookupは10〜26nsで、総件数の影響は小さい。 -- current wordワイルドカード/同一語集中: バケット件数に比例する。100件で実変換p50は約15ms、500件で約42〜47ms、1000件で約78〜86ms。 - -### N=4/5追加の妥当性 - -妥当。N=4/5で指数的な探索増加はなく、同じ1000件ならN=2/3と同程度の変換時間だった。主な追加コストはノード特徴量を保持するメモリである。 - -## 推奨事項 - -1. 4/5ノード機能はこのまま採用可能。 -2. current wordワイルドカードを大量登録する場合はUIで警告する。 -3. 同じcurrent wordまたはワイルドカードのバケットが100件を超えたら診断表示を検討する。 -4. 将来さらに大量登録を許可する場合は、wordだけでなくleftId/rightIdも含む複合索引へ拡張する。 -5. CIの回帰閾値には単一実行値を使わず、同一端末で5回以上測定した中央値を採用する。 - -## 成否 - -- JVM性能プローブ: 成功 -- Pixel 6実変換プローブ: 成功 -- AndroidTest: 1 test、failure 0、error 0 -- 全測定ケースの候補fingerprint: `-660537399`で一致 - -JVMの生データは `app/build/reports/ngram-performance/scorer.tsv`、Android実機ログは `app/build/outputs/androidTest-results/connected/debug/flavors/liteStandard/` に保存されている。 diff --git a/docs/ngram-rule-4-5-node-design.md b/docs/ngram-rule-4-5-node-design.md deleted file mode 100644 index 470f76266..000000000 --- a/docs/ngram-rule-4-5-node-design.md +++ /dev/null @@ -1,325 +0,0 @@ -# N-gram 補正 4/5 ノード対応・性能観測設計 - -## 1. 目的 - -現在の 2 ノード・3 ノード補正を 4 ノード・5 ノードまで拡張する。 -同時に、N とルール数が以下に与える影響を再現可能な形で観測する。 - -- スコアラーが常駐時に保持するメモリ -- スコアラー生成時の割り当て量と生成時間 -- 1 回の `score()` にかかる時間 -- 実際のかな漢字変換にかかる時間、割り当て量、GC 回数 -- 補正による探索量(展開回数・キュー最大サイズ)の変化 - -性能値は端末・ビルド種別・実行時状態に依存するため、最初は合否判定ではなくレポートとして保存する。 - -## 2. 現状 - -### 2.1 実行時 - -- `NgramRule.kt` は `TwoNodeRule` と `ThreeNodeRule` を別々の型で持つ。 -- `NgramRuleScorer` は 2 ノードを `current.word`、3 ノードを `second.word` で索引化する。 -- `FindPath` の後方探索で辺 `prevNode -> currentNode` を展開するときに `score(prevNode, currentNode)` を 1 回呼ぶ。 -- 3 ノード目は `currentNode.next` から取得する。 -- 2 ノードと 3 ノードの両方が一致した場合、および完全一致ルールとワイルドカードルールが一致した場合は補正値を加算する。 - -後方探索時には右側の経路が既に確定している。そのため 4/5 ノード対応でも新しい経路を列挙せず、次の固定されたウィンドウを照合できる。 - -```text -2-node: prev, current -3-node: prev, current, current.next -4-node: prev, current, current.next, current.next.next -5-node: prev, current, current.next, current.next.next, current.next.next.next -``` - -### 2.2 永続化・UI - -- Room は `two_node_rule` と `three_node_rule` の別テーブルで、DB バージョンは 37。 -- DAO、Repository、ViewModel、編集ダイアログ、一覧、JSON バックアップがすべて 2/3 ノード別に分岐している。 -- 現在の JUnit ベンチマークは 2/3 ノードの `ns/op` を `println` するが、端末上の ART、保持メモリ、割り当て量、分位点は測っていない。 - -## 3. 採用方針 - -4/5 ノード専用クラスと専用テーブルを単純追加せず、2〜5 ノードを一つのモデルで表現する。これにより、スコアラー、Repository、UI、バックアップ、テストに同じ分岐を増やさない。 - -上限は今回の要件に合わせて 5 とする。実行時のホットパスでは可変長モデルをそのまま辿らず、ロード時に 2〜5 ノード用の索引へコンパイルする。 - -### 3.1 ドメインモデル - -概念上は次のモデルに統一する。 - -```kotlin -data class NgramRule( - val nodes: List, // size: 2..5 - val adjustment: Int, -) -``` - -生成時に以下を検証する。 - -- `nodes.size` は 2〜5 -- adjustment は -10000〜10000 -- word、leftId、rightId がすべてワイルドカードのノードも現行互換として許可 - -`TwoNodeRule` / `ThreeNodeRule` は移行中だけ変換アダプターとして残し、呼び出し元とテストの移行後に削除する。 - -### 3.2 実行時索引 - -ルールはロード時に次の二段階で索引化する。 - -1. N(2、3、4、5) -2. アンカーノードである `nodes[1].word`(現在の `current` / `second` と同じ位置) - -各 N について、完全な word キーのバケットと word ワイルドカードのバケットを分ける。`score()` は現在語のバケットとワイルドカードバケットだけを走査する。 - -```text -compiledRules[N][currentWord] -> candidate rules -wildcardRules[N] -> candidate rules -``` - -照合時に `List`、`Sequence`、一時 `Array` を生成しない。最大 5 ノードの参照をローカル変数として取得し、必要な N のルールが存在するときだけ `next` を先へ辿る。これにより、4/5 ノード機能を追加しただけで、ルール未登録時の変換割り当て量が増えないようにする。 - -### 3.3 スコア計算の意味 - -- N ノードルールは、辺 `nodes[0] -> nodes[1]` を展開した時点で 1 回だけ評価する。 -- 必要な `next` がない、または途中が EOS の場合、その N のルールは一致しない。 -- BOS/EOS を補正対象外とする現行仕様を維持する。 -- 2/3/4/5 ノードおよび完全一致・ワイルドカードで複数ルールが一致した場合、現行どおり adjustment をすべて加算する。 -- `Int` 加算のオーバーフローを避けるため、内部合計は `Long` か飽和加算を用い、最終的に `Int` 範囲へ収める。ルール数を増やす性能試験で意図しないオーバーフローを起こさないようにする。 - -### 3.4 経路探索との関係 - -`FindPath` の三つの後方探索経路すべてで、スコア計算前に `currentNode.next` 以降が対象の候補経路を指していることを保証する。 - -特に `PathQueueElement` を使う探索では、共有 `Node.next` の書き換えに依存すると別候補経路の suffix を参照する危険がある。実装時に以下のどちらかを選び、テストで固定する。 - -- 推奨: `score(prevNode, currentNode, next1, next2, next3)` のようにキュー要素が持つ経路から suffix を明示的に渡す。一時リストやラムダは作らない。 -- 最小変更: 現行どおり `Node.next` を使うが、スコア直前に正しい suffix を設定し、並行・再利用される経路で上書きされないことを証明する。 - -4/5 ノードでは suffix の誤参照が見えやすくなるため、推奨案を採る。 - -## 4. Room 設計 - -DB バージョンを 37 から 38 に上げ、2〜5 ノード共通の `ngram_rule` テーブルへ移行する。ホットパスでのメモリ効率と DAO の単純さを優先し、最大 5 ノード分の列を持つ単一行形式とする。未使用ノードは空文字と `-1` で保存する。 - -主要列は以下。 - -```text -id INTEGER PRIMARY KEY AUTOINCREMENT -nodeCount INTEGER NOT NULL -- 2..5 -node1Word, node1LeftId, node1RightId -... -node5Word, node5LeftId, node5RightId -adjustment INTEGER NOT NULL -``` - -制約・索引: - -- nodeCount の 2〜5 制約は Entity 生成・Repository・import の各境界で検証する(Room の生成スキーマと migration を一致させるため DB 固有の CHECK には依存しない) -- 全特徴量と nodeCount の UNIQUE index で重複登録を防ぐ -- 一覧取得用に `(nodeCount, id)` index を作る -- 実行時検索は DB ではなくメモリ上のコンパイル済み索引を使う - -`MIGRATION_37_38` は次の順で実施する。 - -1. `ngram_rule` を作成する。 -2. `two_node_rule` を nodeCount=2 として `INSERT OR IGNORE` する。 -3. `three_node_rule` を nodeCount=3 として `INSERT OR IGNORE` する。 -4. 件数検証後に旧 2 テーブルを削除する。 -5. `AppModule` に migration を登録する。 - -Room migration test で、ワイルドカード、ID、adjustment、重複制約が保存されることを確認する。 - -## 5. Repository・更新通知 - -- DAO は `Flow>` を一つ公開する。 -- Repository は Entity と `NgramRule` の相互変換、正規化、2〜5 の検証を担当する。 -- `replaceAll` は Room の `@Transaction` にし、削除と挿入の途中状態を observer に見せない。 -- `NgramRuleScorerManager` は全ルールを一度コンパイルし、完成した不変スコアラーを `AtomicReference` で差し替える。 -- UI 保存後の明示的 `refreshNow()` と Flow 更新による二重再構築は整理し、原則 Flow を単一の更新元にする。保存直後の反映保証が必要なら Repository の transaction 完了後に一度だけ同期更新する。 - -## 6. UI と JSON 互換性 - -### 6.1 UI - -編集処理を `showRuleDialog(nodeCount, existing)` に統一する。編集画面には最大 5 個のノード入力欄を用意し、選択した N を超える欄は非表示にする。 - -- 追加時に 2 / 3 / 4 / 5 ノードを選べる -- 一覧タイトルは「Nノードルール」 -- 詳細は全ノードを `->` で連結 -- 編集・削除処理は N ごとの `when` 分岐を持たない -- 既存の辞書単語検索と ID 入力補助を全ノードで利用できる - -固定 XML をさらに複製せず、ノード入力部分を再利用可能な child layout として最大 5 個 inflate する。 - -### 6.2 JSON - -新形式には明示的なバージョンを持たせる。 - -```json -{ - "version": 2, - "rules": [ - { "nodes": [/* 2..5 NodeFeatureInput */], "adjustment": -2000 } - ] -} -``` - -import は JSON オブジェクトを先に検査する。 - -- version=2: `rules` を読む -- version がなく `twoNodeRules` / `threeNodeRules` がある: 旧形式として新モデルへ変換 -- 未知の version、N が 2〜5 以外、不正な ID・adjustment: エラー位置を示して全体を取り込まない - -export は version=2 のみとする。ProGuard keep 対象も新バックアップ型へ更新する。 - -## 7. テスト設計 - -### 7.1 正しさを保証する通常テスト - -通常の unit test は性能値を assert せず、以下を高速に検証する。 - -- 2/3 ノードの既存挙動が不変 -- 4 ノード完全一致・不一致・suffix 不足・途中 EOS -- 5 ノード完全一致・不一致・suffix 不足・途中 EOS -- word / leftId / rightId の各ワイルドカード -- 2/3/4/5 が同時一致したときの加算 -- 同じ current word の完全一致バケットとワイルドカードバケットの加算 -- 異なる候補経路の suffix を取り違えない -- 4/5 ノード補正によって期待する変換候補が 1 位になる統合テスト -- 旧 JSON import、新 JSON round-trip、DB 37→38 migration - -### 7.2 性能プローブの層 - -性能観測は三層に分ける。 - -#### A. スコアラー単体 - -対象: - -- N: 2、3、4、5 -- ルール数: 0、10、100、500、1000(必要なら 5000) -- 分布: current word が分散した通常ケース / 同じ current word に集中した最悪ケース / word ワイルドカード集中 -- 結果: hit / miss - -指標: - -- scorer 構築時間 -- scorer 構築時 allocated bytes -- scorer の GC 後 retained Java heap(複数 scorer を保持して差分を拡大し、1 個あたりへ換算) -- `score()` の p50 / p95 / ns/op -- `score()` 1 回あたり allocated bytes(目標 0) - -#### B. 合成グラフによる FindPath - -同じノード数・分岐数・候補数のグラフで、N とルール数だけを変える。 - -指標: - -- 変換 p50 / p95 -- 変換 1 回あたり allocated bytes -- A* ループ回数、展開辺数、キュー最大サイズ -- 候補 fingerprint - -補正が一致しない no-op ルールでは fingerprint が baseline と同一であることを確認する。一致ルールの試験では期待候補だけが変わることを確認する。 - -#### C. 実辞書・実端末 - -短文、中程度、長文、および 5 ノード以上に分節される日本語入力の固定 corpus を使う。既存の `ConversionPerformanceProbeTest` と `PrefixConversionPerformanceInstrumentedTest` のレポート方式を再利用する。 - -指標: - -- cold 変換時間 -- warm p50 / p95 / max -- 逐次入力 1 セッション全体の時間 -- `art.gc.bytes-allocated` の差分、GC 回数 -- GC 後 Java heap 差分、参考値として PSS -- 最終候補 fingerprint - -端末上の結果を正式値とし、デスクトップ JVM の値は高速な傾向確認に限定する。 - -### 7.3 比較条件 - -最低限、次のケースを同一プロセス・同一端末で順序を入れ替えて複数回測る。 - -| ケース | 有効なルール | -|---|---| -| baseline | 追加ルール 0 | -| N=2 | 2 ノードのみ K 件 | -| N=3 | 3 ノードのみ K 件 | -| N=4 | 4 ノードのみ K 件 | -| N=5 | 5 ノードのみ K 件 | -| mixed | 2/3/4/5 を各 K 件 | - -ルール総数を揃える比較と「各 N に K 件」の比較を分ける。後者だけでは mixed の総数が 4 倍になり、N の影響と件数の影響を区別できない。 - -各ケースは warm-up 後に 20 回以上のバッチを実行し、平均だけでなく p50 と p95 を保存する。端末、API、ABI、build type、アプリ version、ルール seed、反復数もレポートへ記録する。 - -### 7.4 出力 - -人間向けテキストに加え、比較可能な TSV または JSON を `build/reports/ngram-performance/` に出力する。 - -```text -device, buildType, layer, distribution, n, ruleCount, -buildNs, retainedBytes, allocatedBytesPerOp, -p50Ns, p95Ns, conversionP50Ms, conversionP95Ms, -expandedEdges, maxQueueSize, fingerprint -``` - -最初の実装では閾値による CI failure を入れない。基準端末で 5 回以上の baseline を集め、分散を確認してから、例えば「p95 が baseline 比 15% 以上悪化」のような回帰閾値を別途決める。 - -## 8. 予想される性能特性 - -- 4/5 ノード対応コードだけを追加し、ルールが 0 件なら、常駐メモリと変換時間への影響はほぼ固定かつ小さい。 -- ルール保持メモリは、概ね「ルール数 × N」に比例する。 -- word 索引が効く通常ケースの `score()` 時間は全ルール総数ではなく、同じ current word のバケット件数に主に比例する。 -- current word ワイルドカードが多い場合は毎回走査されるため、ルール数に比例して遅くなる。 -- N=5 は N=2 より最大 3 個多く node/feature を比較するため、同一バケット件数なら遅くなるが、経路組合せの指数的増加はない。 -- adjustment により A* の探索順が変わるため、実変換時間は単体 scorer の増分だけでは説明できない場合がある。そのため探索量も同時記録する。 - -## 9. 実装順序 - -1. 2〜5 ノード共通ドメインモデルと、suffix を明示する scorer API を導入する。 -2. 既存 2/3 の正しさテストを新モデルで通し、4/5 の scorer・FindPath テストを追加する。 -3. Room 37→38 migration、共通 DAO/Repository、migration test を実装する。 -4. ScorerManager を共通ルールへ移行する。 -5. 共通 UI、4/5 ノード入力、一覧、編集、削除を実装する。 -6. JSON v2 export と v1/v2 import を実装する。 -7. 単体・合成グラフ・実端末の性能プローブとレポート生成を実装する。 -8. 基準端末で baseline / N=2 / N=3 / N=4 / N=5 / mixed を測り、結果を比較する。 - -## 10. 完了条件 - -- 2/3 ノードの既存ルール、DB データ、旧 JSON が失われない。 -- UI から 2/3/4/5 ノードルールを追加・編集・削除・export/import できる。 -- 4/5 ノード補正が三つの後方探索経路すべてで一度だけ適用される。 -- ルール 0 件時に `score()` がヒープ割り当てを行わない。 -- N とルール数ごとの保持メモリ、割り当て量、単体時間、変換時間、探索量を同じ形式で出力できる。 -- 候補 fingerprint によって、性能測定中も結果の同一性または意図した変化を検証できる。 - -## 11. 実装後の計測コマンド - -スコアラー単体の JVM プローブは明示的に有効化したときだけ実行される。 - -```bash -NGRAM_PERF_PROBE=true \ -NGRAM_PERF_COUNTS=0,10,100,500,1000 \ -NGRAM_PERF_WARMUP=10000 \ -NGRAM_PERF_ITERATIONS=100000 \ -./gradlew :app:testLiteStandardDebugUnitTest \ - --tests '*NgramRuleScorerPerformanceProbeTest' -``` - -結果は `app/build/reports/ngram-performance/scorer.tsv` に出力される。 - -実辞書・実端末の変換プローブは接続端末に対して次のように実行する。 - -```bash -./gradlew :app:connectedLiteStandardDebugAndroidTest \ - -Pandroid.testInstrumentationRunnerArguments.class=com.kazumaproject.markdownhelperkeyboard.converter.NgramConversionPerformanceInstrumentedTest \ - -Pandroid.testInstrumentationRunnerArguments.ngramPerfProbe=true \ - -Pandroid.testInstrumentationRunnerArguments.ngramPerfCounts=0,10,100,500,1000 \ - -Pandroid.testInstrumentationRunnerArguments.ngramPerfIterations=20 -``` - -端末、温度、バックグラウンド負荷を揃え、正式比較では複数回実行する。テストは開始前の N-gram ルールを退避し、終了時に復元する。 diff --git a/docs/omission-flick-typo-performance-report-2026-07-11.md b/docs/omission-flick-typo-performance-report-2026-07-11.md deleted file mode 100644 index 94b2ba162..000000000 --- a/docs/omission-flick-typo-performance-report-2026-07-11.md +++ /dev/null @@ -1,151 +0,0 @@ -# 省略検索・フリック誤入力補正 改善・実機計測レポート - -## 結論 - -Pixel 6で「ここではきものをぬぐことをおすすめします」を1文字ずつ入力し、省略検索とフリック誤入力補正の4条件を各10セッション計測した。 - -- 省略検索ON・フリック補正OFFの全文変換P50は `2,867.92 ms` から `78.86 ms` に短縮した。約97.3%の短縮、約36.4倍の高速化である。 -- 両方ONの全文変換P50は `2,961.74 ms` から `319.93 ms` に短縮した。 -- 省略検索ON・フリック補正OFFの1変換あたり一時割り当ては `24.50 MB` から `3.69 MB` に減少した。 -- 4条件すべてで正常入力の上位4候補の文字列・順位・スコアが同一だった。 -- 4条件すべてで、増分入力後とコールド全再構築の上位12候補が一致した。 -- 入力長による打ち切りは実装していない。60文字の連続入力でも候補が返り、増分結果と全再構築結果が一致した。 - -## 原因 - -初期計測では、20文字の全文変換約2.9秒の内訳は次のとおりだった。 - -- 変換グラフ構築: 約27〜37 ms -- N-best経路探索: 約2,867〜2,931 ms -- グラフノード数: 371 - -主原因は省略辞書探索ではなく、通常N-best探索の経路管理だった。優先度付きキューに入れた複数経路が同じ `Node` の `next` と `g` を共有し、後続の探索が先に入れた経路を上書きしていた。省略検索で分割経路が増えると、同じ出力に至る経路を大量に展開し、短命オブジェクトとGCが増えていた。 - -## 実装内容 - -1. N-best経路を不変の経路要素として保持し、キュー投入後に別経路から上書きされないようにした。 -2. 出力文字列、読み、候補ソース、最大5-gramの今後の評価に必要なノード文脈が同一の経路について、高コスト側だけを支配関係で除外した。これは候補数や文字数による打ち切りではない。 -3. 省略検索のLOUDS探索状態を入力開始位置ごとに保持し、1文字追加時は新しい1文字だけ状態を進めるようにした。 -4. 濁点・半濁点・小文字の派生文字リストを毎回生成せず、コールバックで列挙するようにした。 -5. 増分グラフとForward DPを再利用し、辞書や学習データのリビジョンが変わった場合は再利用しないシグネチャ判定を入れた。 -6. グラフ構築とA*探索にキャンセルチェックを伝播した。 - -## 計測条件 - -- 端末: Google Pixel 6 -- OS: Android API 36 -- ビルド: `fullStandardDebug` -- 入力: `ここではきものをぬぐことをおすすめします`(20文字) -- 候補生成: 通常候補、N-best 4、Beam width 20 -- Mozc UT追加辞書: OFF -- 学習辞書: 計測対象外 -- 各条件10セッション -- 1セッション: `こ` から全文まで20回、各プレフィックスを順番に変換 - -## 速度 - -| 省略検索 | フリック補正 | 改善前 全文P50 | 改善後 全文P50 | 改善後 全文P95 | 改善後 20回合計P50 | -|---|---|---:|---:|---:|---:| -| OFF | OFF | 9.63 ms | 8.12 ms | 14.37 ms | 118.42 ms | -| OFF | ON | 86.71 ms | 88.16 ms | 97.50 ms | 800.77 ms | -| ON | OFF | 2,867.92 ms | 78.86 ms | 82.07 ms | 872.09 ms | -| ON | ON | 2,961.74 ms | 319.93 ms | 341.61 ms | 2,099.77 ms | - -省略検索単独は実機で約80 msに収まった。両方ONは改善前より大幅に速いが、約320 msであり、さらなる改善余地がある。 - -## メモリ・GC - -| 省略検索 | フリック補正 | 改善前 1変換割り当て | 改善後 1変換割り当て | 改善後 1セッション | 10セッション中GC | GC後保持差分 | -|---|---|---:|---:|---:|---:|---:| -| OFF | OFF | 273,674 B | 5,386 B | 107,724 B | 0 | +49,152 B | -| OFF | ON | 3,620,372 B | 3,892,101 B | 77,842,022 B | 5 | +61,440 B | -| ON | OFF | 24,495,104 B | 3,691,213 B | 73,824,265 B | 4 | +69,632 B | -| ON | ON | 26,802,585 B | 7,296,225 B | 145,924,505 B | 9 | +53,248 B | - -省略検索ON・フリック補正OFFの一時割り当ては約84.9%減少した。GC後のマネージドヒープ保持差分は最大69,632 Bで、継続的なリークは観測しなかった。 - -## 候補精度 - -正常入力は4条件すべてで次の上位4候補だった。 - -1. `ここでは着物を脱ぐことをおすすめします` — score 25416 -2. `ここで履物を脱ぐことをおすすめします` — score 26137 -3. `ここでは着物を脱ぐことをオススメします` — score 26238 -4. `ここではきものを脱ぐことをおすすめします` — score 26302 - -改善前も正解の第1候補は同じだった。経路の共有上書きを解消した結果、第2・第3候補のスコアはより低い正規の経路コストになり、第4候補は「ぬぐ」を「脱ぐ」に変換する候補になった。 - -誤入力のスポット確認: - -- `ここではきものをにぐことをおすすめします`(`ni`方向のフリック誤入力、意図は `ぬぐ`) - - 補正OFF: `ここでは着物を似具ことをおすすめします` - - 補正ON: `ここでは着物を脱ぐことをおすすめします` -- `ここではきものをぬくことをおすすめします`(濁点省略、意図は `ぬぐ`) - - 省略検索OFF/ONのどちらでも「着物を抜く」が第1候補だった。N-best 4の範囲では「脱ぐ」は表示されなかった。速度改善とは別に、省略候補のランキング課題が残っている。 - -上記はスポット確認であり、大規模な誤入力コーパスによる統計的な精度評価ではない。ただし、計測したすべての入力と設定で `incrementalMatchesColdRebuild=true` を確認した。 - -## 60文字入力 - -20文字文を3回連結した60文字を、省略検索ON・フリック補正OFFで1文字ずつ変換した。 - -- 60文字時の変換: 289.22 ms -- 60回の変換合計: 9,353.05 ms -- 60文字の変換候補: 返却あり -- 増分候補とコールド全再構築の上位12候補: 一致 - -したがって、一定文字数以上で候補を出さない制限は入っていない。ただし、60文字を1文字ずつ全プレフィックス変換する合計コストと一時メモリは大きく、長文の連続変換には今後も改善余地がある。 - -## キャンセル応答 - -| 省略検索 | フリック補正 | 停止時間 | -|---|---|---:| -| OFF | OFF | 1.23 ms | -| OFF | ON | 24.39 ms | -| ON | OFF | 6.38 ms | -| ON | ON | 8.17 ms | - -すべての条件でキャンセル要求を受けて停止した。 - -## 効果のなかった試行 - -- 新しい入力末尾に完全一致する省略辞書経路だけを再検索する初期案は、全文P50 `2,858.23 ms` でほぼ効果がなかった。 -- Forward DP再利用だけの追加も、全文P50 `2,893.96 ms` で効果がなかった。 -- この結果を受けて工程別計測を行い、N-best経路探索が主因と特定した。 - -## 検証 - -### 追記: 実際の候補タブ設定 - -初回レポートは通常N-best 4を直接呼び出しており、実機の候補タブ設定と一致していなかった。実機の設定は次のとおりだった。 - -- 候補タブ: ON -- 文節分離: ON -- N-best: 14 -- 省略検索: ON -- フリック誤入力補正: ON -- Beam width: 20 - -実際のIMEログでは、改善前の `IMEService.getSuggestionList.kanaKanjiEngine` は `5,080〜5,639 ms`、候補タブのアダプタ更新とDiff処理は約1〜3 msだった。遅延は表示ではなく、文節分離付きN-best 14の生成にあった。 - -この経路にも次の改善を追加した。 - -- 文節分離付きA*への同値経路支配除外 -- フリック補正のLOUDS探索状態とペナルティの増分再利用 -- 探索経路の文字列を毎回連結せず、候補確定時だけ文字列化する永続経路化 -- 同じ入力文字のフリック候補リストと探索深さの再利用 - -最終の実設定相当計測(3セッション): - -- 20文字時 P50: `365.81 ms`(改善前の実IME約5.1〜5.6秒から短縮) -- 1変換あたり一時割り当て: `5,994,632 B` -- 20回の入力合計 P50: `2,208.40 ms` -- GC後保持差分: `+49,152 B` -- 増分候補とコールド全再構築の上位12候補: 一致 - -上位候補の文字列、順位、スコアは改善前の文節分離付き経路と一致した。ただし約366 msはなお体感可能な時間であり、「遅延が完全になくなった」とは評価しない。 - -- `:app:testFullStandardDebugUnitTest`: 成功 -- `:app:compileFullStandardDebugAndroidTestKotlin`: 成功 -- Pixel 6の計測・精度テスト: すべて成功 -- `git diff --check`: 成功 diff --git a/docs/system-ngram-performance-2026-07-12.tsv b/docs/system-ngram-performance-2026-07-12.tsv deleted file mode 100644 index fc37dbd9e..000000000 --- a/docs/system-ngram-performance-2026-07-12.tsv +++ /dev/null @@ -1,3 +0,0 @@ -enabled rules storageBytes heapDeltaBytes nativeHeapDeltaBytes pssDeltaBytes allocatedBytesPerConversion gcCount p50Ms p95Ms p99Ms firstCandidate firstScore matched -false 0 0 0 1840 17408 155648 0 2.679972 3.145955 3.205973 服を切る 10440 false -true 100000 6175023 6176768 -96 6182912 155648 0 2.754760 3.265991 3.352336 服を着る 10691 true diff --git a/docs/system-ngram-performance-report-2026-07-12.md b/docs/system-ngram-performance-report-2026-07-12.md deleted file mode 100644 index 3535280c5..000000000 --- a/docs/system-ngram-performance-report-2026-07-12.md +++ /dev/null @@ -1,85 +0,0 @@ -# Scoreless system n-gram version 3 implementation and performance - -Date: 2026-07-12 - -## Implementation - -JapaneseKeyboard now reads the converter's version 3 binary directly from -`app/src/main/assets/ngram/system_ngram.dat`. The reader owns the one `ByteArray` -allocated by the Android asset loader. It does not use mmap, `MappedByteBuffer`, -`FileChannel.map`, a temporary file, or a second dictionary-sized byte array. - -The format reader validates the magic, version, complete header/offset layout, -CRC, signatures, context-to-coarse-POS table, bucket ranges, hash entry record -IDs and ordering, front-coded block ranges, and every encoded record boundary. -Lookup encodes a canonical query into a thread-local scratch buffer, searches -the dynamically sized bucketed 64-bit hash index, decodes a hit's 16-record -front-coding block into a second scratch buffer, and finally performs a complete -byte-for-byte canonical-key comparison. A 48-bit hash hit alone never matches. - -Exact words, coarse POS, and `*` are supported for rules of two through five -nodes. `*` consumes exactly one node. Matching is performed against the `Node` -path used to construct a candidate. The scoreless result is only a Boolean -ordering key; no `Candidate.score` is added, subtracted, or overwritten. The -existing user n-gram scorer remains a separate provider and code path. - -When no system dictionary is present, path search stops at the requested count. -When present, it searches up to a 32–64 candidate safety bound only while no -match has been found, and stops as soon as both the requested count and a match -exist. A small inline match set removed the allocation caused by the original -`LinkedHashSet` backing table. - -## 100,000-rule dictionary - -The Kotlin converter built the three release rules plus 99,997 deterministic -temporary exact rules. The temporary source was deleted and both repositories -were restored to the three-rule release asset after measurement. - -| Item | Value | -|---|---:| -| Rules | 100,000 | -| File size | 6,175,023 bytes (5.89 MiB) | -| Hash index | 1,262,148 bytes | -| Front-coded records | 4,885,111 bytes | -| Final release rules | 3 | -| Final release asset | 3,890 bytes | - -## Pixel 6 measurement - -Physical Pixel 6, Android 16 / API 36, `liteStandardDebug`, input -`ふくをきる`, requested candidates 4. Both configurations were warmed at -least 30 times. Timing used 100 enabled and 100 disabled samples in alternating -order. This is a debug build, so absolute timing is not a release benchmark. - -| System n-gram | Java heap retained | Native heap delta | PSS delta | Allocated/conversion | GC | p50 | p95 | p99 | First candidate | Score | Match | -|---|---:|---:|---:|---:|---:|---:|---:|---:|---|---:|---| -| Disabled | 0 B | +1,840 B | +17,408 B | 155,648 B | 0 | 2.680 ms | 3.146 ms | 3.206 ms | 服を切る | 10,440 | no | -| Enabled | +6,176,768 B | -96 B | +6,182,912 B | 155,648 B | 0 | 2.755 ms | 3.266 ms | 3.352 ms | 服を着る | 10,691 | yes | - -Enabled minus disabled timing was +0.075 ms at p50, +0.120 ms at p95, -and +0.146 ms at p99. PSS enabled-minus-disabled was 6,165,504 bytes. -The native-heap difference is allocator/snapshot noise; dictionary storage is -on the Java heap. Retained Java heap was only 1,745 bytes above the binary size. -No additional per-conversion allocation or GC was measured after replacing the -match-tracking `LinkedHashSet` with inline storage. - -Without the dictionary, `服を着る` retained score 10,691 but ranked behind -`服を切る` (10,440). With the dictionary it moved to first without changing -that score. Disabling the dictionary restored the original first candidate. - -## Verification and CI - -The reader test covers exact mismatch, POS mismatch, one-node wildcard arity, -2–5 grams, bad magic/version/CRC/offsets, truncation, a hash index redirected to -another record, and reference identity of the owned dictionary byte array. An -instrumented test exercises real candidate generation and enable/disable -restoration. Gradle verifies the version 3 asset and CRC, creates a ZIP with the -required asset path, and verifies its contents. Pull-request and release Actions -run those checks and save the build report. A separate manual workflow checks -out the Kotlin converter, generates the 100,000-rule asset, and runs the Android -performance test on an emulator. - -The remaining concern is that timing and PSS have normal device snapshot noise; -the measured deltas are comfortably within the requested p95/p99 targets, but -release-profile measurements should be repeated when compiler or graph-search -code changes materially. diff --git a/docs/type-null-input-behavior-manual-check.md b/docs/type-null-input-behavior-manual-check.md deleted file mode 100644 index b8281d200..000000000 --- a/docs/type-null-input-behavior-manual-check.md +++ /dev/null @@ -1,37 +0,0 @@ -# TYPE_NULL input behavior manual checks - -## Termux + TYPE_NULL + default - -- Set `TYPE_NULL 入力欄の動作` to `デフォルト(直接入力)`. -- Open Termux and use the QWERTY English keyboard. -- Type `c`, `o`, `w`, `s`, `a`, `y`. -- Confirm each character appears in Termux immediately before Enter. -- Confirm the candidate strip does not accumulate `cowsay`. -- Press Enter and confirm the command runs. -- Press Backspace and confirm Termux deletes the character in the terminal. -- Repeat with `直接入力` and confirm the same behavior. - -## Termux + TYPE_NULL + ComposingText override - -- Set `TYPE_NULL 入力欄の動作` to `ComposingText を使う`. -- Open Termux and type `cowsay`. -- Confirm input accumulates in the composing buffer/candidate strip as before. -- Press Enter and confirm the text is committed. - -## Normal EditText - -- Confirm Japanese input still uses composing text. -- Confirm conversion candidates are shown. -- Confirm live conversion still works when enabled. -- Confirm Zenzai, Gemma, and bunsetsu separation behavior is unchanged. -- Confirm Enter, Done, and Search actions keep their existing behavior. - -## Password fields - -- Confirm existing password-field settings are unchanged. -- Confirm `TYPE_NULL 入力欄の動作` does not affect password fields. - -## Number and phone fields - -- Confirm number fields keep the existing behavior. -- Confirm phone fields keep the existing behavior. 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) {