diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index a6ebebf53..ea385c597 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -46,7 +46,7 @@ jobs: uses: actions/cache@v4 with: path: .zenz-model-cache - key: ${{ runner.os }}-zenz-model-v1-a728931c8b4867a53ca33d1ff3fe7360b0f15cd5-Q5_K_M-ggml-model-Q5_K_M.gguf + key: ${{ runner.os }}-zenz-model-v2-4f5423f0fad41a73b1242eb96fe5c12ae4fdca83-Q5_K_M-ggml-model-Q5_K_M.gguf - name: Decode JKS from Base64 run: | diff --git a/.github/workflows/fast-input-regression.yml b/.github/workflows/fast-input-regression.yml index 891432c07..3cb36988d 100644 --- a/.github/workflows/fast-input-regression.yml +++ b/.github/workflows/fast-input-regression.yml @@ -73,7 +73,7 @@ jobs: uses: actions/cache@v5 with: path: .zenz-model-cache - key: ${{ runner.os }}-zenz-model-v1-a728931c8b4867a53ca33d1ff3fe7360b0f15cd5-Q5_K_M-ggml-model-Q5_K_M.gguf + key: ${{ runner.os }}-zenz-model-v2-4f5423f0fad41a73b1242eb96fe5c12ae4fdca83-Q5_K_M-ggml-model-Q5_K_M.gguf - name: Enable KVM run: | diff --git a/README.md b/README.md index 2bb180346..663aa5c86 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ Sumireは、**プライバシーを絶対に妥協しない** | キーボード | キーボードの選択 | 画面/操作 | | キーボード | 位置とサイズの設定(縦画面) | 画面/操作 | | キーボード | 位置とサイズの設定(横画面) | 画面/操作 | +| キーボード | 横画面で全画面入力を許可 | 通常端末: OFF / small端末: ON | | キーボード | キー・変換候補の文字サイズ | 画面/操作 | | キーボード | キーボード背景画像 | 画面/操作 | | キーボード | 背景画像の表示方法 | フィット | @@ -289,7 +290,7 @@ cd JapaneseKeyboard #### Zenz モデルをビルド時に生成する -`zenz` モジュールは、開発機のビルド時に Hugging Face から元モデルを取得し、`llama.cpp` で GGUF 変換と量子化を行った結果を APK/AAB に同梱します。アプリ実行時には通信しないため、`INTERNET` 権限は不要です。 +`zenz` モジュールは、開発機のビルド時に Hugging Face から量子化済みのGGUFモデルを取得し、APK/AAB に同梱します。アプリ実行時には通信しないため、`INTERNET` 権限は不要です。 ```bash ./gradlew installDebug @@ -299,15 +300,15 @@ cd JapaneseKeyboard ```properties # local.properties -zenzModelRepo=Miwa-Keita/zenz-v3.1-xsmall -zenzModelRevision=a728931c8b4867a53ca33d1ff3fe7360b0f15cd5 +zenzModelRepo=Miwa-Keita/zenz-v3.2-xsmall-gguf +zenzModelRevision=4f5423f0fad41a73b1242eb96fe5c12ae4fdca83 zenzModelQuantization=Q5_K_M zenzModelAssetName=ggml-model-Q5_K_M.gguf # 公開モデルなので通常は不要 # zenzModelHfToken=hf_xxx ``` -このモードでは生成済みモデルを `zenz/build/generated/assets/zenzModel/main/ggml-model-Q5_K_M.gguf` に出力して APK/AAB に同梱します。Python 3 と CMake が必要で、初回は `huggingface_hub` と `llama.cpp` の変換用依存を自動で入れます。 +このモードではモデルを `zenz/build/generated/assets/zenzModel/main/ggml-model-Q5_K_M.gguf` に出力して APK/AAB に同梱します。Python 3 が必要で、初回は `huggingface_hub` を自動で入れます。 #### 配布バリアント diff --git a/app/build.gradle b/app/build.gradle index dce6e2fca..7e8a5f89b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ android { applicationId "com.kazumaproject.markdownhelperkeyboard" minSdk 24 targetSdk 36 - versionCode 795 - versionName "1.7.102" + versionCode 797 + versionName "1.7.104" 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 15bf3a223..ae984026f 100644 --- a/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/FastInputMatrixInstrumentedTest.kt +++ b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/FastInputMatrixInstrumentedTest.kt @@ -12,10 +12,14 @@ import android.graphics.Bitmap import android.graphics.PointF import android.graphics.Rect import android.os.Bundle +import android.os.Debug import android.os.ParcelFileDescriptor import android.os.PowerManager import android.os.SystemClock import android.provider.Settings +import android.text.Spanned +import android.text.style.BackgroundColorSpan +import android.text.style.UnderlineSpan import android.util.Log import android.view.InputDevice import android.view.MotionEvent @@ -50,6 +54,429 @@ class FastInputMatrixInstrumentedTest { private val uiAutomation: UiAutomation get() = instrumentation.uiAutomation + @Test + fun flickEditorPreviewFunctionalAndPerformanceOnPhysicalDevice() { + runPhysicalDeviceSession("flick-editor-preview") { session -> + var scenario: ActivityScenario? = null + try { + scenario = launchHost(session.context) + rotateAndVerify(TestOrientation.PORTRAIT) + val defaultPreviewBackgroundColor = session.context.getColor( + com.kazumaproject.core.R.color.char_in_edit_color + ) + + val sumireStyles = listOf( + "default", + "circle", + "second-flick", + "third-flick", + "sumire", + ) + val functionalCases = buildList { + add(TestKeyboard.TENKEY to null) + sumireStyles.forEach { style -> add(TestKeyboard.SUMIRE to style) } + } + + functionalCases.forEach { (keyboard, style) -> + val testCase = previewTestCase(keyboard) + applyCasePreferences(session.preferences, testCase) + check( + session.preferences.edit() + .putBoolean("flick_editor_preview_preference", true) + .putBoolean("theme_custom_input_color_enable", false) + .putString("sumire_keyboard_style_preference", style ?: "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) + val geometry = awaitStableGeometry( + keyboard = keyboard, + requireCandidateContent = false, + ) + + val downTime = SystemClock.uptimeMillis() + assertTrue( + "DOWN injection failed for $keyboard/$style", + injectSinglePointerEvent( + downTime, + MotionEvent.ACTION_DOWN, + geometry.prime.center, + ), + ) + assertEquals( + "DOWN preview mismatch for $keyboard/$style", + keyboard.primeText, + awaitEditorText(scenario) { it.isNotEmpty() }, + ) + assertEditorPreviewDecoration( + scenario = scenario, + expectedText = keyboard.primeText, + expectedBackgroundColor = defaultPreviewBackgroundColor, + caseName = "$keyboard/$style DOWN", + ) + assertTrue( + "Candidate generation started before UP for $keyboard/$style", + findCandidateState().texts.isEmpty(), + ) + assertTrue( + "CANCEL injection failed for $keyboard/$style", + injectSinglePointerEvent( + downTime, + MotionEvent.ACTION_CANCEL, + geometry.prime.center, + ), + ) + assertEquals( + "Preview remained after CANCEL for $keyboard/$style", + "", + awaitEditorText(scenario) { it.isEmpty() }, + ) + } + + listOf(TestKeyboard.TENKEY, TestKeyboard.SUMIRE).forEach { keyboard -> + val testCase = previewTestCase(keyboard) + applyCasePreferences(session.preferences, testCase) + check( + session.preferences.edit() + .putBoolean("flick_editor_preview_preference", true) + .putBoolean("theme_custom_input_color_enable", true) + .putInt( + "theme_custom_pre_edit_bg_color", + PREVIEW_TEST_BACKGROUND_COLOR, + ) + .putString("sumire_keyboard_style_preference", "default") + .commit() + ) + restartInput(scenario) + SystemClock.sleep(IME_LAYOUT_SETTLE_MS) + val geometry = awaitStableGeometry(keyboard, false) + val end = PointF( + geometry.prime.center.x + geometry.prime.width * 0.70f, + geometry.prime.center.y, + ) + val downTime = SystemClock.uptimeMillis() + assertTrue( + injectSinglePointerEvent( + downTime, + MotionEvent.ACTION_DOWN, + geometry.prime.center, + ) + ) + assertEquals( + keyboard.primeText, + awaitEditorText(scenario) { it.isNotEmpty() }, + ) + assertTrue( + injectSinglePointerEvent(downTime, MotionEvent.ACTION_MOVE, end) + ) + val movedPreview = awaitEditorText(scenario) { + it.isNotEmpty() && it != keyboard.primeText + } + assertTrue("MOVE did not replace preview for $keyboard", movedPreview.isNotEmpty()) + assertEditorPreviewDecoration( + scenario = scenario, + expectedText = movedPreview, + expectedBackgroundColor = PREVIEW_TEST_BACKGROUND_COLOR, + caseName = "$keyboard MOVE", + ) + assertTrue( + injectSinglePointerEvent(downTime, MotionEvent.ACTION_UP, end) + ) + assertEquals( + "UP result differed from MOVE preview for $keyboard", + movedPreview, + awaitTextSettled(scenario), + ) + } + + listOf(TestKeyboard.TENKEY, TestKeyboard.SUMIRE).forEach { keyboard -> + val testCase = previewTestCase(keyboard) + applyCasePreferences(session.preferences, testCase) + check( + session.preferences.edit() + .putBoolean("flick_editor_preview_preference", false) + .putString("sumire_keyboard_style_preference", "default") + .commit() + ) + restartInput(scenario) + SystemClock.sleep(IME_LAYOUT_SETTLE_MS) + val geometry = awaitStableGeometry(keyboard, false) + val downTime = SystemClock.uptimeMillis() + assertTrue( + injectSinglePointerEvent( + downTime, + MotionEvent.ACTION_DOWN, + geometry.prime.center, + ) + ) + SystemClock.sleep(PREVIEW_HOLD_ASSERT_MS) + assertEquals("OFF changed editor on DOWN for $keyboard", "", readText(scenario)) + assertTrue( + injectSinglePointerEvent( + downTime, + MotionEvent.ACTION_UP, + geometry.prime.center, + ) + ) + assertEquals(keyboard.primeText, awaitTextSettled(scenario)) + } + + listOf(TestKeyboard.TENKEY, TestKeyboard.SUMIRE).forEach { keyboard -> + applyCasePreferences(session.preferences, previewTestCase(keyboard)) + check( + session.preferences.edit() + .putBoolean("flick_editor_preview_preference", true) + .putBoolean("theme_custom_input_color_enable", true) + .putInt( + "theme_custom_pre_edit_bg_color", + PREVIEW_TEST_BACKGROUND_COLOR, + ) + .putString("sumire_keyboard_style_preference", "default") + .putString("sumire_input_method_preference", "switch-mode-effective") + .commit() + ) + restartInput(scenario) + SystemClock.sleep(IME_LAYOUT_SETTLE_MS) + val geometry = awaitStableGeometry(keyboard, false) + assertTrue("First tail setup tap failed for $keyboard", injectTap(geometry.prime.center)) + assertTrue( + "Second tail setup tap failed for $keyboard", + injectTap(requireNotNull(geometry.neighbor).center), + ) + assertEquals( + "Tail setup text mismatch for $keyboard", + "あな", + awaitEditorText(scenario) { it == "あな" }, + ) + assertTrue( + "Cursor-left tap failed for $keyboard", + injectTap(findCursorLeftBounds(keyboard).center), + ) + + val movePoint = PointF( + geometry.prime.center.x + geometry.prime.width * 0.70f, + geometry.prime.center.y, + ) + val cancelDownTime = SystemClock.uptimeMillis() + assertTrue( + injectSinglePointerEvent( + cancelDownTime, + MotionEvent.ACTION_DOWN, + geometry.prime.center, + ) + ) + assertEquals( + "Tail was not retained on DOWN for $keyboard", + "ああな", + awaitEditorText(scenario) { it == "ああな" }, + ) + assertEditorPreviewDecoration( + scenario = scenario, + expectedText = "ああな", + expectedBackgroundColor = PREVIEW_TEST_BACKGROUND_COLOR, + expectedBackgroundEnd = 2, + caseName = "$keyboard tail DOWN", + ) + assertTrue( + injectSinglePointerEvent( + cancelDownTime, + MotionEvent.ACTION_MOVE, + movePoint, + ) + ) + val movedWithTail = awaitEditorText(scenario) { text -> + text.length == 3 && + text.startsWith("あ") && + text.endsWith("な") && + text != "ああな" + } + assertEditorPreviewDecoration( + scenario = scenario, + expectedText = movedWithTail, + expectedBackgroundColor = PREVIEW_TEST_BACKGROUND_COLOR, + expectedBackgroundEnd = 2, + caseName = "$keyboard tail MOVE", + ) + assertTrue( + injectSinglePointerEvent( + cancelDownTime, + MotionEvent.ACTION_CANCEL, + movePoint, + ) + ) + assertEquals( + "Tail preview did not restore after CANCEL for $keyboard", + "あな", + awaitEditorText(scenario) { it == "あな" }, + ) + + val commitDownTime = SystemClock.uptimeMillis() + assertTrue( + injectSinglePointerEvent( + commitDownTime, + MotionEvent.ACTION_DOWN, + geometry.prime.center, + ) + ) + assertTrue( + injectSinglePointerEvent( + commitDownTime, + MotionEvent.ACTION_MOVE, + movePoint, + ) + ) + val commitPreview = awaitEditorText(scenario) { text -> + text.length == 3 && + text.startsWith("あ") && + text.endsWith("な") && + text != "ああな" + } + assertTrue( + injectSinglePointerEvent( + commitDownTime, + MotionEvent.ACTION_UP, + movePoint, + ) + ) + assertEquals( + "UP did not insert before stringInTail for $keyboard", + commitPreview, + awaitTextSettled(scenario), + ) + } + + val performanceLines = mutableListOf() + applyCasePreferences(session.preferences, previewTestCase(TestKeyboard.TENKEY)) + for (enabled in listOf(false, true)) { + check( + session.preferences.edit() + .putBoolean("flick_editor_preview_preference", enabled) + .putString("keyboard_touch_effect_type_preference", "none") + .commit() + ) + val candidateSamplesNs = LongArray(PREVIEW_CANDIDATE_SAMPLES) + repeat(PREVIEW_CANDIDATE_WARMUP + PREVIEW_CANDIDATE_SAMPLES) { index -> + restartInput(scenario) + val trialGeometry = awaitStableGeometry(TestKeyboard.TENKEY, false) + val downTime = SystemClock.uptimeMillis() + assertTrue( + injectSinglePointerEvent( + downTime, + MotionEvent.ACTION_DOWN, + trialGeometry.prime.center, + ) + ) + if (enabled) { + assertEquals( + TestKeyboard.TENKEY.primeText, + awaitEditorText(scenario) { it.isNotEmpty() }, + ) + } + assertTrue( + "Candidates changed before UP (enabled=$enabled)", + findCandidateState().texts.isEmpty(), + ) + val upStartedNs = SystemClock.elapsedRealtimeNanos() + assertTrue( + injectSinglePointerEvent( + downTime, + MotionEvent.ACTION_UP, + trialGeometry.prime.center, + ) + ) + val candidateLatencyNs = awaitCandidateVisibleLatencyNs(upStartedNs) + if (index >= PREVIEW_CANDIDATE_WARMUP) { + candidateSamplesNs[index - PREVIEW_CANDIDATE_WARMUP] = + candidateLatencyNs + } + } + val sortedCandidateSamples = candidateSamplesNs.sorted() + val candidateLine = buildString { + append("enabled=$enabled ") + append("samples=$PREVIEW_CANDIDATE_SAMPLES ") + append("candidateLatencyMeanMs=") + append(candidateSamplesNs.average() / 1_000_000.0) + append(" candidateLatencyP50Ms=") + append( + sortedCandidateSamples[sortedCandidateSamples.size / 2] / + 1_000_000.0 + ) + append(" candidateLatencyP95Ms=") + append( + sortedCandidateSamples[ + ((sortedCandidateSamples.size - 1) * 0.95).toInt() + ] / 1_000_000.0 + ) + } + performanceLines += candidateLine + Log.i(TAG, "FLICK_EDITOR_PREVIEW_CONVERSION $candidateLine") + sendProgress("FLICK_EDITOR_PREVIEW_CONVERSION $candidateLine\n") + + restartInput(scenario) + SystemClock.sleep(IME_LAYOUT_SETTLE_MS) + val geometry = awaitStableGeometry(TestKeyboard.TENKEY, false) + val performanceMovePoint = PointF( + geometry.prime.center.x + geometry.prime.width * 0.70f, + geometry.prime.center.y, + ) + repeat(PREVIEW_PERFORMANCE_WARMUP_GESTURES) { + injectPreviewCancelGesture(geometry.prime.center, performanceMovePoint) + } + Runtime.getRuntime().gc() + SystemClock.sleep(PREVIEW_GC_SETTLE_MS) + val allocatedBefore = runtimeStat("art.gc.bytes-allocated") + val pssBeforeKb = Debug.getPss().toLong() + val startedNs = SystemClock.elapsedRealtimeNanos() + repeat(PREVIEW_PERFORMANCE_GESTURES) { + assertTrue( + injectPreviewCancelGesture( + geometry.prime.center, + performanceMovePoint, + ) + ) + } + val elapsedNs = SystemClock.elapsedRealtimeNanos() - startedNs + val allocatedAfter = runtimeStat("art.gc.bytes-allocated") + val pssImmediateKb = Debug.getPss().toLong() + Runtime.getRuntime().gc() + SystemClock.sleep(PREVIEW_GC_SETTLE_MS) + val pssSettledKb = Debug.getPss().toLong() + val line = buildString { + append("enabled=$enabled ") + append("gestures=$PREVIEW_PERFORMANCE_GESTURES ") + append("meanGestureUs=") + append(elapsedNs / PREVIEW_PERFORMANCE_GESTURES / 1_000.0) + append(" allocatedBytes=") + append(allocatedAfter - allocatedBefore) + append(" allocatedBytesPerGesture=") + append((allocatedAfter - allocatedBefore) / PREVIEW_PERFORMANCE_GESTURES) + append(" pssBeforeKb=$pssBeforeKb") + append(" pssImmediateKb=$pssImmediateKb") + append(" pssSettledKb=$pssSettledKb") + } + performanceLines += line + Log.i(TAG, "FLICK_EDITOR_PREVIEW_PERF $line") + sendProgress("FLICK_EDITOR_PREVIEW_PERF $line\n") + } + + val report = File(session.outputDirectory, "flick-editor-preview-performance.txt") + report.writeText( + buildString { + appendLine("device=${android.os.Build.MODEL}") + appendLine("sdk=${android.os.Build.VERSION.SDK_INT}") + performanceLines.forEach(::appendLine) + } + ) + sendProgress("FLICK_EDITOR_PREVIEW_REPORT $report\n") + } finally { + scenario?.close() + } + } + } + @Test fun tenKeyNumberBracketGuideSurvivesActualFlicksOnPhysicalDevice() { runPhysicalDeviceSession("tenkey-number-bracket-guide") { session -> @@ -240,6 +667,83 @@ class FastInputMatrixInstrumentedTest { } } + @Test + fun tenKeyDeleteLongPressClearsComposingCandidatesOnPhysicalDevice() { + runPhysicalDeviceSession("delete-long-press-candidate-clear") { session -> + val testCase = TestCase( + keyboard = TestKeyboard.TENKEY, + columns = 1, + candidateTabVisible = false, + toolbarVisible = false, + toolbarIntegrated = false, + orientation = TestOrientation.PORTRAIT + ) + 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 before = awaitStableGeometry( + keyboard = TestKeyboard.TENKEY, + requireCandidateContent = false + ) + val inputInjected = injectSequence( + first = before.first.center, + second = before.second.center, + repetitions = 1 + ) + val inputText = awaitTextSettled(scenario) + awaitStableGeometry( + keyboard = TestKeyboard.TENKEY, + requireCandidateContent = true + ) + assertTrue("Ten-key input injection failed", inputInjected) + assertTrue("Composing input did not reach the editor", inputText.isNotEmpty()) + assertTrue( + "Candidate list was not populated before delete long press", + findCandidateState().texts.isNotEmpty() + ) + + val deleteBounds = findVisibleNodeById("key_delete")?.screenRect() + ?: throw SetupException("Delete key is not visible") + val deleteInjected = injectTapWithoutTrailingGap( + point = deleteBounds.center, + holdMs = 850L + ) + awaitStableGeometry( + keyboard = TestKeyboard.TENKEY, + requireCandidateContent = false + ) + val afterText = awaitTextSettled(scenario) + val afterCandidates = findCandidateState().texts + + assertTrue("Delete long-press injection failed", deleteInjected) + assertTrue("ComposingText was not fully deleted", afterText.isEmpty()) + assertTrue( + "SuggestionAdapter still exposes candidates after ComposingText deletion: " + + afterCandidates, + afterCandidates.isEmpty() + ) + } finally { + scenario?.close() + } + } + } + @Test fun qwertyVariationPopupTwoFingerInputOnPhysicalDevice() { runPhysicalDeviceSession("qwerty-variation-popup-multitouch") { session -> @@ -865,6 +1369,7 @@ class FastInputMatrixInstrumentedTest { .putBoolean("enable_typo_correction_japanese_flick_keyboard_preference", false) .putBoolean("enable_typo_correction_qwerty_english_keyboard_preference", false) .putBoolean("learn_dictionary_preference", false) + .putBoolean("zero_query_suggestion_preference", false) check(editor.commit()) { "Failed to persist preferences for $case" } check( @@ -1025,6 +1530,96 @@ class FastInputMatrixInstrumentedTest { return text } + private fun assertEditorPreviewDecoration( + scenario: ActivityScenario, + expectedText: String, + expectedBackgroundColor: Int, + caseName: String, + expectedBackgroundEnd: Int = expectedText.length, + ) { + val deadline = SystemClock.uptimeMillis() + RESULT_TIMEOUT_MS + var latest = readEditorDecoration(scenario) + while (SystemClock.uptimeMillis() < deadline) { + latest = readEditorDecoration(scenario) + if (latest.matches( + expectedText, + expectedBackgroundColor, + expectedBackgroundEnd, + ) + ) return + SystemClock.sleep(PREVIEW_TEXT_POLL_MS) + } + assertTrue( + "$caseName did not retain the pre-edit background/underline spans: $latest", + latest.matches( + expectedText, + expectedBackgroundColor, + expectedBackgroundEnd, + ), + ) + } + + private fun readEditorDecoration( + scenario: ActivityScenario + ): EditorDecorationSnapshot { + var snapshot = EditorDecorationSnapshot.Empty + scenario.onActivity { activity -> + val text = activity.editText.text + snapshot = EditorDecorationSnapshot( + text = text?.toString().orEmpty(), + backgrounds = text.getSpans( + 0, + text.length, + BackgroundColorSpan::class.java, + ).map { span -> + BackgroundSpanSnapshot( + color = span.backgroundColor, + start = text.getSpanStart(span), + end = text.getSpanEnd(span), + flags = text.getSpanFlags(span), + ) + }, + underlines = text.getSpans( + 0, + text.length, + UnderlineSpan::class.java, + ).map { span -> + SpanRangeSnapshot( + start = text.getSpanStart(span), + end = text.getSpanEnd(span), + flags = text.getSpanFlags(span), + ) + }, + ) + } + return snapshot + } + + private fun awaitEditorText( + scenario: ActivityScenario, + predicate: (String) -> Boolean, + ): String { + val deadline = SystemClock.uptimeMillis() + RESULT_TIMEOUT_MS + var latest = readText(scenario) + while (SystemClock.uptimeMillis() < deadline) { + latest = readText(scenario) + if (predicate(latest)) return latest + SystemClock.sleep(PREVIEW_TEXT_POLL_MS) + } + return latest + } + + private fun awaitCandidateVisibleLatencyNs(startedNs: Long): Long { + val deadline = SystemClock.uptimeMillis() + RESULT_TIMEOUT_MS + while (SystemClock.uptimeMillis() < deadline) { + if (findCandidateState().texts.isNotEmpty()) { + return SystemClock.elapsedRealtimeNanos() - startedNs + } + SystemClock.sleep(PREVIEW_CANDIDATE_POLL_MS) + } + throw SetupException("Candidate list did not become visible during latency measurement") + } + private fun awaitTextSettled( scenario: ActivityScenario ): String { @@ -1130,6 +1725,26 @@ class FastInputMatrixInstrumentedTest { } ?: throw SetupException("Key ${locator.render()} is not visible") } + private fun findCursorLeftBounds(keyboard: TestKeyboard): ScreenRect { + return when (keyboard) { + TestKeyboard.TENKEY -> awaitVisibleNodeBounds("key_soft_left") + TestKeyboard.SUMIRE -> { + val root = findVisibleNodeById(keyboard.rootViewId) + ?: throw SetupException("${keyboard.rootViewId} is not visible") + val labels = setOf("CursorMoveLeft", "Move Cursor Left", "カーソル左") + val node = findDescendant(root) { candidate -> + candidate.text?.toString() in labels || + candidate.contentDescription?.toString() in labels + } ?: throw SetupException("Sumire cursor-left key is not visible") + node.screenRect() + } + + TestKeyboard.QWERTY -> throw SetupException( + "Tail preview test does not target QWERTY" + ) + } + } + private fun findCandidateState(): CandidateState { val recycler = findVisibleNodeById("suggestion_recycler_view") ?: return CandidateState(bounds = null, texts = emptyList()) @@ -1318,6 +1933,17 @@ class FastInputMatrixInstrumentedTest { } } + private fun injectPreviewCancelGesture(start: PointF, move: PointF): Boolean { + val downTime = SystemClock.uptimeMillis() + var injected = injectSinglePointerEvent(downTime, MotionEvent.ACTION_DOWN, start) + injected = injectSinglePointerEvent(downTime, MotionEvent.ACTION_MOVE, move) && injected + injected = injectSinglePointerEvent(downTime, MotionEvent.ACTION_CANCEL, move) && injected + return injected + } + + private fun runtimeStat(name: String): Long = + Debug.getRuntimeStat(name)?.toLongOrNull() ?: 0L + private fun injectTapPairAtDownInterval( point: PointF, downToDownIntervalMs: Long @@ -2039,6 +2665,15 @@ class FastInputMatrixInstrumentedTest { orientation.name.lowercase() } + private fun previewTestCase(keyboard: TestKeyboard) = TestCase( + keyboard = keyboard, + columns = 1, + candidateTabVisible = false, + toolbarVisible = false, + toolbarIntegrated = false, + orientation = TestOrientation.PORTRAIT, + ) + private data class PhysicalDeviceSession( val context: Context, val preferences: SharedPreferences, @@ -2053,6 +2688,53 @@ class FastInputMatrixInstrumentedTest { val texts: List ) + private data class EditorDecorationSnapshot( + val text: String, + val backgrounds: List, + val underlines: List, + ) { + fun matches( + expectedText: String, + expectedBackgroundColor: Int, + expectedBackgroundEnd: Int, + ): Boolean { + val expectedUnderlineEnd = expectedText.length + val hasBackground = backgrounds.any { span -> + span.color == expectedBackgroundColor && + span.start == 0 && + span.end == expectedBackgroundEnd && + span.flags and Spanned.SPAN_COMPOSING != 0 + } + val hasUnderline = underlines.any { span -> + span.start == 0 && + span.end == expectedUnderlineEnd && + span.flags and Spanned.SPAN_COMPOSING != 0 + } + return text == expectedText && hasBackground && hasUnderline + } + + companion object { + val Empty = EditorDecorationSnapshot( + text = "", + backgrounds = emptyList(), + underlines = emptyList(), + ) + } + } + + private data class BackgroundSpanSnapshot( + val color: Int, + val start: Int, + val end: Int, + val flags: Int, + ) + + private data class SpanRangeSnapshot( + val start: Int, + val end: Int, + val flags: Int, + ) + private data class FlickVerification( val name: String, val normalizedDelta: PointF, @@ -2229,6 +2911,15 @@ class FastInputMatrixInstrumentedTest { private const val TAP_GAP_MS = 12L private const val FLICK_MOVE_STEPS = 4 private const val FLICK_STEP_MS = 18L + private const val PREVIEW_HOLD_ASSERT_MS = 120L + private const val PREVIEW_TEXT_POLL_MS = 4L + private const val PREVIEW_TEST_BACKGROUND_COLOR = 0x66336699 + private const val PREVIEW_PERFORMANCE_WARMUP_GESTURES = 30 + private const val PREVIEW_PERFORMANCE_GESTURES = 500 + private const val PREVIEW_GC_SETTLE_MS = 250L + private const val PREVIEW_CANDIDATE_WARMUP = 3 + private const val PREVIEW_CANDIDATE_SAMPLES = 15 + private const val PREVIEW_CANDIDATE_POLL_MS = 1L private const val GUIDE_SETTLE_MS = 350L private const val GUIDE_CHANNEL_TOLERANCE = 12 private const val MAX_GUIDE_CHANGED_PIXELS = 80 diff --git a/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/CandidateOrderOverrideImeInstrumentedTest.kt b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/CandidateOrderOverrideImeInstrumentedTest.kt new file mode 100644 index 000000000..66ded0183 --- /dev/null +++ b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/CandidateOrderOverrideImeInstrumentedTest.kt @@ -0,0 +1,369 @@ +package com.kazumaproject.markdownhelperkeyboard.candidate_order + +import android.accessibilityservice.AccessibilityServiceInfo +import android.app.Instrumentation +import android.content.Context +import android.content.Intent +import android.graphics.PointF +import android.graphics.Rect +import android.os.SystemClock +import android.view.InputDevice +import android.view.MotionEvent +import android.view.accessibility.AccessibilityNodeInfo +import androidx.navigation.Navigation +import androidx.preference.PreferenceManager +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.kazumaproject.markdownhelperkeyboard.candidate_order.model.CandidateOrderScope +import com.kazumaproject.markdownhelperkeyboard.FastInputHostActivity +import com.kazumaproject.markdownhelperkeyboard.R +import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CandidateConversionSegment +import com.kazumaproject.markdownhelperkeyboard.ime_service.di.KanaKanjiEngineEntryPoint +import com.kazumaproject.markdownhelperkeyboard.setting_activity.MainActivity +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CandidateOrderOverrideImeInstrumentedTest { + + private val instrumentation: Instrumentation + get() = InstrumentationRegistry.getInstrumentation() + + @Test + fun lexicalHiOrderPromotesHiNodeInsideHiwoUsingSoftKeyboardTouches() = runBlocking { + verifyHiwoUsingSoftKeyboardTouches( + scope = CandidateOrderScope.LEXICAL_UNIT, + expectedFirstCandidate = "火を", + ) + } + + @Test + fun exactHiOrderDoesNotAffectHiwoUsingSoftKeyboardTouches() = runBlocking { + verifyHiwoUsingSoftKeyboardTouches( + scope = CandidateOrderScope.EXACT_INPUT, + expectedFirstCandidate = "日を", + ) + } + + @Test + fun candidateOrderScreenShowsAndSwitchesBothScopesOnDevice() { + configureAccessibilityInspection() + val context = ApplicationProvider.getApplicationContext() + val scenario = ActivityScenario.launch( + Intent(context, MainActivity::class.java), + ) + try { + scenario.onActivity { activity -> + Navigation.findNavController( + activity, + R.id.nav_host_fragment_activity_main, + ).navigate(R.id.candidateOrderOverrideFragment) + } + instrumentation.waitForIdleSync() + + assertTrue(awaitVisibleText("この読みだけ(完全一致)").width() > 0) + val lexicalScope = awaitVisibleText("同じ語として使われる場合") + assertTrue(tap(lexicalScope.center())) + instrumentation.waitForIdleSync() + assertTrue( + awaitVisibleText( + "通常変換の第一候補がこの読みを独立した同じ語として解析した場合だけ、長い入力にも適用します。異なる語区切りには影響しません。", + ).width() > 0, + ) + } finally { + scenario.close() + } + } + + private suspend fun verifyHiwoUsingSoftKeyboardTouches( + scope: CandidateOrderScope, + expectedFirstCandidate: String, + ) { + configureAccessibilityInspection() + val context = ApplicationProvider.getApplicationContext() + val entryPoint = EntryPointAccessors.fromApplication( + context.applicationContext, + KanaKanjiEngineEntryPoint::class.java, + ) + val candidateOrderRepository = entryPoint.candidateOrderOverrideRepository() + val originalHiOrders = candidateOrderRepository.observeAll() + .first() + .filter { it.input.trim() == "ひ" } + .groupBy { CandidateOrderScope.fromDatabase(it.scope) } + .mapValues { (_, rows) -> rows.sortedBy { it.rank }.map { it.candidate } } + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + val originalPreferences = preferences.all + preferences + .edit() + .putBoolean("candidate_order_override_enable_preference", true) + .putString( + "keyboard_order_preference", + """["TENKEY","SUMIRE","QWERTY","ROMAJI","CUSTOM"]""", + ) + .putBoolean("save_last_used_keyboard", false) + .putBoolean("flick_input_only_preference", true) + .putBoolean("live_conversion_preference", false) + .commit() + candidateOrderRepository.saveOrder( + input = "ひ", + candidates = listOf("火", "日", "陽"), + scope = scope, + ) + + val scenario = ActivityScenario.launch( + Intent(context, FastInputHostActivity::class.java), + ) + try { + scenario.onActivity { it.restartEditorInput(clearText = true) } + instrumentation.waitForIdleSync() + SystemClock.sleep(1_500) + + assertTrue(flickLeft("key_6")) // は -> ひ + assertTrue(flickLeft("key_11")) // わ -> を + + val candidates = awaitVisibleCandidates() + assertEquals(expectedFirstCandidate, candidates.first().first) + assertTrue(tap(candidates.first().second.center())) + + val deadline = SystemClock.uptimeMillis() + 8_000L + var actual = "" + while (SystemClock.uptimeMillis() < deadline) { + scenario.onActivity { actual = it.editText.text.toString() } + if (actual == expectedFirstCandidate) break + SystemClock.sleep(50) + } + assertEquals(expectedFirstCandidate, actual) + } finally { + scenario.close() + candidateOrderRepository.deleteByInput("ひ") + originalHiOrders.forEach { (scope, candidates) -> + candidateOrderRepository.saveOrder("ひ", candidates, scope) + } + preferences.edit().clear().also { editor -> + originalPreferences.forEach { (key, value) -> + when (value) { + is Boolean -> editor.putBoolean(key, value) + is Float -> editor.putFloat(key, value) + is Int -> editor.putInt(key, value) + is Long -> editor.putLong(key, value) + is String -> editor.putString(key, value) + is Set<*> -> editor.putStringSet( + key, + value.filterIsInstance().toSet(), + ) + } + } + }.commit() + } + } + + @Test + fun lexicalHiOrderDoesNotChangeProductionHitoOosugiBaselinePath() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + val entryPoint = EntryPointAccessors.fromApplication( + context.applicationContext, + KanaKanjiEngineEntryPoint::class.java, + ) + val candidateOrderRepository = entryPoint.candidateOrderOverrideRepository() + val originalLexicalOrder = candidateOrderRepository.observeAll() + .first() + .filter { + it.input.trim() == "ひ" && + CandidateOrderScope.fromDatabase(it.scope) == CandidateOrderScope.LEXICAL_UNIT + } + .sortedBy { it.rank } + .map { it.candidate } + val input = "ひとおおすぎ" + val segments = LinkedHashMap>() + + try { + candidateOrderRepository.saveOrder( + input = "ひ", + candidates = listOf("火", "日", "陽"), + scope = CandidateOrderScope.LEXICAL_UNIT, + ) + val baselineCandidates = entryPoint.kanaKanjiEngine() + .getCandidatesWithBunsetsuSeparation( + input = input, + n = 4, + mozcUtPersonName = false, + mozcUTPlaces = false, + mozcUTWiki = false, + mozcUTNeologd = false, + mozcUTWeb = false, + userDictionaryRepository = entryPoint.userDictionaryRepository(), + learnRepository = null, + isOmissionSearchEnable = false, + enableTypoCorrectionJapaneseFlick = false, + enableTypoCorrectionQwertyEnglish = false, + typoCorrectionOffsetScore = 3000, + omissionSearchOffsetScore = 1900, + beamWidth = 20, + candidateSegmentCollector = segments, + ) + .candidates + assertTrue(baselineCandidates.isNotEmpty()) + + val orderedCandidates = candidateOrderRepository.applyOrderFromSnapshot( + input = input, + candidates = baselineCandidates, + candidateSegmentsByString = segments, + ) + + assertEquals(baselineCandidates.first().string, orderedCandidates.first().string) + println( + "CANDIDATE_ORDER_PRODUCTION_REGRESSION " + + "input=$input " + + "baselineFirst=${baselineCandidates.first().string} " + + "orderedFirst=${orderedCandidates.first().string} " + + "baselineSegments=${segments[baselineCandidates.first().string]}", + ) + } finally { + candidateOrderRepository.deleteRule("ひ", CandidateOrderScope.LEXICAL_UNIT) + if (originalLexicalOrder.isNotEmpty()) { + candidateOrderRepository.saveOrder( + "ひ", + originalLexicalOrder, + CandidateOrderScope.LEXICAL_UNIT, + ) + } + } + } + + private fun configureAccessibilityInspection() { + val automation = instrumentation.uiAutomation + val info = automation.serviceInfo + info.flags = info.flags or + AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS or + AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS + automation.serviceInfo = info + } + + private fun flickLeft(keyId: String): Boolean { + val bounds = awaitVisibleBounds(keyId) + val start = bounds.center() + val end = PointF(start.x - bounds.width() * 0.70f, start.y) + val downTime = SystemClock.uptimeMillis() + var injected = inject(downTime, MotionEvent.ACTION_DOWN, start) + SystemClock.sleep(16) + injected = inject(downTime, MotionEvent.ACTION_MOVE, end) && injected + SystemClock.sleep(16) + injected = inject(downTime, MotionEvent.ACTION_UP, end) && injected + SystemClock.sleep(100) + return injected + } + + private fun tap(point: PointF): Boolean { + val downTime = SystemClock.uptimeMillis() + var injected = inject(downTime, MotionEvent.ACTION_DOWN, point) + SystemClock.sleep(32) + injected = inject(downTime, MotionEvent.ACTION_UP, point) && injected + return injected + } + + private fun inject(downTime: Long, action: Int, point: PointF): Boolean { + val event = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + action, + point.x, + point.y, + 0, + ).apply { + source = InputDevice.SOURCE_TOUCHSCREEN + } + return instrumentation.uiAutomation.injectInputEvent(event, true).also { + event.recycle() + } + } + + private fun awaitVisibleBounds(idName: String): Rect { + val deadline = SystemClock.uptimeMillis() + 8_000L + while (SystemClock.uptimeMillis() < deadline) { + findVisibleNodeById(idName)?.let { node -> + return Rect().also(node::getBoundsInScreen) + } + SystemClock.sleep(50) + } + error("Timed out waiting for visible id=$idName") + } + + private fun awaitVisibleText(text: String): Rect { + val deadline = SystemClock.uptimeMillis() + 8_000L + while (SystemClock.uptimeMillis() < deadline) { + for (window in instrumentation.uiAutomation.windows) { + val root = window.root ?: continue + val found = findDescendant(root) { node -> + node.isVisibleToUser && node.text?.toString() == text + } + if (found != null) { + return Rect().also(found::getBoundsInScreen) + } + } + SystemClock.sleep(50) + } + error("Timed out waiting for visible text=$text") + } + + private fun awaitVisibleCandidates(): List> { + val deadline = SystemClock.uptimeMillis() + 8_000L + while (SystemClock.uptimeMillis() < deadline) { + val recycler = findVisibleNodeById("suggestion_recycler_view") + val candidates = buildList { + if (recycler != null) { + for (index in 0 until recycler.childCount) { + val child = recycler.getChild(index) ?: continue + val textNode = findDescendant(child) { node -> + node.viewIdResourceName + ?.endsWith(":id/suggestion_item_text_view") == true + } ?: continue + val text = textNode.text?.toString().orEmpty().trim() + if (text.isNotEmpty()) { + add(text to Rect().also(textNode::getBoundsInScreen)) + } + } + } + } + if (candidates.isNotEmpty()) return candidates + SystemClock.sleep(50) + } + error("Timed out waiting for candidates") + } + + private fun findVisibleNodeById(idName: String): AccessibilityNodeInfo? { + for (window in instrumentation.uiAutomation.windows) { + val root = window.root ?: continue + val found = findDescendant(root) { node -> + node.isVisibleToUser && + node.viewIdResourceName?.endsWith(":id/$idName") == true + } + if (found != null) return found + } + return null + } + + private fun findDescendant( + root: AccessibilityNodeInfo, + predicate: (AccessibilityNodeInfo) -> Boolean, + ): AccessibilityNodeInfo? { + val queue = ArrayDeque() + queue.add(root) + while (queue.isNotEmpty()) { + val node = queue.removeFirst() + if (predicate(node)) return node + for (index in 0 until node.childCount) { + node.getChild(index)?.let(queue::addLast) + } + } + return null + } + + private fun Rect.center(): PointF = PointF(exactCenterX(), exactCenterY()) +} diff --git a/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/converter/ConversionPerformanceInstrumentedProbeTest.kt b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/converter/ConversionPerformanceInstrumentedProbeTest.kt index ab8ffc1ad..7040544a7 100644 --- a/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/converter/ConversionPerformanceInstrumentedProbeTest.kt +++ b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/converter/ConversionPerformanceInstrumentedProbeTest.kt @@ -1,16 +1,29 @@ package com.kazumaproject.markdownhelperkeyboard.converter import android.content.Context +import android.os.Debug +import android.os.SystemClock +import android.view.ContextThemeWrapper +import androidx.room.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import com.kazumaproject.custom_keyboard.data.KeyCharacterCase +import com.kazumaproject.custom_keyboard.layout.KeyboardDefaultLayouts +import com.kazumaproject.custom_keyboard.view.FlickKeyboardView import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate +import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CandidateConversionSegment import com.kazumaproject.markdownhelperkeyboard.converter.engine.KanaKanjiEngine +import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase import com.kazumaproject.markdownhelperkeyboard.ime_service.di.KanaKanjiEngineEntryPoint +import com.kazumaproject.markdownhelperkeyboard.candidate_order.database.CandidateOrderOverrideEntity +import com.kazumaproject.markdownhelperkeyboard.candidate_order.model.CandidateOrderScope +import com.kazumaproject.markdownhelperkeyboard.repository.CandidateOrderOverrideRepository import com.kazumaproject.markdownhelperkeyboard.repository.UserDictionaryRepository import dagger.hilt.android.EntryPointAccessors import kotlinx.coroutines.runBlocking import org.junit.Assume.assumeTrue +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith import java.io.File @@ -19,6 +32,85 @@ import kotlin.system.measureNanoTime @RunWith(AndroidJUnit4::class) class ConversionPerformanceInstrumentedProbeTest { + @Test + fun compareConversionWithDefinedAndUppercaseKeyPresentation() = runBlocking { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val arguments = InstrumentationRegistry.getArguments() + assumeTrue(arguments.getString("keyCaseConversionProbe") == "true") + val iterations = arguments.getString("keyCaseConversionIterations")?.toIntOrNull() ?: 30 + val input = "わたしはきのうともだちとえきまえであいました" + val context = ApplicationProvider.getApplicationContext() + val entryPoint = EntryPointAccessors.fromApplication( + context.applicationContext, + KanaKanjiEngineEntryPoint::class.java, + ) + val engine = entryPoint.kanaKanjiEngine() + val repository = entryPoint.userDictionaryRepository() + lateinit var keyboard: FlickKeyboardView + + instrumentation.runOnMainSync { + keyboard = FlickKeyboardView( + ContextThemeWrapper( + context, + com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar, + ), + ).apply { + setKeyboard(KeyboardDefaultLayouts.createQwertyTemplateLayout()) + } + } + + repeat(30) { + engine.convertForProbe(input, repository) + } + + val asDefinedNanos = LongArray(iterations) + val uppercaseNanos = LongArray(iterations) + var asDefinedCandidates: List = emptyList() + var uppercaseCandidates: List = emptyList() + + suspend fun measure( + characterCase: KeyCharacterCase, + samples: LongArray, + index: Int, + capture: (List) -> Unit, + ) { + instrumentation.runOnMainSync { + keyboard.setKeyCharacterCase(characterCase) + } + samples[index] = measureNanoTime { + capture(engine.convertForProbe(input, repository)) + } + } + + repeat(iterations) { index -> + if (index % 2 == 0) { + measure(KeyCharacterCase.AS_DEFINED, asDefinedNanos, index) { + asDefinedCandidates = it + } + measure(KeyCharacterCase.UPPERCASE, uppercaseNanos, index) { + uppercaseCandidates = it + } + } else { + measure(KeyCharacterCase.UPPERCASE, uppercaseNanos, index) { + uppercaseCandidates = it + } + measure(KeyCharacterCase.AS_DEFINED, asDefinedNanos, index) { + asDefinedCandidates = it + } + } + } + + assertEquals(asDefinedCandidates, uppercaseCandidates) + val asDefinedAverageUs = asDefinedNanos.average() / 1_000.0 + val uppercaseAverageUs = uppercaseNanos.average() / 1_000.0 + println( + "KEY_CASE_CONVERSION_ISOLATION iterations=$iterations " + + "asDefinedAverageUs=$asDefinedAverageUs " + + "uppercaseAverageUs=$uppercaseAverageUs " + + "ratio=${uppercaseAverageUs / asDefinedAverageUs} " + + "candidateCount=${uppercaseCandidates.size}", + ) + } @Test fun measureProductionDiConversionPerformance() = runBlocking { @@ -28,6 +120,8 @@ class ConversionPerformanceInstrumentedProbeTest { val label = arguments.getString("conversionPerfLabel") ?: "instrumented" val warmup = arguments.getString("conversionPerfWarmup")?.toIntOrNull() ?: 2 val iterations = arguments.getString("conversionPerfIterations")?.toIntOrNull() ?: 10 + val collectCandidateSegments = + arguments.getString("conversionPerfCollectCandidateSegments") == "true" val inputs = listOf( "きょう", "きょうはいいてんきですね", @@ -52,24 +146,40 @@ class ConversionPerformanceInstrumentedProbeTest { userDictionaryRepository = entryPoint.userDictionaryRepository() } + var lastCandidateSegments: Map> = emptyMap() + suspend fun convert(input: String): List { + val segmentCollector: MutableMap>? = + if (collectCandidateSegments) LinkedHashMap() else null + return engine.convertForProbe( + input = input, + userDictionaryRepository = userDictionaryRepository, + candidateSegmentCollector = segmentCollector, + ).also { + if (segmentCollector != null) lastCandidateSegments = segmentCollector + } + } + val firstResults = LinkedHashMap>() val firstConversionNs = inputs.associateWith { input -> measureNanoTime { - firstResults[input] = engine.convertForProbe(input, userDictionaryRepository) + firstResults[input] = convert(input) } } repeat(warmup) { inputs.forEach { input -> - engine.convertForProbe(input, userDictionaryRepository) + convert(input) } } + Runtime.getRuntime().gc() + SystemClock.sleep(500) + val pssBeforeKb = Debug.getPss() val warmResults = LinkedHashMap>() val warmConversionNs = inputs.associateWith { input -> measureNanoTime { repeat(iterations) { - warmResults[input] = engine.convertForProbe(input, userDictionaryRepository) + warmResults[input] = convert(input) } } } @@ -78,14 +188,23 @@ class ConversionPerformanceInstrumentedProbeTest { var continuousResult: List = emptyList() val continuousElapsedNs = measureNanoTime { repeat(iterations) { - continuousResult = engine.convertForProbe(continuousInput, userDictionaryRepository) + continuousResult = convert(continuousInput) } } + Runtime.getRuntime().gc() + SystemClock.sleep(500) + val pssAfterKb = Debug.getPss() val report = buildString { appendLine("label=$label") appendLine("warmup=$warmup") appendLine("iterations=$iterations") + appendLine("collectCandidateSegments=$collectCandidateSegments") + appendLine("segmentCandidateCount=${lastCandidateSegments.size}") + appendLine("segmentNodeCount=${lastCandidateSegments.values.sumOf { it.size }}") + appendLine("pssBeforeKb=$pssBeforeKb") + appendLine("pssAfterKb=$pssAfterKb") + appendLine("pssDeltaKb=${pssAfterKb - pssBeforeKb}") appendLine("entryPointResolveNs=$entryPointResolveNs") appendLine("productionSingletonResolveNs=$productionSingletonResolveNs") appendLine("fingerprint=${warmResults.fingerprint()}") @@ -116,9 +235,252 @@ class ConversionPerformanceInstrumentedProbeTest { println(report) } + @Test + fun measureCandidateOrderScalePerformance() = runBlocking { + val arguments = InstrumentationRegistry.getArguments() + assumeTrue(arguments.getString("candidateOrderScaleProbe") == "true") + + val scale = arguments.getString("candidateOrderScale")?.toIntOrNull() ?: 100 + val conversionWarmup = + arguments.getString("candidateOrderConversionWarmup")?.toIntOrNull() ?: 10 + val conversionIterations = + arguments.getString("candidateOrderConversionIterations")?.toIntOrNull() ?: 100 + val sortIterations = + arguments.getString("candidateOrderSortIterations")?.toIntOrNull() ?: 10_000 + val candidatesPerGroup = + arguments.getString("candidateOrderCandidatesPerGroup")?.toIntOrNull() ?: 3 + require(scale in 1..10_000) + require(candidatesPerGroup in 3..200) + + val context = ApplicationProvider.getApplicationContext() + val input = "ひを" + lateinit var engine: KanaKanjiEngine + lateinit var userDictionaryRepository: UserDictionaryRepository + + suspend fun convertWithSegments(): Pair< + List, + Map>, + > { + val segments = LinkedHashMap>() + val candidates = engine.convertForProbe( + input = input, + userDictionaryRepository = userDictionaryRepository, + candidateSegmentCollector = segments, + ) + return candidates to segments + } + + val memoryProbeCandidates = listOf( + Candidate("日を", 1, 2u, 0), + Candidate("火を", 1, 2u, 1), + Candidate("陽を", 1, 2u, 2), + ) + val memoryProbeSegments = mapOf( + "日を" to listOf( + CandidateConversionSegment(0, 1, "日"), + CandidateConversionSegment(1, 2, "を"), + ), + "火を" to listOf( + CandidateConversionSegment(0, 1, "火"), + CandidateConversionSegment(1, 2, "を"), + ), + "陽を" to listOf( + CandidateConversionSegment(0, 1, "陽"), + CandidateConversionSegment(1, 2, "を"), + ), + ) + + val databaseName = "candidate-order-scale-${scale}-${System.nanoTime()}.db" + val database = Room.databaseBuilder( + context, + AppDatabase::class.java, + databaseName, + ).build() + try { + val dao = database.candidateOrderOverrideDao() + val repository = CandidateOrderOverrideRepository(dao) + + val now = System.currentTimeMillis() + val entityCount = scale * candidatesPerGroup + val insertNs = measureNanoTime { + val batch = ArrayList(10_000) + suspend fun flushBatch() { + if (batch.isEmpty()) return + dao.insertAll(batch) + batch.clear() + } + for (groupIndex in 0 until scale) { + val suffix = groupIndex.toString().padStart(5, '0') + val groupInput = if (groupIndex == 0) "ひ" else "べんちまっぷ$suffix" + for (candidateIndex in 0 until candidatesPerGroup) { + val output = when { + groupIndex == 0 && candidateIndex == 0 -> "火" + groupIndex == 0 && candidateIndex == 1 -> "日" + groupIndex == 0 && candidateIndex == 2 -> "陽" + else -> "候補${candidateIndex.toString().padStart(3, '0')}-$suffix" + } + batch += CandidateOrderOverrideEntity( + input = groupInput, + scope = CandidateOrderScope.LEXICAL_UNIT.name, + candidate = output, + rank = candidateIndex + 1, + createdAt = now, + updatedAt = now, + ) + if (batch.size >= 10_000) flushBatch() + } + } + flushBatch() + } + + // Measure only the conversion-time lookup/cache allocation. The test database is + // file-backed like production, and insertion pages are already present at baseline. + dao.findByInputs(listOf("__measurement_warmup__")) + forceGcForMeasurement() + val baselinePssKb = Debug.getPss() + val baselineHeapKb = usedJavaHeapKb() + + lateinit var firstOrdered: List + val firstSnapshotAndSortNs = measureNanoTime { + firstOrdered = repository.applyOrderFromSnapshot( + input = input, + candidates = memoryProbeCandidates, + candidateSegmentsByString = memoryProbeSegments, + ) + } + assertEquals("火を", firstOrdered.first().string) + + val ambiguousLongInputCandidates = listOf( + Candidate("人多すぎ", 1, 6u, 0), + Candidate("火と多すぎ", 1, 6u, 1), + ) + val ambiguousLongInputSegments = mapOf( + "人多すぎ" to listOf( + CandidateConversionSegment(0, 2, "人"), + CandidateConversionSegment(2, 6, "多すぎ"), + ), + "火と多すぎ" to listOf( + CandidateConversionSegment(0, 1, "火"), + CandidateConversionSegment(1, 2, "と"), + CandidateConversionSegment(2, 6, "多すぎ"), + ), + ) + val safelyOrdered = repository.applyOrderFromSnapshot( + input = "ひとおおすぎ", + candidates = ambiguousLongInputCandidates, + candidateSegmentsByString = ambiguousLongInputSegments, + ) + assertEquals("人多すぎ", safelyOrdered.first().string) + + forceGcForMeasurement() + val snapshotPssKb = Debug.getPss() + val snapshotHeapKb = usedJavaHeapKb() + + val entryPoint = EntryPointAccessors.fromApplication( + context.applicationContext, + KanaKanjiEngineEntryPoint::class.java, + ) + engine = entryPoint.kanaKanjiEngine() + userDictionaryRepository = entryPoint.userDictionaryRepository() + repeat(conversionWarmup) { convertWithSegments() } + val (referenceCandidates, referenceSegments) = convertWithSegments() + + var lastSorted: List = emptyList() + val sortElapsedNs = measureNanoTime { + repeat(sortIterations) { + lastSorted = repository.applyOrderFromSnapshot( + input = input, + candidates = referenceCandidates, + candidateSegmentsByString = referenceSegments, + ) + } + } + assertEquals("火を", lastSorted.first().string) + + suspend fun convertAndOrder(): List { + val (candidates, segments) = convertWithSegments() + return repository.applyOrderFromSnapshot( + input = input, + candidates = candidates, + candidateSegmentsByString = segments, + ) + } + + repeat(conversionWarmup) { + convertWithSegments() + convertAndOrder() + } + val conversionOnlyNs = LongArray(conversionIterations) + val conversionAndOrderNs = LongArray(conversionIterations) + repeat(conversionIterations) { index -> + if (index % 2 == 0) { + conversionOnlyNs[index] = measureNanoTime { convertWithSegments() } + conversionAndOrderNs[index] = measureNanoTime { convertAndOrder() } + } else { + conversionAndOrderNs[index] = measureNanoTime { convertAndOrder() } + conversionOnlyNs[index] = measureNanoTime { convertWithSegments() } + } + } + + val conversionOnlyAvgUs = conversionOnlyNs.average() / 1_000.0 + val conversionAndOrderAvgUs = conversionAndOrderNs.average() / 1_000.0 + val report = buildString { + appendLine("scale=$scale") + appendLine("overrideGroupCount=$scale") + appendLine("overrideEntityCount=$entityCount") + appendLine("scope=${CandidateOrderScope.LEXICAL_UNIT.name}") + appendLine("ambiguousLongInputFirst=${safelyOrdered.first().string}") + appendLine("candidatesPerGroup=$candidatesPerGroup") + appendLine("databaseMode=file") + appendLine("conversionWarmup=$conversionWarmup") + appendLine("conversionIterations=$conversionIterations") + appendLine("sortIterations=$sortIterations") + appendLine("insertMs=${insertNs / 1_000_000.0}") + appendLine("firstSnapshotAndSortMs=${firstSnapshotAndSortNs / 1_000_000.0}") + appendLine("sortOnlyAvgUs=${sortElapsedNs / sortIterations / 1_000.0}") + appendLine("conversionOnlyAvgUs=$conversionOnlyAvgUs") + appendLine("conversionAndOrderAvgUs=$conversionAndOrderAvgUs") + appendLine( + "conversionOrderDeltaUs=${conversionAndOrderAvgUs - conversionOnlyAvgUs}", + ) + appendLine("baselinePssKb=$baselinePssKb") + appendLine("snapshotPssKb=$snapshotPssKb") + appendLine("snapshotPssDeltaKb=${snapshotPssKb - baselinePssKb}") + appendLine("baselineHeapKb=$baselineHeapKb") + appendLine("snapshotHeapKb=$snapshotHeapKb") + appendLine("snapshotHeapDeltaKb=${snapshotHeapKb - baselineHeapKb}") + appendLine("candidateCount=${referenceCandidates.size}") + appendLine( + "segmentCandidateCount=${referenceSegments.size}", + ) + } + + val outputDir = File(context.filesDir, "conversion-perf").apply { mkdirs() } + File(outputDir, "candidate-order-scale-$scale.txt").writeText(report) + println(report) + } finally { + database.close() + context.deleteDatabase(databaseName) + } + } + + private fun forceGcForMeasurement() { + repeat(3) { + Runtime.getRuntime().gc() + System.runFinalization() + SystemClock.sleep(300) + } + } + + private fun usedJavaHeapKb(): Long { + val runtime = Runtime.getRuntime() + return (runtime.totalMemory() - runtime.freeMemory()) / 1024L + } + private suspend fun KanaKanjiEngine.convertForProbe( input: String, userDictionaryRepository: UserDictionaryRepository, + candidateSegmentCollector: MutableMap>? = null, ): List = getCandidatesWithBunsetsuSeparation( input = input, @@ -136,6 +498,7 @@ class ConversionPerformanceInstrumentedProbeTest { typoCorrectionOffsetScore = 3000, omissionSearchOffsetScore = 1900, beamWidth = 20, + candidateSegmentCollector = candidateSegmentCollector, ).candidates private fun Map>.fingerprint(): String { diff --git a/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/converter/EmojiCategoryInstrumentedTest.kt b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/converter/EmojiCategoryInstrumentedTest.kt new file mode 100644 index 000000000..6e3e63874 --- /dev/null +++ b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/converter/EmojiCategoryInstrumentedTest.kt @@ -0,0 +1,73 @@ +package com.kazumaproject.markdownhelperkeyboard.converter + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.kazumaproject.data.emoji.EmojiCategory +import com.kazumaproject.data.emoticon.EmoticonCategory +import com.kazumaproject.domain.categorizeEmoji +import com.kazumaproject.domain.toEmoticonCategory +import com.kazumaproject.markdownhelperkeyboard.ime_service.di.KanaKanjiEngineEntryPoint +import dagger.hilt.android.EntryPointAccessors +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +@RunWith(AndroidJUnit4::class) +class EmojiCategoryInstrumentedTest { + + @Test + fun bundledEmojiDictionaryUsesPublishedMetadataOnDevice() { + val context = ApplicationProvider.getApplicationContext() + val engine = EntryPointAccessors.fromApplication( + context.applicationContext, + KanaKanjiEngineEntryPoint::class.java, + ).kanaKanjiEngine() + + val emojis = engine.getSymbolEmojiCandidates() + val emoticons = engine.getSymbolEmoticonCandidates() + val emojiCounts = EmojiCategory.entries.associateWith { category -> + emojis.count { it.category == category } + } + val emoticonCounts = EmoticonCategory.entries.associateWith { category -> + emoticons.count { it.category == category } + } + + val report = buildString { + appendLine("device=${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL} API ${android.os.Build.VERSION.SDK_INT}") + appendLine("emojiTotal=${emojis.size}") + EmojiCategory.entries.forEach { appendLine("emoji.${it.name}=${emojiCounts.getValue(it)}") } + emojis.asSequence() + .filter { it.category == EmojiCategory.UNKNOWN } + .take(30) + .forEach { emoji -> + appendLine( + "emoji.unknown=${emoji.symbol} [" + + emoji.symbol.codePoints().toArray() + .joinToString(" ") { "U+%04X".format(it) } + + "]" + ) + } + appendLine("emoticonTotal=${emoticons.size}") + EmoticonCategory.entries.forEach { + appendLine("emoticon.${it.name}=${emoticonCounts.getValue(it)}") + } + } + val reportFile = File(context.filesDir, "emoji-category-device-report.txt") + reportFile.writeText(report) + println(report) + + assertEquals(1_902, emojis.size) + assertEquals(436, emoticons.size) + assertEquals(EmojiCategory.FLAGS, categorizeEmoji("🇯🇵")) + assertEquals(EmojiCategory.SYMBOLS, categorizeEmoji("#️⃣")) + assertEquals(EmojiCategory.SMILEYS_EMOTION, categorizeEmoji("❤️‍🔥")) + assertEquals(EmoticonCategory.SMILE, ":-)".toEmoticonCategory()) + assertEquals(EmoticonCategory.SADNESS, "(TT)".toEmoticonCategory()) + assertEquals(4, emojiCounts.getValue(EmojiCategory.UNKNOWN)) + assertTrue(emojiCounts.getValue(EmojiCategory.UNKNOWN) < emojis.size) + assertTrue(emoticonCounts.getValue(EmoticonCategory.UNKNOWN) < emoticons.size) + } +} diff --git a/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/converter/IncrementalConversionSessionPerformanceInstrumentedTest.kt b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/converter/IncrementalConversionSessionPerformanceInstrumentedTest.kt index 38bd07fbd..dd3cad72c 100644 --- a/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/converter/IncrementalConversionSessionPerformanceInstrumentedTest.kt +++ b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/converter/IncrementalConversionSessionPerformanceInstrumentedTest.kt @@ -38,6 +38,11 @@ import kotlin.system.measureNanoTime @RunWith(AndroidJUnit4::class) class IncrementalConversionSessionPerformanceInstrumentedTest { + private var probeOmissionSearchEnabled = false + private var probeFlickTypoCorrectionEnabled = false + private var probeOptionalDictionariesEnabled = false + private var probeNBest = 4 + @Test fun compactCoreDictionariesMatchSerializedAssetsOnDevice() { val arguments = InstrumentationRegistry.getArguments() @@ -589,13 +594,33 @@ class IncrementalConversionSessionPerformanceInstrumentedTest { val bunsetsuSeparation = arguments.getString("conversionPerfBunsetsu") ?.toBooleanStrictOrNull() ?: true - val prefixes = phrase.indices.map { phrase.substring(0, it + 1) } + probeOmissionSearchEnabled = arguments.getString("conversionPerfOmission") == "true" + probeFlickTypoCorrectionEnabled = + arguments.getString("conversionPerfFlickTypoCorrection") == "true" + probeOptionalDictionariesEnabled = + arguments.getString("conversionPerfMozcOptional") == "true" + probeNBest = arguments.getString("conversionPerfNBest")?.toIntOrNull() ?: 4 + val strictCandidateParity = arguments.getString("conversionPerfStrictParity") != "false" + val requestedPrefixLengths = arguments.getString("conversionPerfPrefixLengths") + ?.split(',') + ?.mapNotNull(String::toIntOrNull) + ?.filter { it in 1..phrase.length } + ?.distinct() + ?.takeIf { it.isNotEmpty() } + val prefixLengths = requestedPrefixLengths ?: (1..phrase.length).toList() + val prefixes = prefixLengths.map { phrase.substring(0, it) } val context = ApplicationProvider.getApplicationContext() val entryPoint = EntryPointAccessors.fromApplication( context.applicationContext, KanaKanjiEngineEntryPoint::class.java, ) val engine = entryPoint.kanaKanjiEngine() + if ( + probeOptionalDictionariesEnabled && + !engine.isInitialOptionalDictionaryStateLoaded() + ) { + engine.initializeOptionalDictionaryStateFromCurrentSources() + } val repository = entryPoint.userDictionaryRepository() val disableSystemNgramForProbe = arguments.getString("conversionPerfSystemNgram") == "false" @@ -622,7 +647,21 @@ class IncrementalConversionSessionPerformanceInstrumentedTest { bunsetsuSeparation, collectFingerprints = true, ) - assertEquals(legacyParity.fingerprints, incrementalParity.fingerprints) + val candidateParityExact = legacyParity.fingerprints == incrementalParity.fingerprints + if (strictCandidateParity) { + prefixes.indices.forEach { index -> + assertEquals( + "prefix=${prefixes[index]}", + legacyParity.fingerprints[index], + incrementalParity.fingerprints[index], + ) + } + } else { + assertEquals( + legacyParity.fingerprints.map { it.take(2) }, + incrementalParity.fingerprints.map { it.take(2) }, + ) + } repeat(warmups) { runSequence( @@ -711,14 +750,17 @@ class IncrementalConversionSessionPerformanceInstrumentedTest { appendLine("device=${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL} API ${android.os.Build.VERSION.SDK_INT}") appendLine("phrase=$phrase") appendLine("prefixCount=${prefixes.size}") + appendLine("prefixLengths=${prefixLengths.joinToString(",")}") appendLine("warmups=$warmups") appendLine("iterations=$iterations") appendLine("bunsetsuSeparation=$bunsetsuSeparation") appendLine("mode=$mode") - appendLine("optionalDictionaries=false") - appendLine("typoCorrection=false") - appendLine("omissionSearch=false") - appendLine("candidateParityEveryPrefix=true") + appendLine("optionalDictionaries=$probeOptionalDictionariesEnabled") + appendLine("typoCorrection=$probeFlickTypoCorrectionEnabled") + appendLine("omissionSearch=$probeOmissionSearchEnabled") + appendLine("nBest=$probeNBest") + appendLine("candidateParityEveryPrefix=$candidateParityExact") + appendLine("top2CandidateParityEveryPrefix=true") appendLine("legacySessionMs=${legacySession.asMilliseconds()}") appendLine("incrementalSessionMs=${incrementalSession.asMilliseconds()}") appendLine("legacyCommandMs=${legacyCommand.asMilliseconds()}") @@ -1068,16 +1110,16 @@ class IncrementalConversionSessionPerformanceInstrumentedTest { input = input, mode = mode, bunsetsuSeparation = bunsetsuSeparation, - n = 4, - mozcUtPersonName = false, - mozcUtPlaces = false, - mozcUtWiki = false, - mozcUtNeologd = false, - mozcUtWeb = false, + n = probeNBest, + mozcUtPersonName = probeOptionalDictionariesEnabled, + mozcUtPlaces = probeOptionalDictionariesEnabled, + mozcUtWiki = probeOptionalDictionariesEnabled, + mozcUtNeologd = probeOptionalDictionariesEnabled, + mozcUtWeb = probeOptionalDictionariesEnabled, userDictionaryRepository = repository, learnRepository = null, - omissionSearchEnabled = false, - typoCorrectionJapaneseFlickEnabled = false, + omissionSearchEnabled = probeOmissionSearchEnabled, + typoCorrectionJapaneseFlickEnabled = probeFlickTypoCorrectionEnabled, typoCorrectionQwertyEnglishEnabled = false, typoCorrectionOffsetScore = 3000, omissionSearchOffsetScore = 1900, diff --git a/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/converter/JapaneseNumberConversionInstrumentedTest.kt b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/converter/JapaneseNumberConversionInstrumentedTest.kt new file mode 100644 index 000000000..203aa42a9 --- /dev/null +++ b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/converter/JapaneseNumberConversionInstrumentedTest.kt @@ -0,0 +1,270 @@ +package com.kazumaproject.markdownhelperkeyboard.converter + +import android.content.Context +import android.os.Build +import android.os.Debug +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate +import com.kazumaproject.markdownhelperkeyboard.converter.engine.KanaKanjiEngine +import com.kazumaproject.markdownhelperkeyboard.ime_service.di.KanaKanjiEngineEntryPoint +import com.kazumaproject.markdownhelperkeyboard.repository.UserDictionaryRepository +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.util.Locale + +@RunWith(AndroidJUnit4::class) +class JapaneseNumberConversionInstrumentedTest { + + @Test + fun verifyCorrectnessAndMeasureProductionPaths() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + val entryPoint = EntryPointAccessors.fromApplication( + context.applicationContext, + KanaKanjiEngineEntryPoint::class.java, + ) + val engine = entryPoint.kanaKanjiEngine() + val repository = entryPoint.userDictionaryRepository() + + val forbiddenByInput = linkedMapOf( + "よしよし" to setOf("4444", "4444", "8383", "8383", "四千四百四十四"), + "しせん" to setOf("4000", "4000", "4,000", "四千"), + "くちょう" to setOf("9000000000000", "9000000000000", "九兆", "9兆"), + "ちょうせん" to setOf("1000000001000", "一兆一千"), + "ごご" to setOf("55", "55", "五十五"), + "さんご" to setOf("35", "35", "三十五"), + "しえん" to setOf("4円", "4円"), + "しじ" to setOf("4時", "4時"), + "いちいち" to setOf("11", "11", "十一"), + "さんさん" to setOf("33", "33", "三十三"), + "ろくろく" to setOf("66", "66", "六十六"), + "よせん" to setOf("4000", "4000", "4,000", "四千"), + "くせん" to setOf("9000", "9000", "9,000", "九千"), + ) + + val englishKanaResults = linkedMapOf>() + forbiddenByInput.forEach { (input, forbidden) -> + val candidates = engine.getCandidatesEnglishKana(input) + englishKanaResults[input] = candidates + assertEquals(input, candidates.first().string) + assertTrue( + "$input unexpectedly generated ${candidates.map { it.string }}", + candidates.none { it.string in forbidden }, + ) + } + + val validInputs = linkedMapOf( + "よんせん" to setOf("4000", "四千"), + "きゅうちょう" to setOf("9000000000000", "九兆"), + "さんにん" to setOf("3人"), + "にじゅっぷん" to setOf("20分"), + "ろくじ" to setOf("6時"), + "にじゅうよじ" to setOf("24時"), + "くじ" to setOf("9時"), + "いっちょう" to setOf("1000000000000", "一兆"), + "しじゅう" to setOf("40", "四十"), + "じゅうよ" to setOf("14", "十四"), + "よにん" to setOf("4人"), + "よえん" to setOf("4円"), + "くえん" to setOf("9円"), + "くにん" to setOf("9人"), + "いっぷん" to setOf("1分"), + "ろっぷん" to setOf("6分"), + "はっぷん" to setOf("8分"), + ) + validInputs.forEach { (input, required) -> + val candidates = engine.getCandidatesEnglishKana(input) + englishKanaResults[input] = candidates + val values = candidates.mapTo(hashSetOf()) { it.string } + assertTrue("$input missing $required from $values", values.containsAll(required)) + } + + val standardResults = linkedMapOf>() + forbiddenByInput.forEach { (input, forbidden) -> + val candidates = engine.convertOriginal(input, repository) + standardResults[input] = candidates + assertTrue( + "$input unexpectedly generated ${candidates.map { it.string }}", + candidates.none { it.string in forbidden }, + ) + } + + val benchmarkCorpus = (forbiddenByInput.keys + validInputs.keys).toList() + val englishKanaBenchmark = measureConversions( + label = "englishKana", + corpus = benchmarkCorpus, + warmupIterations = 1_000, + measuredIterations = 20_000, + ) { input -> + engine.getCandidatesEnglishKana(input) + } + val standardBenchmark = measureConversions( + label = "standardDictionary", + corpus = benchmarkCorpus, + warmupIterations = 30, + measuredIterations = 500, + ) { input -> + engine.convertOriginal(input, repository) + } + + val report = buildString { + appendLine("device=${Build.MANUFACTURER} ${Build.MODEL}") + appendLine("api=${Build.VERSION.SDK_INT}") + appendLine("abi=${Build.SUPPORTED_ABIS.joinToString()}") + appendLine("correctness=PASS") + appendLine("forbiddenCaseCount=${forbiddenByInput.size}") + appendLine("validCaseCount=${validInputs.size}") + appendLine(englishKanaBenchmark.asReportLine()) + appendLine(standardBenchmark.asReportLine()) + appendLine("englishKanaCandidates") + englishKanaResults.forEach { (input, candidates) -> + appendLine("$input\t${candidates.take(16).joinToString(" / ") { it.string }}") + } + appendLine("standardCandidates") + standardResults.forEach { (input, candidates) -> + appendLine("$input\t${candidates.take(16).joinToString(" / ") { it.string }}") + } + } + + val outputDir = File(context.filesDir, "conversion-perf").apply { mkdirs() } + File(outputDir, "japanese-number-conversion.txt").writeText(report) + println("JAPANESE_NUMBER_CONVERSION_REPORT\n$report") + } + + private suspend fun KanaKanjiEngine.convertOriginal( + input: String, + repository: UserDictionaryRepository, + ): List = getCandidatesOriginal( + input = input, + n = 8, + mozcUtPersonName = false, + mozcUTPlaces = false, + mozcUTWiki = false, + mozcUTNeologd = false, + mozcUTWeb = false, + userDictionaryRepository = repository, + learnRepository = null, + isOmissionSearchEnable = false, + enableTypoCorrectionJapaneseFlick = false, + enableTypoCorrectionQwertyEnglish = false, + typoCorrectionOffsetScore = 3_000, + omissionSearchOffsetScore = 3_000, + beamWidth = 20, + ) + + private suspend fun measureConversions( + label: String, + corpus: List, + warmupIterations: Int, + measuredIterations: Int, + convert: suspend (String) -> List, + ): BenchmarkResult { + var blackHole = 0L + repeat(warmupIterations) { index -> + val candidates = convert(corpus[index % corpus.size]) + blackHole += candidates.size + (candidates.firstOrNull()?.string?.length ?: 0) + } + + forceGc() + val baseline = memorySnapshot() + val allocatedBefore = allocatedBytes() + val samplesNs = LongArray(measuredIterations) + + repeat(measuredIterations) { index -> + val startedNs = System.nanoTime() + val candidates = convert(corpus[index % corpus.size]) + samplesNs[index] = System.nanoTime() - startedNs + blackHole += candidates.size + (candidates.firstOrNull()?.string?.length ?: 0) + } + + val allocatedAfter = allocatedBytes() + val immediate = memorySnapshot() + forceGc() + val settled = memorySnapshot() + val sortedNs = samplesNs.sortedArray() + + return BenchmarkResult( + label = label, + iterations = measuredIterations, + averageUs = samplesNs.average() / 1_000.0, + p50Us = sortedNs.percentile(0.50) / 1_000.0, + p95Us = sortedNs.percentile(0.95) / 1_000.0, + allocatedBytesPerCall = allocatedBefore?.let { before -> + allocatedAfter?.let { after -> (after - before).coerceAtLeast(0L).toDouble() / measuredIterations } + }, + baseline = baseline, + immediate = immediate, + settled = settled, + blackHole = blackHole, + ) + } + + private fun LongArray.percentile(fraction: Double): Long { + if (isEmpty()) return 0L + val index = ((size - 1) * fraction).toInt().coerceIn(indices) + return this[index] + } + + private fun allocatedBytes(): Long? = + Debug.getRuntimeStat("art.gc.bytes-allocated")?.toLongOrNull() + + private fun memorySnapshot(): MemorySnapshot { + val runtime = Runtime.getRuntime() + return MemorySnapshot( + javaHeapKb = (runtime.totalMemory() - runtime.freeMemory()) / 1024L, + totalPssKb = Debug.getPss().toLong(), + ) + } + + private fun forceGc() { + repeat(2) { + Runtime.getRuntime().gc() + System.runFinalization() + Thread.sleep(100) + } + } + + private data class MemorySnapshot( + val javaHeapKb: Long, + val totalPssKb: Long, + ) + + private data class BenchmarkResult( + val label: String, + val iterations: Int, + val averageUs: Double, + val p50Us: Double, + val p95Us: Double, + val allocatedBytesPerCall: Double?, + val baseline: MemorySnapshot, + val immediate: MemorySnapshot, + val settled: MemorySnapshot, + val blackHole: Long, + ) { + fun asReportLine(): String = String.format( + Locale.US, + "%s iterations=%d avgUs=%.3f p50Us=%.3f p95Us=%.3f " + + "allocatedBytesPerCall=%s " + + "heapBaselineKb=%d heapImmediateKb=%d heapSettledKb=%d " + + "pssBaselineKb=%d pssImmediateKb=%d pssSettledKb=%d blackHole=%d", + label, + iterations, + averageUs, + p50Us, + p95Us, + allocatedBytesPerCall?.let { String.format(Locale.US, "%.1f", it) } ?: "unavailable", + baseline.javaHeapKb, + immediate.javaHeapKb, + settled.javaHeapKb, + baseline.totalPssKb, + immediate.totalPssKb, + settled.totalPssKb, + blackHole, + ) + } +} diff --git a/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/ExactInputCandidatePromoterPerformanceInstrumentedTest.kt b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/ExactInputCandidatePromoterPerformanceInstrumentedTest.kt new file mode 100644 index 000000000..055bae3b5 --- /dev/null +++ b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/ExactInputCandidatePromoterPerformanceInstrumentedTest.kt @@ -0,0 +1,276 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.candidate + +import android.content.Context +import android.os.Debug +import android.os.SystemClock +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.util.Locale + +@RunWith(AndroidJUnit4::class) +class ExactInputCandidatePromoterPerformanceInstrumentedTest { + @Volatile + private var listSink: List? = null + + @Volatile + private var setSink: Set? = null + + @Volatile + private var wordListSink: List? = null + + @Test + fun measuresRuleAndCandidateScaleOnAndroidRuntime() { + val arguments = InstrumentationRegistry.getArguments() + assumeTrue(arguments.getString(PERFORMANCE_PROBE_ARGUMENT) == "true") + + warmUp() + val ruleResults = listOf(22, 10_000, 100_000).map(::measureRuleScale) + val candidateResults = listOf(10_000, 100_000).map(::measureCandidateScale) + + val report = buildString { + appendLine("EXACT_INPUT_PROMOTION_ANDROID_REPORT") + appendLine( + "device=${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL} " + + "api=${android.os.Build.VERSION.SDK_INT}", + ) + appendLine( + "rules\tsetBuildMs\tsetBuildAllocatedBytes\tsetRetainedHeapDeltaBytes\t" + + "hitP50Us\thitP95Us\thitAllocatedBytesPerCall\t" + + "missP50Us\tmissP95Us\tmissAllocatedBytesPerCall", + ) + ruleResults.forEach { appendLine(it.asTsv()) } + appendLine( + "candidates\tpromotionP50Ms\tpromotionP95Ms\t" + + "promotionAllocatedBytesPerCall\tbytesPerCandidate", + ) + candidateResults.forEach { appendLine(it.asTsv()) } + } + + val context = ApplicationProvider.getApplicationContext() + File(context.filesDir, REPORT_DIRECTORY).apply { mkdirs() } + .resolve(REPORT_FILE) + .writeText(report) + println(report) + } + + private fun warmUp() { + val candidates = List(64) { index -> + candidate(if (index == 63) WARMUP_INPUT else "候補-$index") + } + val promoter = ExactInputCandidatePromoter(hashSetOf(WARMUP_INPUT)) + repeat(1_000) { + listSink = promoter.promote(WARMUP_INPUT, candidates) + } + } + + private fun measureRuleScale(ruleCount: Int): RuleScaleResult { + val words = List(ruleCount) { index -> "長期運用語-$index" } + wordListSink = words + setSink = null + forceGc() + val heapBefore = usedHeapBytes() + val allocatedBefore = allocatedBytes() + val startedNs = SystemClock.elapsedRealtimeNanos() + val rules = HashSet(words) + val buildElapsedNs = SystemClock.elapsedRealtimeNanos() - startedNs + val buildAllocatedBytes = allocatedBytes() - allocatedBefore + setSink = rules + forceGc() + val retainedHeapDeltaBytes = usedHeapBytes() - heapBefore + + val target = words.last() + val candidates = List(64) { index -> + candidate(if (index == 63) target else "候補-$index") + } + val promoter = ExactInputCandidatePromoter(rules) + repeat(50) { + listSink = promoter.promote(target, candidates) + } + val hitTiming = measureRepeated(300) { + listSink = promoter.promote(target, candidates) + } + val hitAllocatedBytesPerCall = measureAllocatedBytesPerCall(200) { + listSink = promoter.promote(target, candidates) + } + val missTiming = measureRepeated(300) { + listSink = promoter.promote(UNREGISTERED_INPUT, candidates) + } + val missAllocatedBytesPerCall = measureAllocatedBytesPerCall(500) { + listSink = promoter.promote(UNREGISTERED_INPUT, candidates) + } + + assertEquals(target, promoter.promote(target, candidates).first().string) + assertSame(candidates, promoter.promote(UNREGISTERED_INPUT, candidates)) + return RuleScaleResult( + rules = ruleCount, + setBuildNs = buildElapsedNs, + setBuildAllocatedBytes = buildAllocatedBytes, + setRetainedHeapDeltaBytes = retainedHeapDeltaBytes, + hitTiming = hitTiming, + hitAllocatedBytesPerCall = hitAllocatedBytesPerCall, + missTiming = missTiming, + missAllocatedBytesPerCall = missAllocatedBytesPerCall, + ) + } + + private fun measureCandidateScale(candidateCount: Int): CandidateScaleResult { + val promoter = ExactInputCandidatePromoter(hashSetOf(TARGET_INPUT)) + val candidates = List(candidateCount) { index -> + candidate( + string = if (index == candidateCount - 1) TARGET_INPUT else "候補-$index", + type = if (index % 1_000 == 0) { + CANDIDATE_TYPE_LEARNED_DICTIONARY + } else { + 1 + }, + ) + } + repeat(if (candidateCount == 10_000) 30 else 10) { + listSink = promoter.promote(TARGET_INPUT, candidates) + } + val allocatedBytesPerCall = measureAllocatedBytesPerCall( + iterations = if (candidateCount == 10_000) 20 else 10, + ) { + listSink = promoter.promote(TARGET_INPUT, candidates) + } + val timing = measureRepeated(if (candidateCount == 10_000) 50 else 20) { + listSink = promoter.promote(TARGET_INPUT, candidates) + } + val result = promoter.promote(TARGET_INPUT, candidates) + val learnedCount = candidates.count { + it.type == CANDIDATE_TYPE_LEARNED_DICTIONARY + } + + assertEquals(candidateCount, result.size) + assertEquals(TARGET_INPUT, result[learnedCount].string) + assertTrue(allocatedBytesPerCall > 0L) + return CandidateScaleResult( + candidates = candidateCount, + timing = timing, + allocatedBytesPerCall = allocatedBytesPerCall, + ) + } + + private fun measureAllocatedBytesPerCall( + iterations: Int, + block: () -> Unit, + ): Long { + val before = allocatedBytes() + repeat(iterations) { + block() + } + return (allocatedBytes() - before) / iterations + } + + private fun measureRepeated( + iterations: Int, + block: () -> Unit, + ): TimingMeasurement { + val samples = LongArray(iterations) + repeat(iterations) { index -> + val startedNs = SystemClock.elapsedRealtimeNanos() + block() + samples[index] = SystemClock.elapsedRealtimeNanos() - startedNs + } + samples.sort() + return TimingMeasurement( + p50Ns = samples[((samples.size - 1) * 50) / 100], + p95Ns = samples[((samples.size - 1) * 95) / 100], + ) + } + + private fun forceGc() { + repeat(3) { + Runtime.getRuntime().gc() + SystemClock.sleep(50) + } + } + + private fun usedHeapBytes(): Long { + val runtime = Runtime.getRuntime() + return runtime.totalMemory() - runtime.freeMemory() + } + + private fun allocatedBytes(): Long = + checkNotNull(Debug.getRuntimeStat("art.gc.bytes-allocated")).toLong() + + private fun candidate( + string: String, + type: Byte = 1, + ): Candidate = Candidate( + string = string, + type = type, + length = string.length.toUByte(), + score = 0, + ) + + private data class RuleScaleResult( + val rules: Int, + val setBuildNs: Long, + val setBuildAllocatedBytes: Long, + val setRetainedHeapDeltaBytes: Long, + val hitTiming: TimingMeasurement, + val hitAllocatedBytesPerCall: Long, + val missTiming: TimingMeasurement, + val missAllocatedBytesPerCall: Long, + ) { + fun asTsv(): String = listOf( + rules, + setBuildNs.asMilliseconds(), + setBuildAllocatedBytes, + setRetainedHeapDeltaBytes, + hitTiming.p50Ns.asMicroseconds(), + hitTiming.p95Ns.asMicroseconds(), + hitAllocatedBytesPerCall, + missTiming.p50Ns.asMicroseconds(), + missTiming.p95Ns.asMicroseconds(), + missAllocatedBytesPerCall, + ).joinToString("\t") + } + + private data class CandidateScaleResult( + val candidates: Int, + val timing: TimingMeasurement, + val allocatedBytesPerCall: Long, + ) { + fun asTsv(): String = listOf( + candidates, + timing.p50Ns.asMilliseconds(), + timing.p95Ns.asMilliseconds(), + allocatedBytesPerCall, + String.format( + Locale.ROOT, + "%.2f", + allocatedBytesPerCall.toDouble() / candidates, + ), + ).joinToString("\t") + } + + private data class TimingMeasurement( + val p50Ns: Long, + val p95Ns: Long, + ) + + private companion object { + const val PERFORMANCE_PROBE_ARGUMENT = "exactInputPromotionPerfProbe" + const val REPORT_DIRECTORY = "conversion-perf" + const val REPORT_FILE = "exact-input-promotion-android.txt" + const val WARMUP_INPUT = "ウォームアップ" + const val TARGET_INPUT = "ふ" + const val UNREGISTERED_INPUT = "登録外" + + fun Long.asMicroseconds(): String = + String.format(Locale.ROOT, "%.3f", this / 1_000.0) + + fun Long.asMilliseconds(): String = + String.format(Locale.ROOT, "%.3f", this / 1_000_000.0) + } +} diff --git a/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/gemma/GemmaDatabaseMigrationInstrumentedTest.kt b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/gemma/GemmaDatabaseMigrationInstrumentedTest.kt index c0c9080e3..ef2f40e99 100644 --- a/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/gemma/GemmaDatabaseMigrationInstrumentedTest.kt +++ b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/gemma/GemmaDatabaseMigrationInstrumentedTest.kt @@ -11,17 +11,21 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith -/** Verifies the production 40 -> 41 migration against the database installed on the device. */ +/** Verifies the production 40 -> latest migration against the database installed on the device. */ @RunWith(AndroidJUnit4::class) class GemmaDatabaseMigrationInstrumentedTest { @Test fun migrateExistingDatabaseAndSeedMediaActions() = runBlocking { val context = InstrumentationRegistry.getInstrumentation().targetContext val database = Room.databaseBuilder(context, AppDatabase::class.java, "learn_database") - .addMigrations(AppDatabase.MIGRATION_40_41) + .addMigrations( + AppDatabase.MIGRATION_40_41, + AppDatabase.MIGRATION_41_42, + AppDatabase.MIGRATION_42_43 + ) .build() try { - assertEquals(41, database.openHelper.writableDatabase.version) + assertEquals(43, database.openHelper.writableDatabase.version) val repository = GemmaPromptTemplateRepository(database.gemmaPromptTemplateDao()) repository.ensureBuiltIns() val keys = database.gemmaPromptTemplateDao().getBuiltInKeys() diff --git a/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingInlinePreferenceClassificationInstrumentedTest.kt b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingInlinePreferenceClassificationInstrumentedTest.kt index d319279a5..17b7be32f 100644 --- a/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingInlinePreferenceClassificationInstrumentedTest.kt +++ b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingInlinePreferenceClassificationInstrumentedTest.kt @@ -42,6 +42,29 @@ class SettingInlinePreferenceClassificationInstrumentedTest { assertEquals(R.id.keyboardDisplayPreferenceFragment, target.destinationId) } + @Test + fun fullscreenPreferenceUsesDeviceResourceDefaultInSearchAndAppPreference() { + val key = AppPreference.ALLOW_FULLSCREEN_MODE_KEY + val deviceDefault = context.resources.getBoolean(R.bool.config_allow_fullscreen_mode) + val target = destinationForKey(key).destination as SettingDestinationType.SwitchPreference + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + + assertEquals(deviceDefault, target.defaultValue) + assertEquals(deviceDefault, AppPreference.isFullscreenModeAllowed(deviceDefault)) + + preferences.edit().putBoolean(key, !deviceDefault).commit() + + assertEquals(!deviceDefault, AppPreference.isFullscreenModeAllowed(deviceDefault)) + } + + @Test + fun fullscreenPreferenceIsAvailableInNewAndLegacySettings() { + val key = AppPreference.ALLOW_FULLSCREEN_MODE_KEY + + assertTrue(key in realPreferenceKeys(listOf(R.xml.pref_keyboard_display))) + assertTrue(key in realPreferenceKeys(listOf(R.xml.pref_common_legacy))) + } + @Test fun listPreferenceXmlItemIsClassifiedWithEntriesAndEntryValues() { val destination = destinationForKey("candidate_column_preference") diff --git a/app/src/main/assets/ngram/system_ngram.dat b/app/src/main/assets/ngram/system_ngram.dat index 37f22ba97..a145dc2b3 100644 Binary files a/app/src/main/assets/ngram/system_ngram.dat and b/app/src/main/assets/ngram/system_ngram.dat differ diff --git a/app/src/main/assets/system/tango.dat.zip b/app/src/main/assets/system/tango.dat.zip index 54929af9a..fe1fbec17 100644 Binary files a/app/src/main/assets/system/tango.dat.zip and b/app/src/main/assets/system/tango.dat.zip differ diff --git a/app/src/main/assets/system/token.dat.zip b/app/src/main/assets/system/token.dat.zip index 5aed624c5..1610faabe 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/candidate_order/adapter/SavedCandidateOrderAdapter.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/adapter/SavedCandidateOrderAdapter.kt index 5893d3096..3afeb9142 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/adapter/SavedCandidateOrderAdapter.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/adapter/SavedCandidateOrderAdapter.kt @@ -6,11 +6,12 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.kazumaproject.markdownhelperkeyboard.candidate_order.model.SavedCandidateOrderGroup +import com.kazumaproject.markdownhelperkeyboard.candidate_order.model.CandidateOrderScope import com.kazumaproject.markdownhelperkeyboard.databinding.ListItemSavedCandidateOrderBinding class SavedCandidateOrderAdapter( private val onEdit: (SavedCandidateOrderGroup) -> Unit, - private val onDeleteInput: (String) -> Unit + private val onDeleteRule: (String, CandidateOrderScope) -> Unit, ) : ListAdapter(DiffCallback()) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { @@ -32,6 +33,13 @@ class SavedCandidateOrderAdapter( fun bind(item: SavedCandidateOrderGroup) { binding.textSavedCandidateOrderInput.text = item.input + binding.textSavedCandidateOrderScope.text = binding.root.context.getString( + if (item.scope == CandidateOrderScope.EXACT_INPUT) { + com.kazumaproject.markdownhelperkeyboard.R.string.candidate_order_scope_exact_short + } else { + com.kazumaproject.markdownhelperkeyboard.R.string.candidate_order_scope_lexical_short + }, + ) binding.textSavedCandidateOrderCandidates.text = item.candidates .mapIndexed { index, candidate -> "${index + 1}. $candidate" } .joinToString(separator = "\n") @@ -39,7 +47,7 @@ class SavedCandidateOrderAdapter( onEdit(item) } binding.buttonDeleteSavedCandidateOrder.setOnClickListener { - onDeleteInput(item.input) + onDeleteRule(item.input, item.scope) } } } @@ -48,7 +56,7 @@ class SavedCandidateOrderAdapter( override fun areItemsTheSame( oldItem: SavedCandidateOrderGroup, newItem: SavedCandidateOrderGroup - ): Boolean = oldItem.input == newItem.input + ): Boolean = oldItem.input == newItem.input && oldItem.scope == newItem.scope override fun areContentsTheSame( oldItem: SavedCandidateOrderGroup, diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/database/CandidateOrderOverrideDao.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/database/CandidateOrderOverrideDao.kt index 6472fd6ea..67364e639 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/database/CandidateOrderOverrideDao.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/database/CandidateOrderOverrideDao.kt @@ -13,16 +13,25 @@ interface CandidateOrderOverrideDao { @Query( """ SELECT * FROM candidate_order_override - WHERE input = :input + WHERE input = :input AND scope = :scope ORDER BY rank ASC """ ) - suspend fun findByInput(input: String): List + suspend fun findByRule(input: String, scope: String): List + + @Query( + """ + SELECT * FROM candidate_order_override + WHERE input IN (:inputs) + ORDER BY input ASC, scope ASC, rank ASC + """ + ) + suspend fun findByInputs(inputs: List): List @Query( """ SELECT * FROM candidate_order_override - ORDER BY input ASC, rank ASC + ORDER BY input ASC, scope ASC, rank ASC """ ) fun observeAll(): Flow> @@ -30,7 +39,7 @@ interface CandidateOrderOverrideDao { @Query( """ SELECT * FROM candidate_order_override - ORDER BY input ASC, rank ASC + ORDER BY input ASC, scope ASC, rank ASC """ ) suspend fun getAll(): List @@ -43,6 +52,14 @@ interface CandidateOrderOverrideDao { ) suspend fun deleteByInput(input: String) + @Query( + """ + DELETE FROM candidate_order_override + WHERE input = :input AND scope = :scope + """ + ) + suspend fun deleteByRule(input: String, scope: String) + @Query("DELETE FROM candidate_order_override") suspend fun deleteAll() @@ -50,8 +67,12 @@ interface CandidateOrderOverrideDao { suspend fun insertAll(entities: List) @Transaction - suspend fun replaceForInput(input: String, entities: List) { - deleteByInput(input) + suspend fun replaceForRule( + input: String, + scope: String, + entities: List, + ) { + deleteByRule(input, scope) insertAll(entities) } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/database/CandidateOrderOverrideEntity.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/database/CandidateOrderOverrideEntity.kt index db6638dd7..92cfb54ce 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/database/CandidateOrderOverrideEntity.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/database/CandidateOrderOverrideEntity.kt @@ -8,13 +8,15 @@ import androidx.room.PrimaryKey tableName = "candidate_order_override", indices = [ Index(value = ["input"]), - Index(value = ["input", "candidate"], unique = true) + Index(value = ["input", "scope"]), + Index(value = ["input", "scope", "candidate"], unique = true) ] ) data class CandidateOrderOverrideEntity( @PrimaryKey(autoGenerate = true) val id: Int = 0, val input: String, + val scope: String = "EXACT_INPUT", val candidate: String, val rank: Int, val createdAt: Long, diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/model/CandidateOrderScope.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/model/CandidateOrderScope.kt new file mode 100644 index 000000000..87a45e96b --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/model/CandidateOrderScope.kt @@ -0,0 +1,11 @@ +package com.kazumaproject.markdownhelperkeyboard.candidate_order.model + +enum class CandidateOrderScope { + EXACT_INPUT, + LEXICAL_UNIT; + + companion object { + fun fromDatabase(value: String): CandidateOrderScope = + entries.firstOrNull { it.name == value } ?: EXACT_INPUT + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/model/SavedCandidateOrderGroup.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/model/SavedCandidateOrderGroup.kt index 85dd6f052..955698056 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/model/SavedCandidateOrderGroup.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/model/SavedCandidateOrderGroup.kt @@ -2,6 +2,7 @@ package com.kazumaproject.markdownhelperkeyboard.candidate_order.model data class SavedCandidateOrderGroup( val input: String, + val scope: CandidateOrderScope = CandidateOrderScope.EXACT_INPUT, val candidates: List, val updatedAt: Long ) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/ui/CandidateOrderOverrideFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/ui/CandidateOrderOverrideFragment.kt index acb4f4395..9494a7ef5 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/ui/CandidateOrderOverrideFragment.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/ui/CandidateOrderOverrideFragment.kt @@ -26,6 +26,7 @@ import androidx.recyclerview.widget.RecyclerView import com.kazumaproject.markdownhelperkeyboard.R import com.kazumaproject.markdownhelperkeyboard.candidate_order.adapter.CandidateOrderOverrideAdapter import com.kazumaproject.markdownhelperkeyboard.candidate_order.adapter.SavedCandidateOrderAdapter +import com.kazumaproject.markdownhelperkeyboard.candidate_order.model.CandidateOrderScope import com.kazumaproject.markdownhelperkeyboard.databinding.FragmentCandidateOrderOverrideBinding import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @@ -102,8 +103,8 @@ class CandidateOrderOverrideFragment : Fragment() { onEdit = { savedOrder -> viewModel.editSavedOrder(savedOrder) }, - onDeleteInput = { input -> - showDeleteSavedOrderConfirmDialog(input) + onDeleteRule = { input, scope -> + showDeleteSavedOrderConfirmDialog(input, scope) } ) binding.recyclerViewSavedCandidateOrder.apply { @@ -142,6 +143,15 @@ class CandidateOrderOverrideFragment : Fragment() { binding.editTextCandidateOrderReading.doAfterTextChanged { viewModel.updateReading(it?.toString().orEmpty()) } + binding.radioGroupCandidateOrderScope.setOnCheckedChangeListener { _, checkedId -> + viewModel.updateScope( + if (checkedId == R.id.radio_candidate_order_scope_lexical) { + CandidateOrderScope.LEXICAL_UNIT + } else { + CandidateOrderScope.EXACT_INPUT + }, + ) + } binding.buttonFetchCandidateOrderCandidates.setOnClickListener { viewModel.fetchCandidates() } @@ -163,6 +173,21 @@ class CandidateOrderOverrideFragment : Fragment() { } adapter.submitList(state.candidates) savedOrderAdapter.submitList(state.savedOrders) + val expectedScopeId = if (state.scope == CandidateOrderScope.EXACT_INPUT) { + R.id.radio_candidate_order_scope_exact + } else { + R.id.radio_candidate_order_scope_lexical + } + if (binding.radioGroupCandidateOrderScope.checkedRadioButtonId != expectedScopeId) { + binding.radioGroupCandidateOrderScope.check(expectedScopeId) + } + binding.textCandidateOrderScopeExplanation.setText( + if (state.scope == CandidateOrderScope.EXACT_INPUT) { + R.string.candidate_order_scope_exact_description + } else { + R.string.candidate_order_scope_lexical_description + }, + ) binding.progressCandidateOrder.visibility = if (state.isLoading) View.VISIBLE else View.GONE binding.textCandidateOrderEmpty.visibility = @@ -191,17 +216,26 @@ class CandidateOrderOverrideFragment : Fragment() { } } - private fun showDeleteSavedOrderConfirmDialog(input: String) { + private fun showDeleteSavedOrderConfirmDialog( + input: String, + scope: CandidateOrderScope, + ) { AlertDialog.Builder(requireContext()) .setTitle(R.string.candidate_order_override_delete_saved_order_confirm_title) .setMessage( getString( R.string.candidate_order_override_delete_saved_order_confirm_message, - input + getString( + if (scope == CandidateOrderScope.EXACT_INPUT) { + R.string.candidate_order_scope_exact_short + } else { + R.string.candidate_order_scope_lexical_short + }, + ) + " / " + input ) ) .setPositiveButton(R.string.candidate_order_override_saved_order_delete) { _, _ -> - viewModel.deleteSavedOrder(input) + viewModel.deleteSavedOrder(input, scope) } .setNegativeButton(android.R.string.cancel, null) .show() diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/ui/CandidateOrderOverrideViewModel.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/ui/CandidateOrderOverrideViewModel.kt index 861d60fe3..908ca2d36 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/ui/CandidateOrderOverrideViewModel.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/ui/CandidateOrderOverrideViewModel.kt @@ -6,8 +6,10 @@ import androidx.lifecycle.viewModelScope import com.kazumaproject.markdownhelperkeyboard.R import com.kazumaproject.markdownhelperkeyboard.candidate_order.database.CandidateOrderOverrideEntity import com.kazumaproject.markdownhelperkeyboard.candidate_order.model.CandidateOrderItem +import com.kazumaproject.markdownhelperkeyboard.candidate_order.model.CandidateOrderScope import com.kazumaproject.markdownhelperkeyboard.candidate_order.model.SavedCandidateOrderGroup import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate +import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CandidateConversionSegment import com.kazumaproject.markdownhelperkeyboard.converter.engine.KanaKanjiEngine import com.kazumaproject.markdownhelperkeyboard.repository.CandidateOrderOverrideRepository import com.kazumaproject.markdownhelperkeyboard.repository.LearnRepository @@ -26,6 +28,7 @@ import javax.inject.Inject data class CandidateOrderOverrideUiState( val reading: String = "", + val scope: CandidateOrderScope = CandidateOrderScope.EXACT_INPUT, val candidates: List = emptyList(), val savedOrders: List = emptyList(), val isLoading: Boolean = false, @@ -34,6 +37,7 @@ data class CandidateOrderOverrideUiState( internal data class CandidateOrderEditingState( val reading: String, + val scope: CandidateOrderScope, val candidates: List ) @@ -50,11 +54,12 @@ internal fun filterCandidateOrderEditableCandidates( } internal fun List.toSavedCandidateOrderGroups(): List { - return groupBy { it.input } - .map { (input, rows) -> + return groupBy { it.input to CandidateOrderScope.fromDatabase(it.scope) } + .map { (rule, rows) -> val sortedRows = rows.sortedBy { it.rank } SavedCandidateOrderGroup( - input = input, + input = rule.first, + scope = rule.second, candidates = sortedRows.map { it.candidate }, updatedAt = sortedRows.maxOfOrNull { it.updatedAt } ?: 0L ) @@ -62,6 +67,7 @@ internal fun List.toSavedCandidateOrderGroups(): L .sortedWith( compareByDescending { it.updatedAt } .thenBy { it.input } + .thenBy { it.scope.name } ) } @@ -71,6 +77,7 @@ internal fun SavedCandidateOrderGroup.toCandidateOrderEditingState(): CandidateO return CandidateOrderEditingState( reading = normalizedInput, + scope = scope, candidates = candidates.mapIndexed { index, candidate -> CandidateOrderItem( candidate = candidate, @@ -106,7 +113,12 @@ class CandidateOrderOverrideViewModel @Inject constructor( fun updateReading(reading: String) { if (uiState.value.reading == reading) return - _uiState.update { it.copy(reading = reading) } + _uiState.update { it.copy(reading = reading, candidates = emptyList()) } + } + + fun updateScope(scope: CandidateOrderScope) { + if (uiState.value.scope == scope) return + _uiState.update { it.copy(scope = scope, candidates = emptyList(), message = null) } } fun editSavedOrder(savedOrder: SavedCandidateOrderGroup) { @@ -115,6 +127,7 @@ class CandidateOrderOverrideViewModel @Inject constructor( _uiState.update { it.copy( reading = editingState.reading, + scope = editingState.scope, candidates = editingState.candidates, message = null ) @@ -130,6 +143,8 @@ class CandidateOrderOverrideViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(isLoading = true, message = null) } + val scope = uiState.value.scope + val candidateSegments = LinkedHashMap>() val candidates = withContext(Dispatchers.Default) { kanaKanjiEngine.getCandidates( input = reading, @@ -148,12 +163,23 @@ class CandidateOrderOverrideViewModel @Inject constructor( .enable_typo_correction_japanese_flick_keyboard_offset_score_preference, omissionSearchOffsetScore = appPreference.omission_search_offset_score_preference, beamWidth = appPreference.conversion_beam_width_preference, + candidateSegmentCollector = candidateSegments, ) } .let { filterCandidateOrderEditableCandidates(reading, it) } + val candidatesForScope = if (scope == CandidateOrderScope.LEXICAL_UNIT) { + candidates.filterToSameStructureAsFirst(reading, candidateSegments) + } else { + candidates + } + val orderedCandidates = withContext(Dispatchers.IO) { - candidateOrderOverrideRepository.applyOrder(reading, candidates) + candidateOrderOverrideRepository.applyOrder( + input = reading, + candidates = candidatesForScope, + scope = scope, + ) } _uiState.update { @@ -165,7 +191,12 @@ class CandidateOrderOverrideViewModel @Inject constructor( ) }, isLoading = false, - message = if (orderedCandidates.isEmpty()) "候補が見つかりません" else null + message = when { + candidates.isEmpty() -> "候補が見つかりません" + scope == CandidateOrderScope.LEXICAL_UNIT && orderedCandidates.isEmpty() -> + "同じ語として安全に並び替えられる候補が見つかりません" + else -> null + } ) } } @@ -183,24 +214,26 @@ class CandidateOrderOverrideViewModel @Inject constructor( fun save() { val reading = uiState.value.reading.trim() + val scope = uiState.value.scope val candidates = uiState.value.candidates if (reading.isEmpty() || candidates.isEmpty()) return viewModelScope.launch(Dispatchers.IO) { candidateOrderOverrideRepository.saveOrder( input = reading, - candidates = candidates.map { it.candidate } + candidates = candidates.map { it.candidate }, + scope = scope, ) _uiState.update { it.copy(candidates = emptyList(), message = context.getString(R.string.candidate_order_override_saved)) } } } - fun deleteSavedOrder(input: String) { + fun deleteSavedOrder(input: String, scope: CandidateOrderScope) { val normalizedInput = input.trim() if (normalizedInput.isEmpty()) return viewModelScope.launch(Dispatchers.IO) { - candidateOrderOverrideRepository.deleteByInput(normalizedInput) + candidateOrderOverrideRepository.deleteRule(normalizedInput, scope) _uiState.update { it.copy( message = context.getString( @@ -223,4 +256,32 @@ class CandidateOrderOverrideViewModel @Inject constructor( fun clearMessage() { _uiState.update { it.copy(message = null) } } + + private fun List.filterToSameStructureAsFirst( + reading: String, + segmentsByString: Map>, + ): List { + val reference = firstOrNull() ?: return emptyList() + val signature = segmentsByString[reference.string]?.boundarySignature(reading.length) + ?: return emptyList() + return filter { candidate -> + segmentsByString[candidate.string]?.boundarySignature(reading.length) == signature + } + } + + private fun List.boundarySignature( + inputLength: Int, + ): List? { + if (isEmpty()) return null + var expectedStart = 0 + val result = ArrayList(size) + for (segment in this) { + if (segment.inputStart != expectedStart || segment.inputEnd <= segment.inputStart) { + return null + } + result += segment.inputEnd + expectedStart = segment.inputEnd + } + return result.takeIf { expectedStart == inputLength } + } } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/CandidateConversionSegment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/CandidateConversionSegment.kt new file mode 100644 index 000000000..8120b8b69 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/CandidateConversionSegment.kt @@ -0,0 +1,15 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.candidate + +/** + * Exact input/output correspondence for one node on a conversion path. + * + * Input offsets are UTF-16 indices into the original reading. Keeping offsets instead of slicing + * the completed candidate is required because readings and converted strings can have different + * lengths (for example, "きょう" and "今日"). + */ +data class CandidateConversionSegment( + val inputStart: Int, + val inputEnd: Int, + val output: String, +) + diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/ExactInputCandidatePromotionPolicy.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/ExactInputCandidatePromotionPolicy.kt new file mode 100644 index 000000000..fb0be5212 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/ExactInputCandidatePromotionPolicy.kt @@ -0,0 +1,122 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.candidate + +/** + * Promotes selected literal-input candidates without overriding learned choices. + * + * The stable priority order is: + * 1. learned candidates, + * 2. the first candidate whose output is exactly the current input, + * 3. every other candidate. + * + * Callers may apply an explicit, user-saved order after this policy. Inputs or candidate lists that + * require no reordering are returned unchanged so the normal conversion path does not allocate. + */ +internal class ExactInputCandidatePromoter( + private val promotedInputs: Set, +) { + fun promote( + input: String, + candidates: List, + ): List { + if (candidates.size <= 1 || input !in promotedInputs) return candidates + + var exactIndex = -1 + var previousCategory = LEARNED_CATEGORY + var needsReordering = false + + for (index in candidates.indices) { + val candidate = candidates[index] + if (exactIndex == -1 && candidate.string == input) { + exactIndex = index + } + val category = when { + candidate.type == CANDIDATE_TYPE_LEARNED_DICTIONARY -> LEARNED_CATEGORY + index == exactIndex -> EXACT_INPUT_CATEGORY + else -> OTHER_CATEGORY + } + if (category < previousCategory) { + needsReordering = true + } + previousCategory = category + } + + if (exactIndex == -1 || !needsReordering) return candidates + + val exactCandidate = candidates[exactIndex] + val result = ArrayList(candidates.size) + for (index in candidates.indices) { + val candidate = candidates[index] + if (candidate.type == CANDIDATE_TYPE_LEARNED_DICTIONARY) { + result.add(candidate) + } + } + if (exactCandidate.type != CANDIDATE_TYPE_LEARNED_DICTIONARY) { + result.add(exactCandidate) + } + for (index in candidates.indices) { + val candidate = candidates[index] + if ( + candidate.type != CANDIDATE_TYPE_LEARNED_DICTIONARY && + index != exactIndex + ) { + result.add(candidate) + } + } + return result + } + + private companion object { + const val LEARNED_CATEGORY = 0 + const val EXACT_INPUT_CATEGORY = 1 + const val OTHER_CATEGORY = 2 + } +} + +internal object ExactInputCandidatePromotionPolicy { + private val promoter = ExactInputCandidatePromoter( + hashSetOf( + "ての", + "か?", + "なにこれ?", + "え", + "き", + "く", + "け", + "こ", + "さ", + "し", + "せ", + "そ", + "た", + "ち", + "て", + "ひ", + "ふ", + "ほ", + "み", + "む", + "め", + "ゆ", + "り", + "ぎ", + "ぐ", + "げ", + "ご", + "ざ", + "じ", + "ず", + "ど", + "ば", + "び", + "ぶ", + "ぼ", + "ぴ", + "ぷ", + ), + ) + + fun promote( + input: String, + candidates: List, + ): List = promoter.promote(input, candidates) +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/dictionary/TokenArray.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/dictionary/TokenArray.kt index db2ecf1eb..6dfe0cf48 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/dictionary/TokenArray.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/dictionary/TokenArray.kt @@ -116,6 +116,51 @@ class TokenArray { } } + /** + * Visits at most [scratchIndices].size tokens in stable ascending word-cost order without + * materialising and sorting every token for the reading. [scratchIndices] belongs to the + * caller so repeated typo-frontier hits do not allocate a temporary list per result. + */ + fun forEachLowestCostDictionaryByYomiTermId( + nodeId: Int, + succinctBitVector: SuccinctBitVector, + scratchIndices: IntArray, + block: (posTableIndex: Short, wordCost: Short, nodeId: Int) -> Unit, + ) { + if (scratchIndices.isEmpty()) return + val startSelect0 = succinctBitVector.select0(nodeId) + val endSelect0 = succinctBitVector.select0(nodeId + 1) + val startRank1 = succinctBitVector.rank1(startSelect0) + val endRank1 = succinctBitVector.rank1(endSelect0) + var selectedCount = 0 + for (tokenIndex in startRank1 until endRank1) { + var insertionIndex = selectedCount + while ( + insertionIndex > 0 && + wordCostList[tokenIndex] < wordCostList[scratchIndices[insertionIndex - 1]] + ) { + insertionIndex-- + } + if (insertionIndex >= scratchIndices.size) continue + val newCount = (selectedCount + 1).coerceAtMost(scratchIndices.size) + var shiftIndex = newCount - 1 + while (shiftIndex > insertionIndex) { + scratchIndices[shiftIndex] = scratchIndices[shiftIndex - 1] + shiftIndex-- + } + scratchIndices[insertionIndex] = tokenIndex + selectedCount = newCount + } + for (index in 0 until selectedCount) { + val tokenIndex = scratchIndices[index] + block( + posTableIndexList[tokenIndex], + wordCostList[tokenIndex], + nodeIdAt(tokenIndex), + ) + } + } + fun getListDictionaryByYomiTermIdShortArray( nodeId: Short, rank0ArrayTokenArrayBitvector: ShortArray, diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngine.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngine.kt index b828e8453..dd6d76c6c 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngine.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngine.kt @@ -20,6 +20,7 @@ import com.kazumaproject.markdownhelperkeyboard.dictionary_override.DictionaryFi import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointers import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyboardProximityInfo import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -261,6 +262,97 @@ class EnglishEngine : QwertyGlideCandidateProvider { } } + /** + * Loads the bundled glide index away from the IME main thread. + * + * Until the index is ready, glide input continues to use the small fallback decoder. Repeated + * input sessions share the in-flight job instead of restarting the same asset read. + */ + fun configureQwertyGlideDecoderAsync( + enabled: Boolean, + canUseBundledPrebuiltIndex: Boolean, + prebuiltDictionaryLoader: QwertyGlidePrebuiltDictionaryLoader?, + ) { + if (!enabled) { + configureQwertyGlideDecoder( + enabled = false, + canUseBundledPrebuiltIndex = canUseBundledPrebuiltIndex, + prebuiltDictionaryLoader = prebuiltDictionaryLoader, + ) + return + } + + synchronized(this) { + if ( + canUseBundledPrebuiltIndex && + qwertyGlideInputEnabled && + (qwertyGlideDictionaryReady && qwertyGlideDecoder != null || + qwertyGlideWarmupJob?.isActive == true) + ) { + return + } + qwertyGlideInputEnabled = true + cancelQwertyGlideWarmup() + qwertyGlideDecoder = null + qwertyGlideDictionaryReady = false + } + + if (!canUseBundledPrebuiltIndex || prebuiltDictionaryLoader == null) { + logQwertyGlidePrebuilt( + "External English dictionary override active, skip bundled prebuilt glide index" + ) + logQwertyGlidePrebuilt("Fallback to runtime glide dictionary build") + return + } + + val job = qwertyGlideWarmupScope.launch(start = CoroutineStart.LAZY) { + val startedAt = System.nanoTime() + val result = prebuiltDictionaryLoader.load() + if (!isActive) return@launch + when (result) { + is QwertyGlidePrebuiltLoadResult.Loaded -> { + val decoder = createQwertyGlideDecoder( + provider = result.provider, + dictionaryReady = true, + ) + if (!isActive) return@launch + synchronized(this@EnglishEngine) { + if (!isActive || !qwertyGlideInputEnabled) return@synchronized + qwertyGlideDecoder = decoder + qwertyGlideDictionaryReady = true + } + logQwertyGlidePrebuilt( + "Bundled prebuilt glide index loaded asynchronously: " + + "entries=${result.provider.entryCount} " + + "elapsed_ms=${(System.nanoTime() - startedAt) / 1_000_000L}" + ) + } + + is QwertyGlidePrebuiltLoadResult.NotAvailable -> { + logQwertyGlidePrebuilt( + "Bundled prebuilt glide index unavailable: ${result.reason}" + ) + logQwertyGlidePrebuilt("Fallback to runtime glide dictionary build") + } + + is QwertyGlidePrebuiltLoadResult.Invalid -> { + logQwertyGlidePrebuilt( + "Bundled prebuilt glide index invalid: ${result.reason}" + ) + logQwertyGlidePrebuilt("Fallback to runtime glide dictionary build") + } + } + } + synchronized(this) { + qwertyGlideWarmupJob = job + } + job.start() + } + + internal suspend fun awaitQwertyGlideWarmup() { + qwertyGlideWarmupJob?.join() + } + fun cancelQwertyGlideWarmup() { qwertyGlideWarmupJob?.cancel() qwertyGlideWarmupJob = null diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/KanaKanjiEngine.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/KanaKanjiEngine.kt index 3d1f5befc..9af6c650c 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/KanaKanjiEngine.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/KanaKanjiEngine.kt @@ -23,6 +23,7 @@ import com.kazumaproject.markdownhelperkeyboard.converter.candidate.BunsetsuCand import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_ERA import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_TIME import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate +import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CandidateConversionSegment import com.kazumaproject.markdownhelperkeyboard.converter.graph.GraphBuilder import com.kazumaproject.markdownhelperkeyboard.converter.graph.GraphNodeDedupMode import com.kazumaproject.markdownhelperkeyboard.converter.mozc.MozcBoundaryMode @@ -52,7 +53,6 @@ import com.kazumaproject.markdownhelperkeyboard.ime_service.extensions.convertFu import com.kazumaproject.markdownhelperkeyboard.ime_service.extensions.convertFullWidthNumbersToHalfWidth import com.kazumaproject.markdownhelperkeyboard.ime_service.extensions.convertToKanjiNotation import com.kazumaproject.markdownhelperkeyboard.ime_service.extensions.createValueBasedSymbolCandidates -import com.kazumaproject.markdownhelperkeyboard.ime_service.extensions.groupAndReplaceJapaneseForNumber import com.kazumaproject.markdownhelperkeyboard.ime_service.extensions.isAllEnglishLetters import com.kazumaproject.markdownhelperkeyboard.ime_service.extensions.isAllFullWidthAscii import com.kazumaproject.markdownhelperkeyboard.ime_service.extensions.isAllHalfWidthAscii @@ -78,6 +78,12 @@ import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale +private const val POS_ID_COUNTER_GENERIC: Short = 2011 +private const val POS_ID_COUNTER_TIME: Short = 2015 +private const val POS_ID_NUMBER_ARABIC: Short = 2044 +private const val POS_ID_NUMBER_SEPARATED: Short = 2045 +private const val POS_ID_NUMBER_KANJI: Short = 2046 + class KanaKanjiEngine { data class IncrementalPerformanceSnapshot( @@ -89,12 +95,29 @@ class KanaKanjiEngine { val stateRejectionCount: Int, val expansionCacheHitCount: Int, val expansionCacheMissCount: Int, + val graphAppendReused: Boolean, + val forwardDpReused: Boolean, ) class IncrementalSessionState internal constructor( internal val graphState: GraphBuilder.SessionState, internal val pathState: FindPath.SessionState, ) { + internal fun beginQueryTransaction() { + graphState.beginQueryTransaction() + pathState.beginQueryTransaction() + } + + internal fun commitQueryTransaction() { + graphState.commitQueryTransaction() + pathState.commitQueryTransaction() + } + + internal fun rollbackQueryTransaction() { + graphState.rollbackQueryTransaction() + pathState.rollbackQueryTransaction() + } + internal fun reset() { graphState.reset() pathState.reset() @@ -115,13 +138,20 @@ class KanaKanjiEngine { stateRejectionCount = pathState.lastStateRejectionCount, expansionCacheHitCount = pathState.lastExpansionCacheHitCount, expansionCacheMissCount = pathState.lastExpansionCacheMissCount, + graphAppendReused = graphState.lastAppendReused, + forwardDpReused = pathState.lastForwardDpReused, ) + + internal fun committedInput(): String? = graphState.committedInput() } private lateinit var graphBuilder: GraphBuilder private lateinit var findPath: FindPath private var dictionaryBinaryReader: DictionaryBinaryReader? = null + @Volatile + private var initialOptionalDictionaryStateLoaded: Boolean = false + private lateinit var connectionMatrix: ConnectionMatrix.CostTable fun createIncrementalSessionState(): IncrementalSessionState = IncrementalSessionState( @@ -466,9 +496,37 @@ class KanaKanjiEngine { assignNeologdDictionary(newNeologd) assignWebDictionary(newWeb) mozcDictionaryActive = newMozcDictionaryActive + initialOptionalDictionaryStateLoaded = true + } + } + + /** + * Completes the dictionary state that is not supplied by the Hilt graph without rebuilding + * the already-loaded core dictionaries. The IME starts this once in the background instead + * of racing the first input against a redundant full dictionary reload. + */ + fun initializeOptionalDictionaryStateFromCurrentSources() { + val reader = checkNotNull(dictionaryBinaryReader) { + "DictionaryBinaryReader must be configured before optional dictionaries" + } + val newPerson = loadOptionalTripleDictionary(reader, DictionaryCategory.PERSON_NAME) + val newPlaces = loadOptionalTripleDictionary(reader, DictionaryCategory.PLACES) + val newWiki = loadOptionalTripleDictionary(reader, DictionaryCategory.WIKI) + val newNeologd = loadOptionalTripleDictionary(reader, DictionaryCategory.NEOLOGD) + val newWeb = loadOptionalTripleDictionary(reader, DictionaryCategory.WEB) + synchronized(this) { + assignPersonDictionary(newPerson) + assignPlacesDictionary(newPlaces) + assignWikiDictionary(newWiki) + assignNeologdDictionary(newNeologd) + assignWebDictionary(newWeb) + initialOptionalDictionaryStateLoaded = true } } + fun isInitialOptionalDictionaryStateLoaded(): Boolean = + initialOptionalDictionaryStateLoaded + private fun loadOptionalTripleDictionary( reader: DictionaryBinaryReader, category: DictionaryCategory, @@ -494,10 +552,10 @@ class KanaKanjiEngine { tangoTrie = tangoTrie, yomiTrie = yomiTrie, tokenArray = tokenArray, - succinctBitVectorLBSYomi = SuccinctBitVector(yomiTrie.LBS), - succinctBitVectorIsLeafYomi = SuccinctBitVector(yomiTrie.isLeaf), - succinctBitVectorTokenArray = SuccinctBitVector(tokenArray.bitvector), - succinctBitVectorTangoLBS = SuccinctBitVector(tangoTrie.LBS), + succinctBitVectorLBSYomi = reader.loadYomiLbsIndex(yomiKey, yomiTrie), + succinctBitVectorIsLeafYomi = reader.loadYomiLeafIndex(yomiKey, yomiTrie), + succinctBitVectorTokenArray = reader.loadTokenIndex(tokenKey, tokenArray), + succinctBitVectorTangoLBS = reader.loadTangoLbsIndex(tangoKey, tangoTrie), ) } @@ -1026,6 +1084,7 @@ class KanaKanjiEngine { beamWidth: Int = 20, incrementalSessionState: IncrementalSessionState? = null, predictionConfig: PredictionConfig = PredictionConfig(), + candidateSegmentCollector: MutableMap>? = null, ): List { val conversionContext = currentCoroutineContext() @@ -1074,6 +1133,7 @@ class KanaKanjiEngine { omissionSearchOffSetScore = omissionSearchOffsetScore, graphNodeDedupMode = graphNodeDedupModeForCurrentDictionary(), mozcNodeAttributeTable = mozcNodeAttributeTableForCurrentDictionary(), + beamWidth = beamWidth, sessionState = incrementalSessionState?.graphState, ) @@ -1096,6 +1156,7 @@ class KanaKanjiEngine { beamWidth = beamWidth, cancellationCheck = { conversionContext.ensureActive() }, sessionState = incrementalSessionState?.pathState, + candidateSegmentCollector = candidateSegmentCollector, ) } conversionContext.ensureActive() @@ -1529,6 +1590,7 @@ class KanaKanjiEngine { beamWidth: Int = 20, incrementalSessionState: IncrementalSessionState? = null, predictionConfig: PredictionConfig = PredictionConfig(), + candidateSegmentCollector: MutableMap>? = null, ): BunsetsuCandidateResult { val conversionContext = currentCoroutineContext() @@ -1577,6 +1639,7 @@ class KanaKanjiEngine { omissionSearchOffSetScore = omissionSearchOffsetScore, graphNodeDedupMode = graphNodeDedupModeForCurrentDictionary(), mozcNodeAttributeTable = mozcNodeAttributeTableForCurrentDictionary(), + beamWidth = beamWidth, sessionState = incrementalSessionState?.graphState, ) @@ -1601,6 +1664,7 @@ class KanaKanjiEngine { beamWidth = beamWidth, cancellationCheck = { conversionContext.ensureActive() }, sessionState = incrementalSessionState?.pathState, + candidateSegmentCollector = candidateSegmentCollector, ) } conversionContext.ensureActive() @@ -2063,6 +2127,7 @@ class KanaKanjiEngine { beamWidth: Int = 20, incrementalSessionState: IncrementalSessionState? = null, predictionConfig: PredictionConfig = PredictionConfig(), + candidateSegmentCollector: MutableMap>? = null, ): BunsetsuCandidateResult { val conversionContext = currentCoroutineContext() @@ -2111,6 +2176,7 @@ class KanaKanjiEngine { omissionSearchOffSetScore = omissionSearchOffsetScore, graphNodeDedupMode = graphNodeDedupModeForCurrentDictionary(), mozcNodeAttributeTable = mozcNodeAttributeTableForCurrentDictionary(), + beamWidth = beamWidth, sessionState = incrementalSessionState?.graphState, ) @@ -2135,6 +2201,7 @@ class KanaKanjiEngine { beamWidth = beamWidth, cancellationCheck = { conversionContext.ensureActive() }, sessionState = incrementalSessionState?.pathState, + candidateSegmentCollector = candidateSegmentCollector, ) } conversionContext.ensureActive() @@ -2538,6 +2605,7 @@ class KanaKanjiEngine { beamWidth: Int = 20, incrementalSessionState: IncrementalSessionState? = null, predictionConfig: PredictionConfig = PredictionConfig(), + candidateSegmentCollector: MutableMap>? = null, ): List { val conversionContext = currentCoroutineContext() @@ -2586,6 +2654,7 @@ class KanaKanjiEngine { omissionSearchOffSetScore = omissionSearchOffsetScore, graphNodeDedupMode = graphNodeDedupModeForCurrentDictionary(), mozcNodeAttributeTable = mozcNodeAttributeTableForCurrentDictionary(), + beamWidth = beamWidth, sessionState = incrementalSessionState?.graphState, ) @@ -2608,6 +2677,7 @@ class KanaKanjiEngine { beamWidth = beamWidth, cancellationCheck = { conversionContext.ensureActive() }, sessionState = incrementalSessionState?.pathState, + candidateSegmentCollector = candidateSegmentCollector, ) } conversionContext.ensureActive() @@ -2994,6 +3064,7 @@ class KanaKanjiEngine { beamWidth: Int = 20, incrementalSessionState: IncrementalSessionState? = null, predictionConfig: PredictionConfig = PredictionConfig(), + candidateSegmentCollector: MutableMap>? = null, ): List { val conversionContext = currentCoroutineContext() @@ -3041,6 +3112,7 @@ class KanaKanjiEngine { omissionSearchOffSetScore = omissionSearchOffsetScore, graphNodeDedupMode = graphNodeDedupModeForCurrentDictionary(), mozcNodeAttributeTable = mozcNodeAttributeTableForCurrentDictionary(), + beamWidth = beamWidth, sessionState = incrementalSessionState?.graphState, ) @@ -3063,6 +3135,7 @@ class KanaKanjiEngine { beamWidth = beamWidth, cancellationCheck = { conversionContext.ensureActive() }, sessionState = incrementalSessionState?.pathState, + candidateSegmentCollector = candidateSegmentCollector, ) } conversionContext.ensureActive() @@ -3485,6 +3558,7 @@ class KanaKanjiEngine { beamWidth: Int = 20, incrementalSessionState: IncrementalSessionState? = null, predictionConfig: PredictionConfig = PredictionConfig(), + candidateSegmentCollector: MutableMap>? = null, ): BunsetsuCandidateResult { val conversionContext = currentCoroutineContext() @@ -3532,6 +3606,7 @@ class KanaKanjiEngine { omissionSearchOffSetScore = omissionSearchOffsetScore, graphNodeDedupMode = graphNodeDedupModeForCurrentDictionary(), mozcNodeAttributeTable = mozcNodeAttributeTableForCurrentDictionary(), + beamWidth = beamWidth, sessionState = incrementalSessionState?.graphState, ) @@ -3556,6 +3631,7 @@ class KanaKanjiEngine { beamWidth = beamWidth, cancellationCheck = { conversionContext.ensureActive() }, sessionState = incrementalSessionState?.pathState, + candidateSegmentCollector = candidateSegmentCollector, ) } conversionContext.ensureActive() @@ -4015,49 +4091,60 @@ class KanaKanjiEngine { predictionConfig: PredictionConfig = PredictionConfig(), ): List { val inputToEnglish = input.replaceJapaneseCharactersForEnglish() - val inputToNumbers = input.groupAndReplaceJapaneseForNumber() + val explicitDigitInput = input.takeIf { value -> + value.isNotEmpty() && value.all { it in '0'..'9' || it in '0'..'9' } + } val directJapaneseNumber = input.toNumber() val numberUnitCandidates = createCandidatesForJapaneseNumberWithUnit(input) val preferredNumberCandidate = when { numberUnitCandidates.isNotEmpty() -> numberUnitCandidates.first().string directJapaneseNumber != null -> directJapaneseNumber.second - else -> inputToNumbers + explicitDigitInput != null -> explicitDigitInput.convertFullWidthNumbersToHalfWidth() + else -> null } - val listJapaneseCandidates = listOf( - Candidate( + val listJapaneseCandidates = buildList { + add(Candidate( string = input, type = (1).toByte(), length = input.length.toUByte(), score = 3000 - ), Candidate( + )) + add(Candidate( string = input.hiraToKata(), type = (1).toByte(), length = input.length.toUByte(), score = 3000 - ), Candidate( + )) + add(Candidate( string = input.toHankakuKatakana(), type = (31).toByte(), length = input.length.toUByte(), score = 3000 - ), Candidate( + )) + add(Candidate( string = inputToEnglish, type = (1).toByte(), length = input.length.toUByte(), score = 3000 - ), Candidate( + )) + add(Candidate( string = inputToEnglish.replaceFirstChar { it.uppercaseChar() }, type = (1).toByte(), length = input.length.toUByte(), score = 3000 - ), Candidate( + )) + add(Candidate( string = inputToEnglish.uppercase(), type = (1).toByte(), length = input.length.toUByte(), score = 3000 - ), Candidate( - string = preferredNumberCandidate, - type = (1).toByte(), - length = input.length.toUByte(), - score = 3000 - ) - ) + )) + if (preferredNumberCandidate != null && preferredNumberCandidate != input) { + add(Candidate( + string = preferredNumberCandidate, + type = (1).toByte(), + length = input.length.toUByte(), + score = 3000 + )) + } + } val digitCandidates = when { directJapaneseNumber != null -> createDigitCandidates( @@ -4065,7 +4152,11 @@ class KanaKanjiEngine { ) numberUnitCandidates.isNotEmpty() -> emptyList() - else -> createDigitCandidates(inputToNumbers, input.length.toUByte()) + explicitDigitInput != null -> createDigitCandidates( + explicitDigitInput, + input.length.toUByte(), + ) + else -> emptyList() } val englishDeferred = if (input.isAllEnglishLetters()) { @@ -4193,16 +4284,16 @@ class KanaKanjiEngine { type = 22, length = inputLength, score = 8000, - leftId = 2040, - rightId = 2040 + leftId = POS_ID_NUMBER_ARABIC, + rightId = POS_ID_NUMBER_ARABIC ) val halfWidth = Candidate( string = halfWidthDigits.convertFullWidthToHalfWidth(), type = 31, length = inputLength, score = 8000, - leftId = 2040, - rightId = 2040 + leftId = POS_ID_NUMBER_ARABIC, + rightId = POS_ID_NUMBER_ARABIC ) val timeConversion = createCandidatesForTime(halfWidthDigits) val dateConversion = createCandidatesForDateInDigit(halfWidthDigits) @@ -4216,8 +4307,8 @@ class KanaKanjiEngine { type = 17, score = 2000, length = inputLength, - leftId = 2040, - rightId = 2040 + leftId = POS_ID_NUMBER_KANJI, + rightId = POS_ID_NUMBER_KANJI ) ) add( @@ -4226,8 +4317,8 @@ class KanaKanjiEngine { type = 19, score = 8001, length = inputLength, - leftId = 2040, - rightId = 2040 + leftId = POS_ID_NUMBER_SEPARATED, + rightId = POS_ID_NUMBER_SEPARATED ) ) add( @@ -4236,8 +4327,8 @@ class KanaKanjiEngine { type = 18, score = 8002, length = inputLength, - leftId = 2040, - rightId = 2040 + leftId = POS_ID_NUMBER_ARABIC, + rightId = POS_ID_NUMBER_ARABIC ) ) add( @@ -4246,8 +4337,8 @@ class KanaKanjiEngine { type = 23, score = 7900, length = inputLength, - leftId = 2040, - rightId = 2040 + leftId = POS_ID_NUMBER_KANJI, + rightId = POS_ID_NUMBER_KANJI ) ) } @@ -4266,9 +4357,13 @@ class KanaKanjiEngine { for ((readingSuffix, unit) in unitMappings) { if (!input.endsWith(readingSuffix) || input.length <= readingSuffix.length) continue - val number = input.removeSuffix(readingSuffix).toNumber() ?: continue + val numberReading = normalizeJapaneseNumberReadingForCounter( + input.removeSuffix(readingSuffix), + readingSuffix, + ) ?: continue + val number = numberReading.toNumber() ?: continue val isTimeLike = unit == "時" || unit == "分" - val connectionId = if (isTimeLike) 1851.toShort() else 2040.toShort() + val rightId = if (unit == "時") POS_ID_COUNTER_TIME else POS_ID_COUNTER_GENERIC return listOf( Candidate( @@ -4276,15 +4371,15 @@ class KanaKanjiEngine { type = if (isTimeLike) CANDIDATE_TYPE_TIME else 18, length = input.length.toUByte(), score = 8000, - leftId = connectionId, - rightId = connectionId + leftId = POS_ID_NUMBER_ARABIC, + rightId = rightId ), Candidate( string = "${number.first}$unit", type = if (isTimeLike) 30 else 22, length = input.length.toUByte(), score = 8001, - leftId = connectionId, - rightId = connectionId + leftId = POS_ID_NUMBER_ARABIC, + rightId = rightId ) ) } @@ -4292,6 +4387,31 @@ class KanaKanjiEngine { return emptyList() } + private fun normalizeJapaneseNumberReadingForCounter( + numberReading: String, + counterReading: String, + ): String? { + fun isStandaloneOrAfterPlace(alias: String): Boolean { + if (numberReading == alias) return true + val prefix = numberReading.dropLast(alias.length) + return listOf("じゅう", "ひゃく", "せん", "まん", "おく", "ちょう") + .any(prefix::endsWith) + } + + // 「し」は単独の四としては有効だが、現在扱っている助数詞の + // 直前では通常語との衝突が大きい(しじ、しえん、しにん等)。 + if (numberReading.endsWith("し") && isStandaloneOrAfterPlace("し")) return null + if (counterReading != "じ") return numberReading + + return when { + numberReading.endsWith("よ") && isStandaloneOrAfterPlace("よ") -> + numberReading.dropLast(1) + "よん" + numberReading.endsWith("く") && isStandaloneOrAfterPlace("く") -> + numberReading.dropLast(1) + "きゅう" + else -> numberReading + } + } + fun getSymbolEmojiCandidates(): List = emojiTokenArray.getNodeIds().map { nodeId -> emojiTangoTrie.getLetterShortArray(nodeId, emojiSuccinctBitVectorTangoLBS) @@ -5093,7 +5213,7 @@ class KanaKanjiEngine { Candidate( string = numberAsLong.toKanji(), type = 32, // 新しいタイプ length = input.length.toUByte(), score = 8000, // 優先度を調整 - leftId = 2040, rightId = 2040 + leftId = POS_ID_NUMBER_KANJI, rightId = POS_ID_NUMBER_KANJI ) ) } @@ -5106,8 +5226,8 @@ class KanaKanjiEngine { type = 17, length = input.length.toUByte(), score = 8000, - leftId = 2040, - rightId = 2040 + leftId = POS_ID_NUMBER_KANJI, + rightId = POS_ID_NUMBER_KANJI ) ) } @@ -5120,8 +5240,8 @@ class KanaKanjiEngine { type = if (it == firstNum) (30).toByte() else (31).toByte(), length = input.length.toUByte(), score = 8002, - leftId = 2040, - rightId = 2040 + leftId = POS_ID_NUMBER_ARABIC, + rightId = POS_ID_NUMBER_ARABIC ) ) } @@ -5133,8 +5253,8 @@ class KanaKanjiEngine { type = 19, length = input.length.toUByte(), score = 8001, - leftId = 2040, - rightId = 2040 + leftId = POS_ID_NUMBER_SEPARATED, + rightId = POS_ID_NUMBER_SEPARATED ) ) @@ -5146,8 +5266,8 @@ class KanaKanjiEngine { type = 20, length = input.length.toUByte(), score = 8003, - leftId = 2040, - rightId = 2040 + leftId = POS_ID_NUMBER_ARABIC, + rightId = POS_ID_NUMBER_ARABIC ) ) } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/graph/GraphBuilder.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/graph/GraphBuilder.kt index e51cebef4..b8b7fd271 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/graph/GraphBuilder.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/graph/GraphBuilder.kt @@ -36,7 +36,7 @@ class GraphBuilder { val webPrefixStates: Map, val personPrefixStates: Map, val neologdPrefixStates: Map, - val unprunedFrontier: MutableList?, + val unprunedPositions: Map>?, ) /** @@ -49,16 +49,118 @@ class GraphBuilder { internal var cachedGraph: CachedGraph? = null internal var performanceProbeEnabled: Boolean = false internal var lastConstructGraphNs: Long = 0 + internal var lastAppendReused: Boolean = false + + private var queryTransaction: QueryTransaction? = null + + private data class InPlaceAppendRollback( + val graph: IncrementalGraph, + val previousReusedThroughEndIndex: Int, + val previousForwardDpReusableThroughEndIndex: Int, + val previousInputLength: Int, + val previousPositions: Map?>, + val previousEosIndex: Int, + val previousEos: MutableList?, + ) { + fun restore() { + graph.keys.removeAll { it > previousInputLength } + previousPositions.forEach { (position, nodes) -> + nodes?.let { graph[position] = it } ?: graph.remove(position) + } + previousEos?.let { graph[previousEosIndex] = it } + ?: graph.remove(previousEosIndex) + graph.reusedThroughEndIndex = previousReusedThroughEndIndex + graph.forwardDpReusableThroughEndIndex = + previousForwardDpReusableThroughEndIndex + check(graph.keys.none { it > previousEosIndex }) + } + } + + private data class QueryTransaction( + var committedCache: CachedGraph?, + var inPlaceAppendRollback: InPlaceAppendRollback? = null, + var completedGraphRollback: CompletedGraphRollback? = null, + ) + + private data class CompletedGraphRollback( + val graph: IncrementalGraph, + val unprunedPositions: Map>, + ) { + fun restore() { + unprunedPositions.forEach { (position, nodes) -> + graph[position] = nodes + } + } + } + + internal fun beginQueryTransaction() { + check(queryTransaction == null) { "Graph query transaction is already active" } + queryTransaction = QueryTransaction(committedCache = cachedGraph) + } + + internal fun registerInPlaceAppend( + graph: IncrementalGraph, + previousReusedThroughEndIndex: Int, + previousForwardDpReusableThroughEndIndex: Int, + previousInputLength: Int, + positionsToRestore: Set, + previousEosIndex: Int, + previousEos: MutableList?, + ) { + queryTransaction?.inPlaceAppendRollback = InPlaceAppendRollback( + graph = graph, + previousReusedThroughEndIndex = previousReusedThroughEndIndex, + previousForwardDpReusableThroughEndIndex = + previousForwardDpReusableThroughEndIndex, + previousInputLength = previousInputLength, + previousPositions = positionsToRestore.associateWith { graph[it] }, + previousEosIndex = previousEosIndex, + previousEos = previousEos, + ) + } + + internal fun commitQueryTransaction() { + queryTransaction = null + } + + /** + * Publishes a completely constructed lattice before the cancellable DP/K-best phase. + * If that later phase is cancelled, retain the new input frontier and only restore the + * node lists that beam search may have pruned in place. + */ + internal fun commitCompletedGraph(cache: CachedGraph) { + val transaction = queryTransaction ?: return + val graph = cache.graph as? IncrementalGraph ?: return + transaction.committedCache = cache + transaction.inPlaceAppendRollback = null + transaction.completedGraphRollback = CompletedGraphRollback( + graph = graph, + unprunedPositions = cache.unprunedPositions.orEmpty(), + ) + } + + internal fun committedInput(): String? = cachedGraph?.input + + internal fun rollbackQueryTransaction() { + val transaction = queryTransaction ?: return + transaction.completedGraphRollback?.restore() + ?: transaction.inPlaceAppendRollback?.restore() + cachedGraph = transaction.committedCache + queryTransaction = null + } internal fun reset() { + queryTransaction = null cachedGraph = null lastConstructGraphNs = 0 + lastAppendReused = false } } - private class IncrementalGraph( + internal class IncrementalGraph( override var reusedThroughEndIndex: Int, override val conversionSignature: Int, + override var forwardDpReusableThroughEndIndex: Int = reusedThroughEndIndex, ) : LinkedHashMap>(), IncrementalGraphMetadata @Volatile @@ -208,6 +310,7 @@ class GraphBuilder { enableTypoCorrectionJapaneseFlick: Boolean = false, typoCorrectionOffsetScore: Int, omissionSearchOffSetScore: Int, + beamWidth: Int = 20, graphNodeDedupMode: GraphNodeDedupMode = GraphNodeDedupMode.EXISTING_BY_TANGO_L_R, mozcNodeAttributeTable: MozcNodeAttributeTable? = null, graphNodeTrace: MutableList? = null, @@ -239,6 +342,7 @@ class GraphBuilder { enableTypoCorrectionJapaneseFlick = enableTypoCorrectionJapaneseFlick, typoCorrectionOffsetScore = typoCorrectionOffsetScore, omissionSearchOffSetScore = omissionSearchOffSetScore, + beamWidth = beamWidth, graphNodeDedupMode = graphNodeDedupMode, mozcNodeAttributeTable = mozcNodeAttributeTable, ) @@ -246,15 +350,20 @@ class GraphBuilder { val reusable = activeCache?.takeIf { graphNodeTrace == null && it.signature == signature && - str.length == it.input.length + 1 && + ( + sessionState != null && str.length > it.input.length || + sessionState == null && str.length == it.input.length + 1 + ) && str.startsWith(it.input) && // Appending within an alphabet token changes every maximal-run node that starts // in that token. The current append cache can only preserve a fully stable // prefix, so rebuild this transition instead of publishing a stale short token. - !( - it.input.lastOrNull()?.isAsciiAlphabet() == true && - str.lastOrNull()?.isAsciiAlphabet() == true - ) + str.substring((it.input.length - 1).coerceAtLeast(0)) + .windowed(2) + .none { pair -> pair[0].isAsciiAlphabet() && pair[1].isAsciiAlphabet() } + } + if (sessionState?.performanceProbeEnabled == true) { + sessionState.lastAppendReused = reusable != null } val reusablePrefixLength = reusable?.input?.length ?: -1 val systemOmissionStates = LinkedHashMap>() @@ -267,6 +376,7 @@ class GraphBuilder { val webPrefixStates = LinkedHashMap() val personPrefixStates = LinkedHashMap() val neologdPrefixStates = LinkedHashMap() + val typoTopTokenScratch = IntArray(5) fun prefixSearch( trie: LOUDSWithTermId, @@ -281,13 +391,23 @@ class GraphBuilder { reusable != null && previousNodeIndex != null ) { - trie.advanceCommonPrefixSearch( - previousNodeIndex = previousNodeIndex, - char = str.last(), - input = str, - start = start, - succinctBitVector = bitVector, + var current = LOUDSWithTermId.CommonPrefixSearchProgress( + results = emptyList(), + nodeIndex = previousNodeIndex, ) + val newResults = ArrayList() + for (endIndex in reusable.input.length until str.length) { + current = trie.advanceCommonPrefixSearch( + previousNodeIndex = current.nodeIndex, + char = str[endIndex], + input = str.subSequence(0, endIndex + 1), + start = start, + succinctBitVector = bitVector, + ) + newResults.addAll(current.results) + if (current.nodeIndex < 0) break + } + current.copy(results = newResults) } else { trie.commonPrefixSearchWithProgress( str = str, @@ -298,20 +418,119 @@ class GraphBuilder { currentStates[start] = progress.nodeIndex return progress.results } + + fun typoSearch( + trie: LOUDSWithTermId, + bitVector: SuccinctBitVector, + start: Int, + previous: LOUDSWithTermId.TypoSearchProgress?, + ): LOUDSWithTermId.TypoSearchProgress { + if (sessionState == null || reusable == null || previous == null) { + return trie.commonPrefixSearchWithTypoCorrectionProgress( + str = str, + startIndex = start, + succinctBitVector = bitVector, + maxResults = 98, + maxLen = 12, + ) + } + var current: LOUDSWithTermId.TypoSearchProgress = checkNotNull(previous) + val newResults = ArrayList() + for (index in reusable.input.length until str.length) { + current = trie.advanceTypoCorrectionSearch( + previous = current, + char = str[index], + succinctBitVector = bitVector, + maxResults = 98, + maxLen = 12, + ) + newResults.addAll(current.results) + if (current.terminalStates.isEmpty()) break + } + return current.copy(results = newResults) + } + + fun omissionSearch( + trie: LOUDSWithTermId, + bitVector: SuccinctBitVector, + start: Int, + previous: List?, + ): LOUDSWithTermId.OmissionSearchProgress { + if (sessionState == null || reusable == null) { + return trie.commonPrefixSearchWithOmissionProgress( + str = str, + startIndex = start, + succinctBitVector = bitVector, + ) + } + if (previous == null) { + // Preserve the former one-character append semantics exactly. A larger jump is + // the cancellation catch-up path; build the missing frontier from its true start + // so no matches in the skipped characters are lost. + return if (str.length == reusable.input.length + 1) { + trie.advanceOmissionSearch( + states = listOf(LOUDSWithTermId.OmissionSearchState(0, false)), + char = str.last(), + succinctBitVector = bitVector, + ) + } else { + trie.commonPrefixSearchWithOmissionProgress( + str = str, + startIndex = start, + succinctBitVector = bitVector, + ) + } + } + var states: List = previous + val newResults = ArrayList() + for (index in reusable.input.length until str.length) { + val current = trie.advanceOmissionSearch( + states = states, + char = str[index], + succinctBitVector = bitVector, + ) + states = current.terminalStates + newResults.addAll(current.results) + if (states.isEmpty()) break + } + return LOUDSWithTermId.OmissionSearchProgress(newResults, states) + } val graph: MutableMap> = if (reusable != null) { val sessionGraph = reusable.graph as? IncrementalGraph if (sessionState != null && sessionGraph != null) { - // A session owns this graph exclusively. Keep the already-pruned lattice and - // extend it in place instead of cloning every node twice for every typed letter. - // Clear the published cache first so cancellation cannot expose a partial append. + // Keep two views of the completed prefix: the graph contains the beam-pruned DP + // view, while CachedGraph.unprunedPositions retains the complete lattice. Only + // the old input frontier can change its suffix/exact-input penalty after a + // one-character append, so restore that position and keep older DP results. + // A catch-up append cannot use the one-step path cache and therefore restores the + // complete lattice before FindPath performs a full DP pass. + sessionState.registerInPlaceAppend( + graph = sessionGraph, + previousReusedThroughEndIndex = sessionGraph.reusedThroughEndIndex, + previousForwardDpReusableThroughEndIndex = + sessionGraph.forwardDpReusableThroughEndIndex, + previousInputLength = reusablePrefixLength, + positionsToRestore = reusable.unprunedPositions?.keys.orEmpty(), + previousEosIndex = reusablePrefixLength + 1, + previousEos = sessionGraph[reusablePrefixLength + 1], + ) sessionState.cachedGraph = null sessionGraph.reusedThroughEndIndex = reusablePrefixLength sessionGraph.remove(reusablePrefixLength + 1) // previous EOS - // The previous final position was pruned while it still had a suffix penalty. - // It must be reconsidered after that penalty disappears on append. Retain only - // this one unpruned frontier, not a deep copy of the whole lattice. - reusable.unprunedFrontier?.let { frontier -> - sessionGraph[reusablePrefixLength] = frontier + + if ( + str.length == reusablePrefixLength + 1 && + !reusable.unprunedPositions.isNullOrEmpty() + ) { + sessionGraph.forwardDpReusableThroughEndIndex = reusablePrefixLength + reusable.unprunedPositions[reusablePrefixLength]?.let { frontierNodes -> + sessionGraph[reusablePrefixLength] = frontierNodes + } + } else { + sessionGraph.forwardDpReusableThroughEndIndex = -1 + reusable.unprunedPositions?.forEach { (position, nodes) -> + sessionGraph[position] = nodes + } } sessionGraph } else { @@ -326,6 +545,16 @@ class GraphBuilder { } else { IncrementalGraph(-1, signature).apply { put(0, mutableListOf(BOS)) } } + + fun invalidateForwardDpReuseAndRestoreCompletePrefix() { + val incrementalGraph = graph as? IncrementalGraph ?: return + if (incrementalGraph.forwardDpReusableThroughEndIndex < 0) return + reusable?.unprunedPositions?.forEach { (position, nodes) -> + incrementalGraph[position] = nodes + } + incrementalGraph.forwardDpReusableThroughEndIndex = -1 + } + graph[str.length + 1] = mutableListOf( Node( l = 0, @@ -351,7 +580,13 @@ class GraphBuilder { var foundInAnyDictionary = false // 1. ユーザー辞書 - val userWords = userDictionaryRepository?.commonPrefixSearchInUserDict(subStr()) ?: emptyList() + val userWords = userDictionaryRepository?.let { repository -> + if (reusable != null && str.length == reusable.input.length + 1) { + repository.exactMatchesForConversion(subStr()) + } else { + repository.commonPrefixSearchInUserDict(subStr()) + } + } ?: emptyList() if (userWords.isNotEmpty()) foundInAnyDictionary = true userWords.forEach { userWord -> val endIndex = i + userWord.reading.length @@ -373,7 +608,13 @@ class GraphBuilder { } // 2. 学習辞書 - val learnedWords = learnRepository?.findCommonPrefixes(subStr()) ?: emptyList() + val learnedWords = learnRepository?.let { repository -> + if (reusable != null && str.length == reusable.input.length + 1) { + repository.findExactMatchesForConversion(subStr()) + } else { + repository.findCommonPrefixes(subStr()) + } + } ?: emptyList() if (learnedWords.isNotEmpty()) foundInAnyDictionary = true learnedWords.forEach { learnedWord -> val endIndex = i + learnedWord.input.length @@ -465,34 +706,12 @@ class GraphBuilder { // 1.x システムユーザー辞書 (Typo Correction Prefix) if (enableTypoCorrectionJapaneseFlick && subStr().length > 2) { - val typoProgress = if (reusablePrefixLength >= 0) { - val previous = reusable?.systemUserTypoProgress?.get(i) - if (previous == null) { - localSystemUserYomiTrie.commonPrefixSearchWithTypoCorrectionProgress( - str = str, - startIndex = i, - succinctBitVector = localSystemUserLBSYomi, - maxResults = 98, - maxLen = 12, - ) - } else { - localSystemUserYomiTrie.advanceTypoCorrectionSearch( - previous = previous, - char = str.last(), - succinctBitVector = localSystemUserLBSYomi, - maxResults = 98, - maxLen = 12, - ) - } - } else { - localSystemUserYomiTrie.commonPrefixSearchWithTypoCorrectionProgress( - str = str, - startIndex = i, - succinctBitVector = localSystemUserLBSYomi, - maxResults = 98, - maxLen = 12, - ) - } + val typoProgress = typoSearch( + trie = localSystemUserYomiTrie, + bitVector = localSystemUserLBSYomi, + start = i, + previous = reusable?.systemUserTypoProgress?.get(i), + ) systemUserTypoProgress[i] = typoProgress val typoPrefixResults = typoProgress.results if (typoPrefixResults.isNotEmpty()) foundInAnyDictionary = true @@ -501,85 +720,65 @@ class GraphBuilder { if (typo.penaltyUsed == 0) continue val yomiStr = typo.yomi - val nodeIndex = localSystemUserYomiTrie.getNodeIndex( - yomiStr, - localSystemUserLBSYomi, - ) + val nodeIndex = typo.nodeIndex if (nodeIndex <= 0) continue val termId = localSystemUserYomiTrie.getTermId( nodeIndex, localSystemUserIsLeaf, ) - val listToken = localSystemUserTokenArray.getListDictionaryByYomiTermId( - termId, - localSystemUserTokenBitVector, - ) val endIndex = i + yomiStr.length if (endIndex <= reusablePrefixLength) continue val penalty = typoCorrectionOffsetScore * typo.penaltyUsed - listToken - .sortedBy { it.wordCost } - .take(5) - .forEach { token -> - val tango = when (token.nodeId) { - -2 -> yomiStr - -1 -> yomiStr.hiraToKata() - else -> localSystemUserTangoTrie.getLetter( - token.nodeId, - succinctBitVector = localSystemUserTangoLBS, - ) - } - val cost = token.wordCost.toInt() + penalty - addOrUpdateNode( - graph, - endIndex, - Node( - l = localSystemUserTokenArray.leftIds[token.posTableIndex.toInt()], - r = localSystemUserTokenArray.rightIds[token.posTableIndex.toInt()], - score = cost, - f = cost, - g = cost, - tango = tango, - yomiUsed = yomiStr, - len = yomiStr.length.toShort(), - sPos = i, - mozcAttributes = mozcAttributesFor( - localSystemUserTokenArray.leftIds[token.posTableIndex.toInt()], - ), - ), - graphNodeDedupMode, - graphNodeTrace, - str, - "SYSTEM_USER_TYPO", + localSystemUserTokenArray.forEachLowestCostDictionaryByYomiTermId( + nodeId = termId, + succinctBitVector = localSystemUserTokenBitVector, + scratchIndices = typoTopTokenScratch, + ) { posTableIndex, wordCost, tokenNodeId -> + val tango = when (tokenNodeId) { + -2 -> yomiStr + -1 -> yomiStr.hiraToKata() + else -> localSystemUserTangoTrie.getLetter( + tokenNodeId, + succinctBitVector = localSystemUserTangoLBS, ) } + val cost = wordCost.toInt() + penalty + addOrUpdateNode( + graph, + endIndex, + Node( + l = localSystemUserTokenArray.leftIds[posTableIndex.toInt()], + r = localSystemUserTokenArray.rightIds[posTableIndex.toInt()], + score = cost, + f = cost, + g = cost, + tango = tango, + yomiUsed = yomiStr, + len = yomiStr.length.toShort(), + sPos = i, + mozcAttributes = mozcAttributesFor( + localSystemUserTokenArray.leftIds[posTableIndex.toInt()], + ), + ), + graphNodeDedupMode, + graphNodeTrace, + str, + "SYSTEM_USER_TYPO", + ) + } } } // 1.y システムユーザー辞書 (Omission Search) if (isOmissionSearchEnable && !subStr().hasNConsecutiveChars(4)) { - val omissionProgress = if (reusablePrefixLength >= 0) { - val previousStates = reusable?.systemUserOmissionStates?.get(i) - ?: listOf( - LOUDSWithTermId.OmissionSearchState( - nodeIndex = 0, - omissionOccurred = false, - ) - ) - localSystemUserYomiTrie.advanceOmissionSearch( - states = previousStates, - char = str.last(), - succinctBitVector = localSystemUserLBSYomi, - ) - } else { - localSystemUserYomiTrie.commonPrefixSearchWithOmissionProgress( - str = str, - startIndex = i, - succinctBitVector = localSystemUserLBSYomi, - ) - } + val omissionProgress = omissionSearch( + trie = localSystemUserYomiTrie, + bitVector = localSystemUserLBSYomi, + start = i, + previous = reusable?.systemUserOmissionStates?.get(i), + ) systemUserOmissionStates[i] = omissionProgress.terminalStates val omissionSearchResults = omissionProgress.results if (omissionSearchResults.isNotEmpty()) foundInAnyDictionary = true @@ -687,34 +886,12 @@ class GraphBuilder { // 3.x システム辞書 (Typo Correction Prefix) if (enableTypoCorrectionJapaneseFlick && subStr().length > 2) { - val typoProgress = if (reusablePrefixLength >= 0) { - val previous = reusable?.systemTypoProgress?.get(i) - if (previous == null) { - yomiTrie.commonPrefixSearchWithTypoCorrectionProgress( - str = str, - startIndex = i, - succinctBitVector = succinctBitVectorLBSYomi, - maxResults = 98, - maxLen = 12, - ) - } else { - yomiTrie.advanceTypoCorrectionSearch( - previous = previous, - char = str.last(), - succinctBitVector = succinctBitVectorLBSYomi, - maxResults = 98, - maxLen = 12, - ) - } - } else { - yomiTrie.commonPrefixSearchWithTypoCorrectionProgress( - str = str, - startIndex = i, - succinctBitVector = succinctBitVectorLBSYomi, - maxResults = 98, - maxLen = 12, - ) - } + val typoProgress = typoSearch( + trie = yomiTrie, + bitVector = succinctBitVectorLBSYomi, + start = i, + previous = reusable?.systemTypoProgress?.get(i), + ) systemTypoProgress[i] = typoProgress val typoPrefixResults = typoProgress.results @@ -726,77 +903,61 @@ class GraphBuilder { if (typo.penaltyUsed == 0) continue val yomiStr = typo.yomi - val nodeIndex = yomiTrie.getNodeIndex(yomiStr, succinctBitVectorLBSYomi) + val nodeIndex = typo.nodeIndex if (nodeIndex <= 0) continue val termId = yomiTrie.getTermId(nodeIndex, succinctBitVectorIsLeafYomi) - val listToken = tokenArray.getListDictionaryByYomiTermId( - termId, - succinctBitVectorTokenArray - ) - val endIndex = i + yomiStr.length if (endIndex <= reusablePrefixLength) continue val penalty = typoCorrectionOffsetScore * typo.penaltyUsed - listToken - .sortedBy { it.wordCost } - .take(5) - .forEach { token -> - val tango = when (token.nodeId) { - -2 -> yomiStr - -1 -> yomiStr.hiraToKata() - else -> tangoTrie.getLetter(token.nodeId, succinctBitVectorTangoLBS) - } + tokenArray.forEachLowestCostDictionaryByYomiTermId( + nodeId = termId, + succinctBitVector = succinctBitVectorTokenArray, + scratchIndices = typoTopTokenScratch, + ) { posTableIndex, wordCost, tokenNodeId -> + val tango = when (tokenNodeId) { + -2 -> yomiStr + -1 -> yomiStr.hiraToKata() + else -> tangoTrie.getLetter(tokenNodeId, succinctBitVectorTangoLBS) + } - val cost = token.wordCost.toInt() + penalty + val cost = wordCost.toInt() + penalty - addOrUpdateNode( - graph, - endIndex, - Node( - l = tokenArray.leftIds[token.posTableIndex.toInt()], - r = tokenArray.rightIds[token.posTableIndex.toInt()], - score = cost, - f = cost, - g = cost, - tango = tango, - yomiUsed = yomiStr, - len = yomiStr.length.toShort(), - sPos = i, - mozcAttributes = mozcAttributesFor(tokenArray.leftIds[token.posTableIndex.toInt()]), + addOrUpdateNode( + graph, + endIndex, + Node( + l = tokenArray.leftIds[posTableIndex.toInt()], + r = tokenArray.rightIds[posTableIndex.toInt()], + score = cost, + f = cost, + g = cost, + tango = tango, + yomiUsed = yomiStr, + len = yomiStr.length.toShort(), + sPos = i, + mozcAttributes = mozcAttributesFor( + tokenArray.leftIds[posTableIndex.toInt()], ), - graphNodeDedupMode, - graphNodeTrace, - str, - "SYSTEM_TYPO", - ) - } + ), + graphNodeDedupMode, + graphNodeTrace, + str, + "SYSTEM_TYPO", + ) + } } } // 4. システム辞書 (Omission Search) if (isOmissionSearchEnable && !subStr().hasNConsecutiveChars(4)) { - val omissionProgress = if (reusablePrefixLength >= 0) { - val previousStates = reusable?.systemOmissionStates?.get(i) - ?: listOf( - LOUDSWithTermId.OmissionSearchState( - nodeIndex = 0, - omissionOccurred = false, - ) - ) - yomiTrie.advanceOmissionSearch( - states = previousStates, - char = str.last(), - succinctBitVector = succinctBitVectorLBSYomi, - ) - } else { - yomiTrie.commonPrefixSearchWithOmissionProgress( - str = str, - startIndex = i, - succinctBitVector = succinctBitVectorLBSYomi, - ) - } + val omissionProgress = omissionSearch( + trie = yomiTrie, + bitVector = succinctBitVectorLBSYomi, + start = i, + previous = reusable?.systemOmissionStates?.get(i), + ) systemOmissionStates[i] = omissionProgress.terminalStates val omissionSearchResults: List = omissionProgress.results if (omissionSearchResults.isNotEmpty()) foundInAnyDictionary = true @@ -1053,6 +1214,17 @@ class GraphBuilder { // prefix. Remove the now-invalid unknown fallback retained by the incremental graph. if (foundInAnyDictionary && reusablePrefixLength >= 0) { val fallbackEndIndex = i + 1 + val removesStablePrefixNode = + fallbackEndIndex < reusablePrefixLength && + graph[fallbackEndIndex].orEmpty().any { + it.candidateSource == CandidateSource.UNKNOWN && it.sPos == i + } + if (removesStablePrefixNode) { + // A dictionary match completed by the appended character can invalidate an + // older unknown fallback. Restore the complete prefix and deliberately fall + // back to full DP for this uncommon transition. + invalidateForwardDpReuseAndRestoreCompletePrefix() + } graph[fallbackEndIndex]?.removeAll { it.candidateSource == CandidateSource.UNKNOWN && it.sPos == i } @@ -1118,14 +1290,40 @@ class GraphBuilder { webPrefixStates = webPrefixStates, personPrefixStates = personPrefixStates, neologdPrefixStates = neologdPrefixStates, - unprunedFrontier = if (sessionState != null) { - graph[str.length]?.toMutableList() + unprunedPositions = if (sessionState != null) { + val incrementalGraph = graph as? IncrementalGraph + if ( + reusable != null && + incrementalGraph?.forwardDpReusableThroughEndIndex == reusablePrefixLength + ) { + // Older positions intentionally remain pruned in the active DP view. Carry + // their complete lattice lists forward without publishing them back into the + // graph; the previous frontier and new suffix are currently unpruned. + LinkedHashMap>().apply { + reusable.unprunedPositions?.forEach { (endIndex, nodes) -> + if (endIndex < reusablePrefixLength) put(endIndex, nodes) + } + graph.entries.forEach { (endIndex, nodes) -> + if (endIndex in reusablePrefixLength..str.length) { + put(endIndex, nodes.toMutableList()) + } + } + } + } else { + graph.entries + .asSequence() + .filter { (endIndex, _) -> endIndex <= str.length } + .associateTo(LinkedHashMap()) { (endIndex, nodes) -> + endIndex to nodes.toMutableList() + } + } } else { null }, ) if (sessionState != null) { sessionState.cachedGraph = updatedCache + sessionState.commitCompletedGraph(updatedCache) if (performanceStartNs != 0L) { sessionState.lastConstructGraphNs = System.nanoTime() - performanceStartNs } @@ -1164,6 +1362,7 @@ class GraphBuilder { enableTypoCorrectionJapaneseFlick: Boolean, typoCorrectionOffsetScore: Int, omissionSearchOffSetScore: Int, + beamWidth: Int, graphNodeDedupMode: GraphNodeDedupMode, mozcNodeAttributeTable: MozcNodeAttributeTable?, ): Int { @@ -1181,6 +1380,7 @@ class GraphBuilder { result = 31 * result + enableTypoCorrectionJapaneseFlick.hashCode() result = 31 * result + typoCorrectionOffsetScore result = 31 * result + omissionSearchOffSetScore + result = 31 * result + beamWidth result = 31 * result + graphNodeDedupMode.hashCode() result = 31 * result + System.identityHashCode(mozcNodeAttributeTable) return result diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/graph/IncrementalGraphMetadata.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/graph/IncrementalGraphMetadata.kt index fadce0c4d..a7df6b308 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/graph/IncrementalGraphMetadata.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/graph/IncrementalGraphMetadata.kt @@ -3,5 +3,7 @@ package com.kazumaproject.markdownhelperkeyboard.converter.graph /** Metadata carried only by graphs built from a one-character append. */ interface IncrementalGraphMetadata { val reusedThroughEndIndex: Int + val forwardDpReusableThroughEndIndex: Int + get() = reusedThroughEndIndex val conversionSignature: Int } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/graph/TypoCorrectionResult.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/graph/TypoCorrectionResult.kt index d529baab0..5f01debbe 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/graph/TypoCorrectionResult.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/graph/TypoCorrectionResult.kt @@ -1,3 +1,8 @@ package com.kazumaproject.markdownhelperkeyboard.converter.graph -data class TypoCorrectionResult(val yomi: String, val penaltyUsed: Int) +data class TypoCorrectionResult( + val yomi: String, + val penaltyUsed: Int, + /** LOUDS bit position reached by this result. Avoids traversing [yomi] a second time. */ + val nodeIndex: Int = -1, +) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/louds/with_term_id/LOUDSWithTermId.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/louds/with_term_id/LOUDSWithTermId.kt index bc5c866ae..0c51eddd1 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/louds/with_term_id/LOUDSWithTermId.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/louds/with_term_id/LOUDSWithTermId.kt @@ -882,74 +882,51 @@ class LOUDSWithTermId { return TypoSearchProgress(emptyList(), emptyList(), emptyMap()) } - // 同一yomiの重複を最小penaltyで集約 - val bestPenaltyByYomi = HashMap(128) - val sb = StringBuilder(maxLen) - val terminalStates = ArrayList() - - fun acceptIfLeaf(nodeIndex: Int, penaltyUsed: Int) { - if (sb.isEmpty()) return - if (!isLeaf[nodeIndex]) return - - val yomi = sb.toString() - val prev = bestPenaltyByYomi[yomi] - if (prev == null || penaltyUsed < prev) { - bestPenaltyByYomi[yomi] = penaltyUsed - } - } - - fun dfs(strIndex: Int, nodeIndex: Int, penaltyUsed: Int) { - if (penaltyUsed > maxPenalty) return - if (sb.length > maxLen) return - if (bestPenaltyByYomi.size >= maxResults) return - - // ★ prefix検索: 途中でも leaf なら採用 - acceptIfLeaf(nodeIndex, penaltyUsed) - - // 入力を使い切ったら終了(prefixなのでここで止める) - if (strIndex >= str.length) { - terminalStates.add( - TypoSearchState(nodeIndex, penaltyUsed, sb.length, sb.toString()) - ) - return - } - if (sb.length >= maxLen) return - - val ch = str[strIndex] - - // ★ 候補をペナルティ昇順で展開(枝刈り) - val candidates: List = getTypoCandidates(ch) - - for (cand in candidates) { - val nextPenalty = penaltyUsed + cand.penalty - if (nextPenalty > maxPenalty) continue - - var childPos = firstChild(nodeIndex, succinctBitVector) - while (childPos >= 0 && LBS[childPos]) { - val labelNodeId = succinctBitVector.rank1(childPos) - if (labelNodeId < labelCount && labelAt(labelNodeId) == cand.ch) { - sb.append(cand.ch) - dfs(strIndex + 1, childPos, nextPenalty) - sb.setLength(sb.length - 1) - break - } - childPos++ + // Use the exact same character-at-a-time transition as an incremental append. The old + // cold path used depth-first traversal while append used frontier traversal; once the + // bounded result set reached maxResults, they retained different typo paths and could + // produce different first candidates for the same input. + var progress = TypoSearchProgress( + results = emptyList(), + terminalStates = listOf(TypoSearchState(0, 0, 0, "")), + acceptedPenalties = emptyMap(), + ) + val acceptedResults = LinkedHashMap() + val acceptedNodeIndices = HashMap() + val endExclusive = minOf(str.length, startIndex + maxLen) + for (index in startIndex until endExclusive) { + progress = advanceTypoCorrectionSearch( + previous = progress, + char = str[index], + succinctBitVector = succinctBitVector, + maxPenalty = maxPenalty, + maxLen = maxLen, + maxResults = maxResults, + ) + progress.results.forEach { result -> + val previousPenalty = acceptedResults[result.yomi] + if (previousPenalty == null || result.penaltyUsed < previousPenalty) { + acceptedResults[result.yomi] = result.penaltyUsed + acceptedNodeIndices[result.yomi] = result.nodeIndex } } + if (progress.terminalStates.isEmpty()) break } - // ルート開始 - dfs(strIndex = startIndex, nodeIndex = 0, penaltyUsed = 0) - - // 出力整形: penalty昇順 → 長さ降順(好み)→ 文字列昇順 - val results = bestPenaltyByYomi.entries + val results = acceptedResults.entries .sortedWith( compareBy> { it.value } .thenByDescending { it.key.length } .thenBy { it.key } ) - .map { TypoCorrectionResult(it.key, it.value) } - return TypoSearchProgress(results, terminalStates, bestPenaltyByYomi) + .map { + TypoCorrectionResult( + yomi = it.key, + penaltyUsed = it.value, + nodeIndex = checkNotNull(acceptedNodeIndices[it.key]), + ) + } + return progress.copy(results = results) } fun advanceTypoCorrectionSearch( @@ -966,6 +943,7 @@ class LOUDSWithTermId { val bestPenaltyByYomi = HashMap(previous.acceptedPenalties) val terminalStates = ArrayList() val newResults = LinkedHashMap() + val newResultNodeIndices = HashMap() val typoCandidates = getTypoCandidates(char) for (state in previous.terminalStates) { if (bestPenaltyByYomi.size >= maxResults) break @@ -982,6 +960,7 @@ class LOUDSWithTermId { if (oldPenalty == null || nextPenalty < oldPenalty) { bestPenaltyByYomi[yomi] = nextPenalty newResults[yomi] = nextPenalty + newResultNodeIndices[yomi] = child } } } @@ -991,7 +970,13 @@ class LOUDSWithTermId { .thenByDescending { it.key.length } .thenBy { it.key } ) - .map { TypoCorrectionResult(it.key, it.value) } + .map { + TypoCorrectionResult( + yomi = it.key, + penaltyUsed = it.value, + nodeIndex = checkNotNull(newResultNodeIndices[it.key]), + ) + } return TypoSearchProgress(results, terminalStates, bestPenaltyByYomi) } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/path_algorithm/ExactInputPenaltyPolicy.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/path_algorithm/ExactInputPenaltyPolicy.kt index ddbfeb799..0c5432a9b 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/path_algorithm/ExactInputPenaltyPolicy.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/path_algorithm/ExactInputPenaltyPolicy.kt @@ -6,7 +6,6 @@ internal object ExactInputPenaltyPolicy { private val zeroPrefixPenaltyWords: Set = setOf( "にて", "しか", - "ての", ) fun shouldZeroPrefixPenalty( diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/path_algorithm/FindPath.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/path_algorithm/FindPath.kt index 3783e86de..d947b8719 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/path_algorithm/FindPath.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/path_algorithm/FindPath.kt @@ -11,6 +11,7 @@ import com.kazumaproject.markdownhelperkeyboard.converter.candidate.BunsetsuCand import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_LEARNED_DICTIONARY import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_USER_DICTIONARY import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate +import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CandidateConversionSegment import com.kazumaproject.markdownhelperkeyboard.converter.graph.IncrementalGraphMetadata import com.kazumaproject.markdownhelperkeyboard.converter.mozc.MozcBoundaryCheckResult import com.kazumaproject.markdownhelperkeyboard.converter.mozc.MozcBoundaryChecker @@ -59,8 +60,40 @@ class FindPath( internal var lastStateRejectionCount: Int = 0 internal var lastExpansionCacheHitCount: Int = 0 internal var lastExpansionCacheMissCount: Int = 0 + internal var lastForwardDpReused: Boolean = false + internal var afterForwardDpForTest: (() -> Unit)? = null + + private var queryCheckpoint: QueryCheckpoint? = null + + private data class QueryCheckpoint( + val forwardDpCache: ForwardDpCache?, + val penaltyCache: PenaltyCache?, + ) + + internal fun beginQueryTransaction() { + check(queryCheckpoint == null) { "Path query transaction is already active" } + queryCheckpoint = QueryCheckpoint( + forwardDpCache = forwardDpCache, + penaltyCache = penaltyCache, + ) + } + + internal fun commitQueryTransaction() { + queryCheckpoint = null + } + + internal fun rollbackQueryTransaction() { + val checkpoint = queryCheckpoint ?: return + forwardDpCache = checkpoint.forwardDpCache + penaltyCache = checkpoint.penaltyCache + queryCheckpoint = null + // Queue/path arenas are query-local. They are cleared at the next begin(), while + // node ids and expansion entries for the committed prefix remain reusable. + backwardSearchScratch.discardInFlightWork() + } internal fun reset() { + queryCheckpoint = null forwardDpCache = null penaltyCache = null backwardSearchScratch.reset() @@ -71,6 +104,8 @@ class FindPath( lastStateRejectionCount = 0 lastExpansionCacheHitCount = 0 lastExpansionCacheMissCount = 0 + lastForwardDpReused = false + afterForwardDpForTest = null } } @@ -314,6 +349,11 @@ class FindPath( nodeIds.clear() expansionCache.reset() queueElementPool.clear() + } else { + // Expansion keys include the predecessor-list identity. A one-character append + // replaces the restored frontier list, while older pruned lists retain their + // identity, so only the changed frontier and new suffix miss the cache. + expansionCache.prepareForIncrementalQuery() } expansionCache.configure( connectionMatrix = connectionMatrix, @@ -344,6 +384,17 @@ class FindPath( expansionCacheMissCount = 0 } + fun discardInFlightWork() { + queue.clear() + bestBackwardCostByState.clear() + bestSplitCostsByState.clear() + charPathArena.reset() + queueElementCount = 0 + stateRejectionCount = 0 + expansionCacheHitCount = 0 + expansionCacheMissCount = 0 + } + fun queueElementCount(): Int = queueElementCount fun recordStateRejection() { @@ -436,6 +487,7 @@ class FindPath( private var nextNode2 = IntArray(currentNode.size) private var nextNode3 = IntArray(currentNode.size) private var adjustedScore = IntArray(currentNode.size) + private var predecessorLists = arrayOfNulls>(currentNode.size) private var values = arrayOfNulls(currentNode.size) private var stamps = IntArray(currentNode.size) private var generation = 1 @@ -479,6 +531,7 @@ class FindPath( fun reset() { clear() + predecessorLists.fill(null) values.fill(null) configured = false configuredConnectionMatrix = null @@ -487,22 +540,37 @@ class FindPath( configuredBoundaryMode = null } + fun prepareForIncrementalQuery() { + if (entryCount < MAX_RETAINED_ENTRY_COUNT) return + clear() + predecessorLists.fill(null) + values.fill(null) + } + fun get( currentNode: Int, nextNode1: Int, nextNode2: Int, nextNode3: Int, adjustedScore: Int, + predecessorList: List, ): ExpansionList? { - var slot = hash(currentNode, nextNode1, nextNode2, nextNode3, adjustedScore) and - (stamps.size - 1) + var slot = hash( + currentNode, + nextNode1, + nextNode2, + nextNode3, + adjustedScore, + predecessorList, + ) and (stamps.size - 1) while (stamps[slot] == generation) { if ( this.currentNode[slot] == currentNode && this.nextNode1[slot] == nextNode1 && this.nextNode2[slot] == nextNode2 && this.nextNode3[slot] == nextNode3 && - this.adjustedScore[slot] == adjustedScore + this.adjustedScore[slot] == adjustedScore && + this.predecessorLists[slot] === predecessorList ) return values[slot] slot = (slot + 1) and (stamps.size - 1) } @@ -515,18 +583,26 @@ class FindPath( nextNode2: Int, nextNode3: Int, adjustedScore: Int, + predecessorList: List, value: ExpansionList, ) { if ((entryCount + 1) * 10 >= stamps.size * 6) grow() - var slot = hash(currentNode, nextNode1, nextNode2, nextNode3, adjustedScore) and - (stamps.size - 1) + var slot = hash( + currentNode, + nextNode1, + nextNode2, + nextNode3, + adjustedScore, + predecessorList, + ) and (stamps.size - 1) while (stamps[slot] == generation) { if ( this.currentNode[slot] == currentNode && this.nextNode1[slot] == nextNode1 && this.nextNode2[slot] == nextNode2 && this.nextNode3[slot] == nextNode3 && - this.adjustedScore[slot] == adjustedScore + this.adjustedScore[slot] == adjustedScore && + this.predecessorLists[slot] === predecessorList ) { values[slot] = value return @@ -539,6 +615,7 @@ class FindPath( this.nextNode2[slot] = nextNode2 this.nextNode3[slot] = nextNode3 this.adjustedScore[slot] = adjustedScore + this.predecessorLists[slot] = predecessorList values[slot] = value entryCount++ } @@ -549,6 +626,7 @@ class FindPath( val oldNextNode2 = nextNode2 val oldNextNode3 = nextNode3 val oldAdjustedScore = adjustedScore + val oldPredecessorLists = predecessorLists val oldValues = values val oldStamps = stamps val oldGeneration = generation @@ -558,6 +636,7 @@ class FindPath( nextNode2 = IntArray(currentNode.size) nextNode3 = IntArray(currentNode.size) adjustedScore = IntArray(currentNode.size) + predecessorLists = arrayOfNulls(currentNode.size) values = arrayOfNulls(currentNode.size) stamps = IntArray(currentNode.size) generation = 1 @@ -570,6 +649,7 @@ class FindPath( nextNode2 = oldNextNode2[slot], nextNode3 = oldNextNode3[slot], adjustedScore = oldAdjustedScore[slot], + predecessorList = checkNotNull(oldPredecessorLists[slot]), value = checkNotNull(oldValues[slot]), ) } @@ -589,14 +669,18 @@ class FindPath( nextNode2: Int, nextNode3: Int, adjustedScore: Int, + predecessorList: List, ): Int { var result = currentNode result = 31 * result + nextNode1 result = 31 * result + nextNode2 result = 31 * result + nextNode3 result = 31 * result + adjustedScore + result = 31 * result + System.identityHashCode(predecessorList) return result xor (result ushr 16) } + + const val MAX_RETAINED_ENTRY_COUNT = 32_768 } } @@ -667,6 +751,37 @@ class FindPath( return true } + fun containsExactCost( + nodeIdentity: Int, + nextIdentity1: Int, + nextIdentity2: Int, + nextIdentity3: Int, + outputPathId: Int, + sourceMask: Int, + cost: Int, + ): Boolean { + var slot = hash( + nodeIdentity, + nextIdentity1, + nextIdentity2, + nextIdentity3, + outputPathId, + sourceMask, + ) and (stamps.size - 1) + while (stamps[slot] == generation) { + if ( + this.nodeIdentity[slot] == nodeIdentity && + this.nextIdentity1[slot] == nextIdentity1 && + this.nextIdentity2[slot] == nextIdentity2 && + this.nextIdentity3[slot] == nextIdentity3 && + this.sourceMask[slot] == sourceMask && + this.outputPathId[slot] == outputPathId + ) return costs[slot] == cost + slot = (slot + 1) and (stamps.size - 1) + } + return false + } + private fun grow() { val oldNodeIdentity = nodeIdentity val oldNextIdentity1 = nextIdentity1 @@ -928,6 +1043,7 @@ class FindPath( beamWidth: Int = 20, cancellationCheck: () -> Unit = {}, sessionState: SessionState? = null, + candidateSegmentCollector: MutableMap>? = null, ): MutableList = backwardAStar( graph = graph, length = length, @@ -936,6 +1052,7 @@ class FindPath( beamWidth = beamWidth, cancellationCheck = cancellationCheck, sessionState = sessionState, + candidateSegmentCollector = candidateSegmentCollector, ) fun backwardAStar( @@ -946,6 +1063,7 @@ class FindPath( beamWidth: Int = 20, cancellationCheck: () -> Unit = {}, sessionState: SessionState? = null, + candidateSegmentCollector: MutableMap>? = null, ): MutableList { cancellationCheck() val effectiveBeamWidth = beamWidth.coerceAtLeast(1) @@ -953,12 +1071,15 @@ class FindPath( val activeCache = if (sessionState != null) sessionState.forwardDpCache else forwardDpCache val reusableForwardDp = activeCache?.takeIf { cached -> incrementalMetadata != null && - incrementalMetadata.reusedThroughEndIndex == cached.inputLength && + incrementalMetadata.forwardDpReusableThroughEndIndex == cached.inputLength && incrementalMetadata.conversionSignature == cached.conversionSignature && length == cached.inputLength + 1 && cached.connectionMatrixIdentity == System.identityHashCode(connectionMatrix) && cached.beamWidth == effectiveBeamWidth } + if (sessionState?.performanceProbeEnabled == true) { + sessionState.lastForwardDpReused = reusableForwardDp != null + } val forwardDpStartPosition = if (reusableForwardDp != null) { reusableForwardDp.nodesBeforePreviousEnd.forEach { (position, nodes) -> graph[position] = nodes @@ -975,6 +1096,7 @@ class FindPath( startPosition = forwardDpStartPosition, cancellationCheck = cancellationCheck, ) + sessionState?.afterForwardDpForTest?.invoke() val updatedCache = incrementalMetadata?.let { ForwardDpCache( inputLength = length, @@ -1032,12 +1154,17 @@ class FindPath( if (cancellationPollCounter++ and 0x3f == 0) cancellationCheck() val element = pQueue.poll() ?: break val currentNode = element.node + if (!isCurrentBestBackwardState(element, searchScratch)) continue if (currentNode.tango == "BOS") { val stringFromNode = searchScratch.outputString(element.outputPathId) val yomiUsedFromNode = getYomiUsedFromPath(element) if (foundStrings.add(stringFromNode)) { + candidateSegmentCollector?.set( + stringFromNode, + getConversionSegmentsFromPath(element), + ) val candidate = Candidate( string = stringFromNode, type = resolveCandidateType( @@ -1261,23 +1388,24 @@ class FindPath( } else { -1 } + val previousNodes = getPrevNodes2( + graph = graph, + node = currentNode, + startPosition = currentNode.sPos, + ) scratch.expansionCache.get( currentNode = currentNodeId, nextNode1 = nextNode1Id, nextNode2 = nextNode2Id, nextNode3 = nextNode3Id, adjustedScore = currentNode.adjustedScore, + predecessorList = previousNodes, )?.let { scratch.expansionCacheHitCount++ return it } scratch.expansionCacheMissCount++ - val previousNodes = getPrevNodes2( - graph = graph, - node = currentNode, - startPosition = currentNode.sPos, - ) val cachedNodes = arrayOfNulls(previousNodes.size) val localCosts = IntArray(previousNodes.size) var count = 0 @@ -1311,6 +1439,7 @@ class FindPath( nextNode2 = nextNode2Id, nextNode3 = nextNode3Id, adjustedScore = currentNode.adjustedScore, + predecessorList = previousNodes, value = result, ) } @@ -1357,6 +1486,23 @@ class FindPath( ) } + private fun isCurrentBestBackwardState( + element: PathQueueElement, + scratch: BackwardSearchScratch, + ): Boolean { + val next = element.next ?: return true // The initial EOS element is not in the state table. + val nodeIds = scratch.nodeIds + return scratch.bestBackwardCostByState.containsExactCost( + nodeIdentity = nodeIds.getValue(element.node), + nextIdentity1 = nodeIds.getValue(next.node), + nextIdentity2 = next.next?.node?.let(nodeIds::getValue) ?: -1, + nextIdentity3 = next.next?.next?.node?.let(nodeIds::getValue) ?: -1, + outputPathId = element.outputPathId, + sourceMask = element.sourceMask, + cost = element.backwardCost, + ) + } + private fun getEdgeCost( rid: Int, lid: Int, @@ -1453,6 +1599,7 @@ class FindPath( candidateTrace: MutableList? = null, cancellationCheck: () -> Unit = {}, sessionState: SessionState? = null, + candidateSegmentCollector: MutableMap>? = null, ): BunsetsuCandidateResult = backwardAStarWithBunsetsu( graph = graph, length = length, @@ -1465,6 +1612,7 @@ class FindPath( candidateTrace = candidateTrace, cancellationCheck = cancellationCheck, sessionState = sessionState, + candidateSegmentCollector = candidateSegmentCollector, ) fun backwardAStarWithBunsetsu( @@ -1479,6 +1627,7 @@ class FindPath( candidateTrace: MutableList? = null, cancellationCheck: () -> Unit = {}, sessionState: SessionState? = null, + candidateSegmentCollector: MutableMap>? = null, ): BunsetsuCandidateResult { cancellationCheck() val performanceState = sessionState?.takeIf { it.performanceProbeEnabled } @@ -1530,12 +1679,15 @@ class FindPath( val reusableForwardDp = activeCache?.takeIf { cached -> forwardDpTrace == null && incrementalMetadata != null && - incrementalMetadata.reusedThroughEndIndex == cached.inputLength && + incrementalMetadata.forwardDpReusableThroughEndIndex == cached.inputLength && incrementalMetadata.conversionSignature == cached.conversionSignature && length == cached.inputLength + 1 && cached.connectionMatrixIdentity == System.identityHashCode(connectionMatrix) && cached.beamWidth == effectiveBeamWidth } + if (sessionState?.performanceProbeEnabled == true) { + sessionState.lastForwardDpReused = reusableForwardDp != null + } val forwardDpStartPosition = if (reusableForwardDp != null) { reusableForwardDp.nodesBeforePreviousEnd.forEach { (position, nodes) -> graph[position] = nodes @@ -1562,6 +1714,7 @@ class FindPath( if (performanceState != null) { performanceState.lastForwardDpNs = System.nanoTime() - forwardDpStartNs } + sessionState?.afterForwardDpForTest?.invoke() val updatedCache = if (incrementalMetadata != null) { ForwardDpCache( @@ -1676,6 +1829,7 @@ class FindPath( if (cancellationPollCounter++ and 0x3f == 0) cancellationCheck() val element = pQueue.poll() ?: break val currentNode = element.node + if (!isCurrentBestBackwardState(element, searchScratch)) continue if (currentNode.tango == "BOS") { val stringFromNode = searchScratch.outputString(element.outputPathId) @@ -1691,6 +1845,10 @@ class FindPath( ) if (foundStrings.add(stringFromNode)) { + candidateSegmentCollector?.set( + stringFromNode, + getConversionSegmentsFromPath(element), + ) if (pathMatchesSystemNgram(element, systemNgramDictionary)) { systemNgramMatchedCandidates.add(stringFromNode) } @@ -2416,6 +2574,30 @@ class FindPath( return result } + private fun getConversionSegmentsFromPath( + path: PathQueueElement, + ): List { + val result = mutableListOf() + var currentPosition = 0 + var current: PathQueueElement? = path + while (current != null) { + val node = current.node + if (node.tango != "BOS" && node.tango != "EOS") { + val nextPosition = currentPosition + node.len.toInt() + result.add( + CandidateConversionSegment( + inputStart = currentPosition, + inputEnd = nextPosition, + output = node.tango, + ), + ) + currentPosition = nextPosition + } + current = current.next + } + return result + } + private fun getBunsetsuPositionsFromPath( path: PathQueueElement, ): List { diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/session/KanaKanjiConversionSession.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/session/KanaKanjiConversionSession.kt index 1f7539d72..ef5c2d89c 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/session/KanaKanjiConversionSession.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/session/KanaKanjiConversionSession.kt @@ -2,10 +2,12 @@ package com.kazumaproject.markdownhelperkeyboard.converter.session import com.kazumaproject.markdownhelperkeyboard.converter.candidate.BunsetsuCandidateResult import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate +import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CandidateConversionSegment import com.kazumaproject.markdownhelperkeyboard.converter.engine.KanaKanjiEngine import com.kazumaproject.markdownhelperkeyboard.converter.engine.PredictionConfig import com.kazumaproject.markdownhelperkeyboard.repository.LearnRepository import com.kazumaproject.markdownhelperkeyboard.repository.UserDictionaryRepository +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -40,11 +42,13 @@ data class KanaKanjiQueryRequest( val omissionSearchOffsetScore: Int, val beamWidth: Int, val predictionConfig: PredictionConfig = PredictionConfig(), + val collectCandidateSegments: Boolean = false, ) data class KanaKanjiQueryResult( val candidates: List, val bunsetsuResult: BunsetsuCandidateResult? = null, + val candidateSegmentsByString: Map> = emptyMap(), ) /** @@ -72,9 +76,16 @@ class KanaKanjiConversionSession( internal fun performanceSnapshot(): KanaKanjiEngine.IncrementalPerformanceSnapshot? = incrementalState?.performanceSnapshot() + internal fun committedInput(): String? = incrementalState?.committedInput() + + internal fun setAfterForwardDpForTest(callback: (() -> Unit)?) { + incrementalState?.pathState?.afterForwardDpForTest = callback + } + suspend fun query(request: KanaKanjiQueryRequest): KanaKanjiQueryResult = mutex.withLock { + incrementalState?.beginQueryTransaction() try { - when (request.mode) { + val result = when (request.mode) { CandidateQueryMode.EISUKANA -> KanaKanjiQueryResult( candidates = engine.getCandidatesEnglishKana( input = request.input, @@ -86,16 +97,24 @@ class KanaKanjiConversionSession( CandidateQueryMode.PREDICTION -> queryPrediction(request) CandidateQueryMode.CONVERSION -> queryConversion(request) } + incrementalState?.commitQueryTransaction() + result + } catch (cancellation: CancellationException) { + // A completed graph has its own staged commit. Keep that newest frontier when only the + // later path search was cancelled; an incomplete append still rolls back entirely. + incrementalState?.rollbackQueryTransaction() + throw cancellation } catch (throwable: Throwable) { - // Graph construction and forward DP update state in place. A collectLatest - // cancellation (or any failure) must make the next request rebuild atomically. + // Unexpected engine/repository failures may violate invariants outside the tracked + // append delta. Prefer a clean rebuild for those rare failures. incrementalState?.reset() throw throwable } } - private suspend fun queryOriginal(request: KanaKanjiQueryRequest): KanaKanjiQueryResult = - if (request.bunsetsuSeparation) { + private suspend fun queryOriginal(request: KanaKanjiQueryRequest): KanaKanjiQueryResult { + val segmentCollector = request.newCandidateSegmentCollector() + return if (request.bunsetsuSeparation) { engine.getCandidatesOriginalWithBunsetsu( input = request.input, n = request.n, @@ -114,7 +133,8 @@ class KanaKanjiConversionSession( beamWidth = request.beamWidth, incrementalSessionState = incrementalState, predictionConfig = request.predictionConfig, - ).asQueryResult() + candidateSegmentCollector = segmentCollector, + ).asQueryResult(segmentCollector) } else { KanaKanjiQueryResult( candidates = engine.getCandidatesOriginal( @@ -135,12 +155,16 @@ class KanaKanjiConversionSession( beamWidth = request.beamWidth, incrementalSessionState = incrementalState, predictionConfig = request.predictionConfig, + candidateSegmentCollector = segmentCollector, ), + candidateSegmentsByString = segmentCollector.orEmpty(), ) } + } - private suspend fun queryPrediction(request: KanaKanjiQueryRequest): KanaKanjiQueryResult = - if (request.bunsetsuSeparation) { + private suspend fun queryPrediction(request: KanaKanjiQueryRequest): KanaKanjiQueryResult { + val segmentCollector = request.newCandidateSegmentCollector() + return if (request.bunsetsuSeparation) { engine.getCandidatesWithBunsetsuSeparation( input = request.input, n = request.n, @@ -159,7 +183,8 @@ class KanaKanjiConversionSession( beamWidth = request.beamWidth, incrementalSessionState = incrementalState, predictionConfig = request.predictionConfig, - ).asQueryResult() + candidateSegmentCollector = segmentCollector, + ).asQueryResult(segmentCollector) } else { KanaKanjiQueryResult( candidates = engine.getCandidates( @@ -180,12 +205,16 @@ class KanaKanjiConversionSession( beamWidth = request.beamWidth, incrementalSessionState = incrementalState, predictionConfig = request.predictionConfig, + candidateSegmentCollector = segmentCollector, ), + candidateSegmentsByString = segmentCollector.orEmpty(), ) } + } - private suspend fun queryConversion(request: KanaKanjiQueryRequest): KanaKanjiQueryResult = - if (request.bunsetsuSeparation) { + private suspend fun queryConversion(request: KanaKanjiQueryRequest): KanaKanjiQueryResult { + val segmentCollector = request.newCandidateSegmentCollector() + return if (request.bunsetsuSeparation) { engine.getCandidatesWithoutPredictionWithBunsetsu( input = request.input, n = request.n, @@ -204,7 +233,8 @@ class KanaKanjiConversionSession( japanesePredictionEnabled = false, englishPredictionEnabled = false, ), - ).asQueryResult() + candidateSegmentCollector = segmentCollector, + ).asQueryResult(segmentCollector) } else { KanaKanjiQueryResult( candidates = engine.getCandidatesWithoutPrediction( @@ -225,10 +255,22 @@ class KanaKanjiConversionSession( japanesePredictionEnabled = false, englishPredictionEnabled = false, ), + candidateSegmentCollector = segmentCollector, ), + candidateSegmentsByString = segmentCollector.orEmpty(), ) } + } + + private fun KanaKanjiQueryRequest.newCandidateSegmentCollector(): + MutableMap>? = + if (collectCandidateSegments) LinkedHashMap() else null - private fun BunsetsuCandidateResult.asQueryResult(): KanaKanjiQueryResult = - KanaKanjiQueryResult(candidates = candidates, bunsetsuResult = this) + private fun BunsetsuCandidateResult.asQueryResult( + segmentCollector: Map>?, + ): KanaKanjiQueryResult = KanaKanjiQueryResult( + candidates = candidates, + bunsetsuResult = this, + candidateSegmentsByString = segmentCollector.orEmpty(), + ) } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/data/KeyDefinition.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/data/KeyDefinition.kt index 7437648e9..7f8787d6a 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/data/KeyDefinition.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/data/KeyDefinition.kt @@ -41,5 +41,7 @@ data class KeyDefinition( val columnUnits: Int? = null, val rowSpanUnits: Int? = null, val columnSpanUnits: Int? = null, - val specialKeyColorStyle: String = SpecialKeyColorStyle.SPECIAL.dbValue + val specialKeyColorStyle: String = SpecialKeyColorStyle.SPECIAL.dbValue, + val doubleTapAction: String? = null, + val doubleTapPolicy: String? = null ) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardBackupPipeline.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardBackupPipeline.kt index b938725d8..45f4842f3 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardBackupPipeline.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardBackupPipeline.kt @@ -3,14 +3,17 @@ package com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export import com.google.gson.Gson import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.kazumaproject.custom_keyboard.data.DoubleTapPolicy import com.google.gson.reflect.TypeToken import com.kazumaproject.custom_keyboard.data.CircularFlickDirection import com.kazumaproject.custom_keyboard.data.FlickDirection +import com.kazumaproject.custom_keyboard.data.KeyActionMapper import com.kazumaproject.custom_keyboard.data.KeyType import com.kazumaproject.custom_keyboard.data.KeyIconBuiltInDrawable import com.kazumaproject.custom_keyboard.data.KeyIconType import com.kazumaproject.custom_keyboard.data.KeyboardLayoutUsageMode import com.kazumaproject.custom_keyboard.data.SpecialKeyColorStyle +import com.kazumaproject.custom_keyboard.data.automaticDoubleTapPolicy import com.kazumaproject.custom_keyboard.view.TfbiFlickDirection import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.CircularFlickMapping import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.CustomKeyboardLayout @@ -301,6 +304,39 @@ object KeyboardBackupNormalizer { ) } + val normalizedTapAction = remapKeyAction( + action = keyDto.action, + stableIdsByOldLayoutId = stableIdsByOldLayoutId, + layoutIndex = layoutIndex, + keyIndex = keyIndex, + warnings = warnings + ) + val isSpecialKey = keyDto.isSpecialKey ?: false + val normalizedDoubleTapAction = when { + !isSpecialKey -> null + keyDto.doubleTapEnabled == false -> null + keyDto.doubleTapAction != null -> remapKeyAction( + action = keyDto.doubleTapAction, + stableIdsByOldLayoutId = stableIdsByOldLayoutId, + layoutIndex = layoutIndex, + keyIndex = keyIndex, + warnings = warnings + ) + // Backups from before schema v3 had no gesture field. Preserve the new Shift + // default during import while still allowing v3+ exports to explicitly disable it. + keyDto.doubleTapEnabled == null && normalizedTapAction == "ShiftKeyPressed" -> + "CapLockKey" + else -> null + } + val normalizedDoubleTapPolicy = normalizedDoubleTapAction?.let { + val doubleTapAction = KeyActionMapper.toKeyAction(normalizedDoubleTapAction) + ?: return@let DoubleTapPolicy.EXCLUSIVE.serializedName + automaticDoubleTapPolicy( + normalAction = KeyActionMapper.toKeyAction(normalizedTapAction), + doubleTapAction = doubleTapAction + ).serializedName + } + val normalizedKey = KeyDefinition( keyId = 0, ownerLayoutId = 0, @@ -310,7 +346,7 @@ object KeyboardBackupNormalizer { rowSpan = keyDto.rowSpan ?: 1, colSpan = keyDto.colSpan ?: 1, keyType = enumValueOrNull(keyDto.keyType) ?: KeyType.NORMAL, - isSpecialKey = keyDto.isSpecialKey ?: false, + isSpecialKey = isSpecialKey, drawableResId = keyDto.drawableResId, iconType = normalizedIconType( iconType = keyDto.iconType, @@ -325,20 +361,16 @@ object KeyboardBackupNormalizer { iconValue = keyDto.iconValue ), keyIdentifier = identifier, - action = remapKeyAction( - action = keyDto.action, - stableIdsByOldLayoutId = stableIdsByOldLayoutId, - layoutIndex = layoutIndex, - keyIndex = keyIndex, - warnings = warnings - ), + action = normalizedTapAction, rowUnits = keyDto.rowUnits?.coerceAtLeast(0), columnUnits = keyDto.columnUnits?.coerceAtLeast(0), rowSpanUnits = keyDto.rowSpanUnits?.coerceAtLeast(1), columnSpanUnits = keyDto.columnSpanUnits?.coerceAtLeast(1), specialKeyColorStyle = SpecialKeyColorStyle .fromDbValue(keyDto.specialKeyColorStyle) - .dbValue + .dbValue, + doubleTapAction = normalizedDoubleTapAction, + doubleTapPolicy = normalizedDoubleTapPolicy ) ImportableKeyWithFlicks( diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutExportDtos.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutExportDtos.kt index 11d959a2d..74f23dbb6 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutExportDtos.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutExportDtos.kt @@ -94,6 +94,12 @@ data class KeyDefinitionDto( val iconValue: String? = null, @SerializedName("action") val action: String? = null, + @SerializedName("doubleTapEnabled") + val doubleTapEnabled: Boolean? = null, + @SerializedName("doubleTapAction") + val doubleTapAction: String? = null, + @SerializedName("doubleTapPolicy") + val doubleTapPolicy: String? = null, @SerializedName("rowUnits") val rowUnits: Int? = null, @SerializedName("columnUnits") diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonExporter.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonExporter.kt index 22c9846fa..9966785d7 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonExporter.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonExporter.kt @@ -2,6 +2,8 @@ package com.kazumaproject.markdownhelperkeyboard.custom_keyboard.import_export import com.google.gson.Gson import com.google.gson.GsonBuilder +import com.kazumaproject.custom_keyboard.data.KeyActionMapper +import com.kazumaproject.custom_keyboard.data.automaticDoubleTapPolicy import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.FullKeyboardLayout import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.KeyWithFlicks @@ -84,6 +86,15 @@ private fun exportLayoutName(rawName: String?, layoutId: Long): String { } internal fun KeyWithFlicks.toExportDto(): KeyWithFlicksExportDto { + val doubleTapAction = this.key.doubleTapAction.takeIf { this.key.isSpecialKey } + val doubleTapPolicy = doubleTapAction + ?.let(KeyActionMapper::toKeyAction) + ?.let { action -> + automaticDoubleTapPolicy( + normalAction = KeyActionMapper.toKeyAction(this.key.action), + doubleTapAction = action + ).serializedName + } return KeyWithFlicksExportDto( key = KeyDefinitionDto( keyId = this.key.keyId, @@ -100,6 +111,9 @@ internal fun KeyWithFlicks.toExportDto(): KeyWithFlicksExportDto { iconType = this.key.iconType, iconValue = this.key.iconValue, action = this.key.action, + doubleTapEnabled = doubleTapAction != null, + doubleTapAction = doubleTapAction, + doubleTapPolicy = doubleTapPolicy, rowUnits = this.key.rowUnits, columnUnits = this.key.columnUnits, rowSpanUnits = this.key.rowSpanUnits, diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonImporter.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonImporter.kt index d2a6cd5f3..1243fe216 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonImporter.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonImporter.kt @@ -24,7 +24,7 @@ object KeyboardLayoutJsonImporter { /** * 想定する最新の schemaVersion。 */ - const val LATEST_SCHEMA_VERSION: Int = 2 + const val LATEST_SCHEMA_VERSION: Int = 3 internal val gson: Gson by lazy { GsonBuilder() diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyEditorFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyEditorFragment.kt index 87e28300f..e1964e235 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyEditorFragment.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/ui/KeyEditorFragment.kt @@ -32,6 +32,7 @@ import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import com.kazumaproject.custom_keyboard.data.CircularFlickDirection +import com.kazumaproject.custom_keyboard.data.DoubleTapBinding import com.kazumaproject.custom_keyboard.data.FlickAction import com.kazumaproject.custom_keyboard.data.FlickDirection import com.kazumaproject.custom_keyboard.data.KeyAction @@ -44,6 +45,7 @@ import com.kazumaproject.custom_keyboard.data.KeyIconType import com.kazumaproject.custom_keyboard.data.KeyItem import com.kazumaproject.custom_keyboard.data.KeyType import com.kazumaproject.custom_keyboard.data.SpecialKeyColorStyle +import com.kazumaproject.custom_keyboard.data.automaticDoubleTapPolicy import com.kazumaproject.custom_keyboard.data.compatibleColumnSpan import com.kazumaproject.custom_keyboard.data.compatibleRowSpan import com.kazumaproject.custom_keyboard.data.toCircularFlickMap @@ -118,6 +120,8 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { private lateinit var keyActionAdapter: ArrayAdapter private lateinit var customKeyboardTargetAdapter: ArrayAdapter + private lateinit var doubleTapActionAdapter: ArrayAdapter + private lateinit var doubleTapTargetAdapter: ArrayAdapter private lateinit var circularFlickAdapter: CircularFlickMappingAdapter private lateinit var circularMapAdapter: ArrayAdapter @@ -127,6 +131,7 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { private var customKeyboardTargets: List = emptyList() private var customKeyboardTargetOptions: List = emptyList() private var selectedTargetCustomKeyboardStableId: String? = null + private var selectedDoubleTapTargetStableId: String? = null private var selectedIconRef: KeyIconRef? = null private var originalIconRef: KeyIconRef? = null private val pendingUserIconPaths = mutableSetOf() @@ -188,6 +193,17 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { ) binding.keyActionSpinner.setAdapter(keyActionAdapter) + doubleTapActionAdapter = ArrayAdapter( + requireContext(), + android.R.layout.simple_spinner_dropdown_item, + listOf(getString(R.string.double_tap_disabled)) + actionDisplayNames + ) + binding.doubleTapActionSpinner.setAdapter(doubleTapActionAdapter) + binding.doubleTapActionSpinner.setText( + getString(R.string.double_tap_disabled), + false + ) + // 特殊フリック用アクションスピナー(セル選択後に表示) val specialActionNames = mutableListOf("").apply { addAll(specialFlickDisplayActions.map { it.displayName }) @@ -205,6 +221,12 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { mutableListOf() ) binding.customKeyboardTargetSpinner.setAdapter(customKeyboardTargetAdapter) + doubleTapTargetAdapter = ArrayAdapter( + requireContext(), + android.R.layout.simple_spinner_dropdown_item, + mutableListOf() + ) + binding.doubleTapTargetSpinner.setAdapter(doubleTapTargetAdapter) circularFlickAdapter = CircularFlickMappingAdapter { updated -> val currentMap = currentCircularFlickMaps.getOrNull(currentCircularMapIndex) @@ -343,6 +365,7 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { handleInputStyleUi() } + updateDoubleTapControlsVisibility() updateDoneButtonState() } @@ -425,6 +448,22 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { refreshIconPreview() updateDoneButtonState() } + binding.keyActionSpinner.setOnItemClickListener { _, _, position, _ -> + if ( + displayActions.getOrNull(position)?.action == KeyAction.ShiftKey && + selectedDoubleTapDisplayAction() == null + ) { + displayActionForAction(KeyAction.CapLockKey)?.let { capLock -> + binding.doubleTapActionSpinner.setText(capLock.displayName, false) + updateDoubleTapControlsVisibility() + } + } + } + + binding.doubleTapActionSpinner.doAfterTextChanged { + updateDoubleTapControlsVisibility() + updateDoneButtonState() + } binding.customKeyboardTargetSpinner.setOnItemClickListener { _, _, position, _ -> val option = customKeyboardTargetOptions.getOrNull(position) @@ -451,6 +490,14 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { updateDoneButtonState() } + binding.doubleTapTargetSpinner.setOnItemClickListener { _, _, position, _ -> + selectedDoubleTapTargetStableId = customKeyboardTargetOptions + .getOrNull(position) + ?.takeIf { it.isValid } + ?.stableId + updateDoneButtonState() + } + binding.buttonChooseBuiltinIcon.setOnClickListener { showBuiltInIconPicker() } @@ -860,6 +907,11 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { return displayActions.firstOrNull { it.displayName == selectedText } } + private fun selectedDoubleTapDisplayAction(): DisplayActionUi? { + val selectedText = binding.doubleTapActionSpinner.text.toString() + return displayActions.firstOrNull { it.displayName == selectedText } + } + private fun isMoveToCustomKeyboardSelected(): Boolean { return selectedSingleDisplayAction()?.action is KeyAction.MoveToCustomKeyboard } @@ -911,6 +963,9 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { customKeyboardTargetAdapter.clear() customKeyboardTargetAdapter.addAll(customKeyboardTargetOptions.map { it.label }) customKeyboardTargetAdapter.notifyDataSetChanged() + doubleTapTargetAdapter.clear() + doubleTapTargetAdapter.addAll(customKeyboardTargetOptions.map { it.label }) + doubleTapTargetAdapter.notifyDataSetChanged() } private fun selectTargetCustomKeyboard(stableId: String?) { @@ -933,6 +988,41 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { binding.customKeyboardTargetSpinner.setText(option?.label.orEmpty(), false) } + private fun selectDoubleTapTargetCustomKeyboard(stableId: String?) { + val id = stableId.orEmpty() + if (id.isBlank()) { + selectedDoubleTapTargetStableId = null + binding.doubleTapTargetSpinner.setText("", false) + return + } + + var option = customKeyboardTargetOptions.firstOrNull { it.stableId == id } + if (option == null) { + refreshCustomKeyboardTargetOptions(deletedStableId = id) + option = customKeyboardTargetOptions.firstOrNull { it.stableId == id } + } + + selectedDoubleTapTargetStableId = option + ?.takeIf { it.isValid } + ?.stableId + binding.doubleTapTargetSpinner.setText(option?.label.orEmpty(), false) + } + + private fun updateDoubleTapControlsVisibility() { + val supportsDoubleTap = + binding.keyTypeChipGroup.checkedChipId == R.id.chip_special + binding.doubleTapActionLayout.isVisible = supportsDoubleTap + val selected = selectedDoubleTapDisplayAction() + val isMoveToCustomKeyboard = selected?.action is KeyAction.MoveToCustomKeyboard + binding.doubleTapTargetLayout.isVisible = supportsDoubleTap && isMoveToCustomKeyboard + if (isMoveToCustomKeyboard && selectedDoubleTapTargetStableId.isNullOrBlank()) { + customKeyboardTargetOptions.firstOrNull { it.isValid }?.let { + selectedDoubleTapTargetStableId = it.stableId + binding.doubleTapTargetSpinner.setText(it.label, false) + } + } + } + private fun updateCustomKeyboardTargetVisibility() { val isSpecialSingleMoveTo = binding.keyTypeChipGroup.checkedChipId == R.id.chip_special && @@ -1041,7 +1131,17 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { else -> false } - binding.buttonDone.isEnabled = isEnabled + val doubleTapAction = selectedDoubleTapDisplayAction() + ?.action + ?.takeIf { binding.keyTypeChipGroup.checkedChipId == R.id.chip_special } + val isDoubleTapValid = if (doubleTapAction is KeyAction.MoveToCustomKeyboard) { + selectedDoubleTapTargetStableId + ?.let { target -> customKeyboardTargets.any { it.stableId == target } } + ?: false + } else { + true + } + binding.buttonDone.isEnabled = isEnabled && isDoubleTapValid } private fun setupInitialState() { @@ -1074,6 +1174,18 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { val key = currentKeyData!! selectedIconRef = key.icon?.takeIf { it.isOverride() } originalIconRef = selectedIconRef + key.doubleTapBinding + ?.takeIf { key.isSpecialKey } + ?.let { doubleTap -> + val doubleTapAction = doubleTap.action + displayActionForAction(doubleTapAction)?.let { display -> + binding.doubleTapActionSpinner.setText(display.displayName, false) + } + if (doubleTapAction is KeyAction.MoveToCustomKeyboard) { + selectDoubleTapTargetCustomKeyboard(doubleTapAction.stableId) + } + } + updateDoubleTapControlsVisibility() isFlexibleSizeEditing = state.layout.usesFlexiblePlacement() && selectedKeyItem != null if (isFlexibleSizeEditing) { @@ -1839,6 +1951,40 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { } } + val selectedDoubleTapAction = selectedDoubleTapDisplayAction() + ?.action + ?.takeIf { isSpecial } + val resolvedDoubleTapAction = if ( + selectedDoubleTapAction is KeyAction.MoveToCustomKeyboard + ) { + val stableId = selectedDoubleTapTargetStableId + ?.takeIf { id -> customKeyboardTargets.any { it.stableId == id } } + ?: return + KeyAction.MoveToCustomKeyboard(stableId) + } else { + selectedDoubleTapAction + } + val newDoubleTapBinding = resolvedDoubleTapAction + ?.takeIf { isSpecial } + ?.let { + DoubleTapBinding( + action = it, + policy = automaticDoubleTapPolicy( + normalAction = newAction, + doubleTapAction = it + ) + ) + } + if (newDoubleTapBinding?.action == KeyAction.VoiceInput) { + val hasPermission = ContextCompat.checkSelfPermission( + requireContext(), + Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + if (!hasPermission) { + requestRecordAudioPermission.launch(Manifest.permission.RECORD_AUDIO) + } + } + val savedIconRef = selectedIconRef?.takeIf { isSpecial } val updatedKey = originalKey.copy( label = newLabel, @@ -1863,7 +2009,8 @@ class KeyEditorFragment : Fragment(R.layout.fragment_key_editor) { selectedSpecialKeyColorStyle() } else { SpecialKeyColorStyle.SPECIAL - } + }, + doubleTapBinding = newDoubleTapBinding ) val updated = viewModel.updateKeyAndMappings( diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/database/AppDatabase.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/database/AppDatabase.kt index b3712d5cf..f89ef2b92 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/database/AppDatabase.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/database/AppDatabase.kt @@ -80,7 +80,7 @@ import com.kazumaproject.markdownhelperkeyboard.zeroquery.custom.CustomZeroQuery SumireSpecialKeyPlacementOverrideEntity::class, CustomZeroQueryEntry::class, ], - version = 41, + version = 44, exportSchema = false ) @TypeConverters( @@ -1095,5 +1095,75 @@ abstract class AppDatabase : RoomDatabase() { ) } } + + val MIGRATION_41_42 = object : Migration(41, 42) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE `key_definitions` ADD COLUMN `doubleTapAction` TEXT" + ) + db.execSQL( + "ALTER TABLE `key_definitions` ADD COLUMN `doubleTapPolicy` TEXT" + ) + db.execSQL( + """ + UPDATE `key_definitions` + SET `doubleTapAction` = 'CapLockKey', + `doubleTapPolicy` = 'PROMOTE' + WHERE `action` = 'ShiftKeyPressed' + """.trimIndent() + ) + } + } + + val MIGRATION_42_43 = object : Migration(42, 43) { + override fun migrate(db: SupportSQLiteDatabase) { + // Character keys must keep repeated taps as repeated text input. + db.execSQL( + """ + UPDATE `key_definitions` + SET `doubleTapAction` = NULL, + `doubleTapPolicy` = NULL + WHERE `isSpecialKey` = 0 + """.trimIndent() + ) + // Resolution policy is an implementation detail: only Shift promotes its first tap. + db.execSQL( + """ + UPDATE `key_definitions` + SET `doubleTapPolicy` = CASE + WHEN `action` = 'ShiftKeyPressed' + AND `doubleTapAction` = 'CapLockKey' THEN 'PROMOTE' + ELSE 'EXCLUSIVE' + END + WHERE `isSpecialKey` = 1 + AND `doubleTapAction` IS NOT NULL + """.trimIndent() + ) + db.execSQL( + """ + UPDATE `key_definitions` + SET `doubleTapPolicy` = NULL + WHERE `doubleTapAction` IS NULL + """.trimIndent() + ) + } + } + + val MIGRATION_43_44 = object : Migration(43, 44) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE `candidate_order_override` ADD COLUMN `scope` TEXT NOT NULL DEFAULT 'EXACT_INPUT'" + ) + db.execSQL( + "DROP INDEX IF EXISTS `index_candidate_order_override_input_candidate`" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_candidate_order_override_input_scope` ON `candidate_order_override` (`input`, `scope`)" + ) + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS `index_candidate_order_override_input_scope_candidate` ON `candidate_order_override` (`input`, `scope`, `candidate`)" + ) + } + } } } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CustomKeyboardShiftState.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CustomKeyboardShiftState.kt new file mode 100644 index 000000000..7eb65743d --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CustomKeyboardShiftState.kt @@ -0,0 +1,36 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service + +import com.kazumaproject.custom_keyboard.data.KeyCharacterCase + +internal enum class CustomKeyboardShiftState { + OFF, + ONE_SHOT, + LOCKED; + + val appliesUppercase: Boolean + get() = this != OFF + + val keyCharacterCase: KeyCharacterCase + get() = if (appliesUppercase) { + KeyCharacterCase.UPPERCASE + } else { + KeyCharacterCase.AS_DEFINED + } + + fun onShiftTap(): CustomKeyboardShiftState = when (this) { + OFF -> ONE_SHOT + ONE_SHOT, LOCKED -> OFF + } + + fun onCapsLockTap(): CustomKeyboardShiftState = when (this) { + LOCKED -> OFF + OFF, ONE_SHOT -> LOCKED + } + + fun consumeOneShot(): CustomKeyboardShiftState = + if (this == ONE_SHOT) OFF else this + + fun transformAsciiLetters(text: String): String { + return keyCharacterCase.transformAsciiLetters(text) + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/DeleteLongPressConversionBehavior.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/DeleteLongPressConversionBehavior.kt new file mode 100644 index 000000000..0420dd9a8 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/DeleteLongPressConversionBehavior.kt @@ -0,0 +1,28 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service + +/** + * Controls only candidate conversion and composing presentation during delete repeat. + * + * The repeat interval and the actual deletion algorithm are shared by both behaviors. + */ +internal enum class DeleteLongPressConversionBehavior( + val preferenceValue: String, + val suspendsCandidateResultsDuringRepeat: Boolean, +) { + Deferred( + preferenceValue = "deferred", + suspendsCandidateResultsDuringRepeat = true, + ), + Continuous( + preferenceValue = "continuous", + suspendsCandidateResultsDuringRepeat = false, + ); + + companion object { + val Default = Deferred + + fun fromPreferenceValue(value: String?): DeleteLongPressConversionBehavior { + return entries.firstOrNull { it.preferenceValue == value } ?: Default + } + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/DeleteLongPressConversionGate.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/DeleteLongPressConversionGate.kt new file mode 100644 index 000000000..2ecf1c186 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/DeleteLongPressConversionGate.kt @@ -0,0 +1,62 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service + +import com.kazumaproject.markdownhelperkeyboard.ime_service.models.CandidateShowFlag + +/** + * Coordinates composing-text presentation and candidate conversion while delete is repeating. + * + * Candidate results stay suspended after the finger is released until the replacement refresh + * actually starts. This closes the small window in which an older asynchronous result could + * otherwise be applied between ACTION_UP and collection of the final refresh request. + */ +internal class DeleteLongPressConversionGate { + @Volatile + private var repeating = false + + @Volatile + private var candidateResultsSuspended = false + + @Synchronized + fun beginRepeat(): Boolean { + if (repeating) return false + repeating = true + candidateResultsSuspended = true + return true + } + + fun shouldRenderRawComposing(input: String): Boolean { + return repeating && input.isNotEmpty() + } + + fun mayApplyCandidateResult(): Boolean = !candidateResultsSuspended + + /** + * Ends a real repeat session and returns the single refresh to issue after ACTION_UP. + * + * The result suspension is intentionally retained here. It is released only when the returned + * refresh starts being collected. + */ + @Synchronized + fun finishRepeat(input: String): CandidateShowFlag? { + if (!repeating) return null + repeating = false + return if (input.isEmpty()) { + CandidateShowFlag.Idle + } else { + CandidateShowFlag.Updating + } + } + + /** + * Called at the beginning of candidate refresh collection. + * + * Requests observed during the repeat must not reopen the gate. The one issued after release + * does, after collectLatest has cancelled and joined the previous request. + */ + @Synchronized + fun onCandidateRefreshStarted() { + if (!repeating) { + candidateResultsSuspended = false + } + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/FullscreenModePolicy.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/FullscreenModePolicy.kt new file mode 100644 index 000000000..0d30fa008 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/FullscreenModePolicy.kt @@ -0,0 +1,15 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service + +import android.view.inputmethod.EditorInfo + +internal object FullscreenModePolicy { + + fun shouldUseFullscreenMode( + frameworkRequestsFullscreen: Boolean, + fullscreenModeAllowed: Boolean, + imeOptions: Int, + ): Boolean = + frameworkRequestsFullscreen && + fullscreenModeAllowed && + imeOptions and EditorInfo.IME_FLAG_NO_EXTRACT_UI == 0 +} 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 69c542cb9..cb98a608e 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 @@ -22,6 +22,7 @@ import android.os.Bundle import android.os.CombinedVibration import android.os.Handler import android.os.Looper +import android.os.Process import android.os.ResultReceiver import android.os.SystemClock import android.os.VibrationEffect @@ -123,6 +124,7 @@ import com.kazumaproject.core.domain.extensions.toZenkaku import com.kazumaproject.core.domain.extensions.toZenkakuAlphabet import com.kazumaproject.core.domain.extensions.toZenkakuKatakana import com.kazumaproject.core.domain.flick.FlickThresholdShape +import com.kazumaproject.core.domain.flick.FlickTextPreviewListener import com.kazumaproject.core.domain.flick.MutableRuntimeGestureSettingsSource import com.kazumaproject.core.domain.flick.RuntimeGestureSettings import com.kazumaproject.core.domain.key.Key @@ -148,6 +150,7 @@ import com.kazumaproject.core.domain.window.getScreenHeight import com.kazumaproject.custom_keyboard.data.FlickDirection import com.kazumaproject.custom_keyboard.data.KeyAction import com.kazumaproject.custom_keyboard.data.KeyActionMapper +import com.kazumaproject.custom_keyboard.data.KeyCharacterCase import com.kazumaproject.custom_keyboard.data.KeyboardInputMode import com.kazumaproject.custom_keyboard.data.KeyboardLayout import com.kazumaproject.custom_keyboard.data.KeyboardLayoutUsageMode @@ -177,6 +180,8 @@ import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TY import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_USER_DICTIONARY import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_USER_TEMPLATE import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate +import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CandidateConversionSegment +import com.kazumaproject.markdownhelperkeyboard.converter.candidate.ExactInputCandidatePromotionPolicy import com.kazumaproject.markdownhelperkeyboard.converter.candidate.QWERTY_GLIDE_CANDIDATE_TYPE import com.kazumaproject.markdownhelperkeyboard.converter.candidate.ZenzCandidate import com.kazumaproject.markdownhelperkeyboard.converter.candidate.buildRomajiCandidates @@ -235,6 +240,10 @@ import com.kazumaproject.markdownhelperkeyboard.ime_service.feedback.VibrationTi import com.kazumaproject.markdownhelperkeyboard.ime_service.floating_view.BubbleTextView import com.kazumaproject.markdownhelperkeyboard.ime_service.floating_view.FloatingDockListener import com.kazumaproject.markdownhelperkeyboard.ime_service.floating_view.FloatingDockView +import com.kazumaproject.markdownhelperkeyboard.ime_service.flick_preview.ComposingTextArbiter +import com.kazumaproject.markdownhelperkeyboard.ime_service.flick_preview.FlickInputPreviewCoordinator +import com.kazumaproject.markdownhelperkeyboard.ime_service.flick_preview.FlickPreviewContext +import com.kazumaproject.markdownhelperkeyboard.ime_service.flick_preview.FlickPreviewSource import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.CinematicWaveEffectView import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.CinematicWaveSettings import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.FluidInkTransportMode @@ -332,6 +341,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelChildren @@ -369,6 +379,7 @@ import java.util.Locale import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.Executors import java.util.regex.Pattern import javax.inject.Inject import androidx.appcompat.R as AppCompatR @@ -453,7 +464,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, val leftContext: String, val rightContext: String, val cacheKey: String, - val rerankTargets: List> + val rerankTargets: List>, + val candidateSegmentsByString: Map>, ) private data class ZenzContext( @@ -725,6 +737,15 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val kanaKanjiConversionDispatcher = Executors.newSingleThreadExecutor { runnable -> + Thread( + { + Process.setThreadPriority(Process.THREAD_PRIORITY_DISPLAY) + runnable.run() + }, + "KanaKanjiConversion", + ).apply { isDaemon = true } + }.asCoroutineDispatcher() private val mainHandler = Handler(Looper.getMainLooper()) private val runtimeGestureSettingsSource = MutableRuntimeGestureSettingsSource( RuntimeGestureSettings() @@ -734,6 +755,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private val runtimeInputPreferenceKeys = setOf( AppPreference.FLICK_SENSITIVITY_KEY, AppPreference.FLICK_THRESHOLD_SHAPE_KEY, + AppPreference.FLICK_EDITOR_PREVIEW_KEY, AppPreference.TENKEY_KEYMAP_GUIDE_JAPANESE_KEY, AppPreference.TENKEY_KEYMAP_GUIDE_ENGLISH_KEY, AppPreference.TENKEY_KEYMAP_GUIDE_NUMBER_KEY, @@ -742,6 +764,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, AppPreference.SUMIRE_KEYMAP_GUIDE_NUMBER_KEY, AppPreference.CUSTOM_KEYMAP_GUIDE_KEY, AppPreference.LONG_PRESS_TIMEOUT_KEY, + AppPreference.DELETE_LONG_PRESS_CONVERSION_BEHAVIOR_KEY, AppPreference.VIBRATION_KEY, AppPreference.VIBRATION_TIMING_KEY, AppPreference.KEY_SOUND_KEY, @@ -1179,6 +1202,18 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, applyRawComposingFallback(insertString) } } + if (BuildConfig.DEBUG && shouldStartLiveConversion(insertString)) { + val request = candidateRefreshCoordinator.requests.value + if (request.input == insertString) { + Timber.d( + "liveConversionLatency inputLength=%d revision=%d requestToApplied=%.2fms", + insertString.length, + request.revision, + (System.nanoTime() - + request.publishedAtNanos) / 1_000_000.0, + ) + } + } true } @@ -1210,6 +1245,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, emptyList() private var deleteLongPressJob: Job? = null + private val deleteLongPressConversionGate = DeleteLongPressConversionGate() private var rightLongPressJob: Job? = null private var leftLongPressJob: Job? = null private var candidateTranslationJob: Job? = null @@ -1313,6 +1349,33 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private val rightCursorKeyLongKeyPressed = AtomicBoolean(false) private val leftCursorKeyLongKeyPressed = AtomicBoolean(false) private var isFlickOnlyMode: Boolean? = false + private var flickEditorPreviewPreference: Boolean = false + private var flickPreviewEditorSessionId: Long = 0L + private val composingTextArbiter = ComposingTextArbiter( + writeComposingText = { text, cursorPosition -> + currentInputConnection?.setComposingText(text, cursorPosition) ?: false + }, + finishComposingText = { + currentInputConnection?.finishComposingText() ?: false + }, + copyText = { text -> SpannableString(text) }, + ) + private val flickInputPreviewCoordinator = FlickInputPreviewCoordinator( + composingTextArbiter = composingTextArbiter, + createPreviewText = ::createFlickPreviewComposingText, + ) + private val tenKeyFlickTextPreviewListener = FlickTextPreviewListener { event -> + flickInputPreviewCoordinator.onEvent( + event = event, + context = currentFlickPreviewContext(FlickPreviewSource.TENKEY), + ) + } + private val sumireFlickTextPreviewListener = FlickTextPreviewListener { event -> + flickInputPreviewCoordinator.onEvent( + event = event, + context = currentFlickPreviewContext(FlickPreviewSource.SUMIRE), + ) + } private var isOmissionSearchEnable: Boolean? = false private var delayTime: Int? = 1000 private var isLearnDictionaryMode: Boolean? = false @@ -1331,6 +1394,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private var flickThresholdShapePreferenceValue: FlickThresholdShape = FlickThresholdShape.Radial private var longPressTimeoutPreferenceValue: Int? = 300 + private var deleteLongPressConversionBehavior = + DeleteLongPressConversionBehavior.Default + private var activeDeleteLongPressConversionBehavior: + DeleteLongPressConversionBehavior? = null + private var deleteLongPressFinalRefreshRequested = false private var tenkeyShowIMEButtonPreference: Boolean? = true private var qwertyShowIMEButtonPreference: Boolean? = true private var qwertyShowEmojiButtonPreference: Boolean? = false @@ -1821,6 +1889,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private var zenzLiveLatestResultMeta: ZenzLiveResultMeta? = null private var zenzRerankJob: Job? = null private var zenzRerankRequestToken: Long = 0L + @Volatile + private var latestCandidateSegmentInput: String = "" + @Volatile + private var latestCandidateSegmentsByString: + Map> = emptyMap() private val zenzRerankCache = object : LinkedHashMap>(16, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry>?): Boolean { return size > 24 @@ -2181,7 +2254,6 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } } else { - setupKeyboardView() scope.coroutineContext.cancelChildren() mainLayoutBinding?.let { mainView -> startScope(mainView) @@ -2192,6 +2264,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) { super.onStartInput(attribute, restarting) + flickPreviewEditorSessionId += 1L + flickInputPreviewCoordinator.resetForEditorSession() gemmaInputSessionId += 1L gemmaMediaPanelController?.onInputSessionChanged() gemmaHandwritingController?.onInputSessionChanged() @@ -2225,7 +2299,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, kanaKanjiConversionSession = KanaKanjiConversionSession( engine = kanaKanjiEngine, backend = backend, - ) + ).also { session -> + if (BuildConfig.DEBUG && backend == ConversionBackend.INCREMENTAL_SESSION) { + session.enablePerformanceProbe() + } + } candidateRequestTracker.restart(backend) candidateRefreshCoordinator.restart() } @@ -2268,6 +2346,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, flickSensitivityPreferenceValue = sensitivity flickThresholdShapePreferenceValue = thresholdShape longPressTimeoutPreferenceValue = longPressTimeout + flickEditorPreviewPreference = appPreference.flick_editor_preview_preference + deleteLongPressConversionBehavior = + DeleteLongPressConversionBehavior.fromPreferenceValue( + appPreference.delete_long_press_conversion_behavior + ) isVibration = appPreference.vibration_preference ?: true vibrationTimingStr = appPreference.vibration_timing_preference ?: "both" isKeySoundEnabled = appPreference.key_sound_preference ?: false @@ -2341,6 +2424,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mozcUTNeologd = preferences.mozcUTNeologd mozcUTWeb = preferences.mozcUTWeb isFlickOnlyMode = preferences.isFlickOnlyMode + flickEditorPreviewPreference = preferences.flickEditorPreviewPreference isOmissionSearchEnable = preferences.isOmissionSearchEnable delayTime = preferences.delayTime isLearnDictionaryMode = preferences.isLearnDictionaryMode @@ -2395,13 +2479,22 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, englishEngine.invalidateQwertyGlideCache() currentQwertyGlideCompositionText = null } - englishEngine.configureQwertyGlideDecoder( - enabled = preferences.qwertyGlideInputPreference, - canUseBundledPrebuiltIndex = !dictionarySourceResolver.shouldUseOverrideCategory( - DictionaryCategory.ENGLISH - ), - prebuiltDictionaryLoader = QwertyGlidePrebuiltDictionaryLoader(dictionarySourceResolver), - ) + val dictionaryReloadWillConfigureGlide = + lastAppliedDictionaryOverrideRevision != Long.MIN_VALUE && + (dictionaryOverrideApplyJob?.isActive == true || + dictionaryOverrideStore.currentRevision != + lastAppliedDictionaryOverrideRevision) + if (!dictionaryReloadWillConfigureGlide) { + englishEngine.configureQwertyGlideDecoderAsync( + enabled = preferences.qwertyGlideInputPreference, + canUseBundledPrebuiltIndex = !dictionarySourceResolver.shouldUseOverrideCategory( + DictionaryCategory.ENGLISH + ), + prebuiltDictionaryLoader = QwertyGlidePrebuiltDictionaryLoader( + dictionarySourceResolver + ), + ) + } qwertyShowPopupWindowPreference = preferences.qwertyShowPopupWindowPreference qwertyEnableFlickUpPreference = preferences.qwertyEnableFlickUpPreference qwertyEnableFlickDownPreference = preferences.qwertyEnableFlickDownPreference @@ -2716,24 +2809,54 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, dictionaryOverrideApplyJob = ioScope.launch { val revisionToApply = dictionaryOverrideStore.currentRevision + val initialOptionalStateOnly = + lastAppliedDictionaryOverrideRevision == Long.MIN_VALUE && + !kanaKanjiEngine.isInitialOptionalDictionaryStateLoaded() + val startedAt = System.nanoTime() val success = runCatching { - kanaKanjiEngine.applyDictionaryOverrideState(applicationContext) - englishEngine.reloadDictionariesFromCurrentSources( - reader = dictionaryBinaryReader, - qwertyGlideInputEnabled = qwertyGlideInputPreference, - qwertyGlidePrebuiltDictionaryLoader = QwertyGlidePrebuiltDictionaryLoader( - dictionarySourceResolver - ), - canUseBundledPrebuiltIndex = !dictionarySourceResolver.shouldUseOverrideCategory( - DictionaryCategory.ENGLISH - ), - ) + if (initialOptionalStateOnly) { + kanaKanjiEngine.initializeOptionalDictionaryStateFromCurrentSources() + } else { + kanaKanjiEngine.applyDictionaryOverrideState(applicationContext) + englishEngine.reloadDictionariesFromCurrentSources( + reader = dictionaryBinaryReader, + qwertyGlideInputEnabled = qwertyGlideInputPreference, + qwertyGlidePrebuiltDictionaryLoader = QwertyGlidePrebuiltDictionaryLoader( + dictionarySourceResolver + ), + canUseBundledPrebuiltIndex = + !dictionarySourceResolver.shouldUseOverrideCategory( + DictionaryCategory.ENGLISH + ), + ) + } }.onFailure { Timber.w(it, "Failed to apply dictionary override revision $revisionToApply") }.isSuccess - if (success) { + if (success && dictionaryOverrideStore.currentRevision == revisionToApply) { lastAppliedDictionaryOverrideRevision = revisionToApply + if (initialOptionalStateOnly) { + Timber.d( + "Initial optional dictionary state loaded without core reload: " + + "elapsed_ms=${(System.nanoTime() - startedAt) / 1_000_000.0}" + ) + } + } else { + if (!initialOptionalStateOnly) { + // The regular reload also configures glide. If it failed before reaching that + // point, retain the lightweight asynchronous configuration as a fallback. + englishEngine.configureQwertyGlideDecoderAsync( + enabled = qwertyGlideInputPreference, + canUseBundledPrebuiltIndex = + !dictionarySourceResolver.shouldUseOverrideCategory( + DictionaryCategory.ENGLISH + ), + prebuiltDictionaryLoader = QwertyGlidePrebuiltDictionaryLoader( + dictionarySourceResolver + ), + ) + } } } } @@ -4056,6 +4179,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, override fun onStartInputView(editorInfo: EditorInfo?, restarting: Boolean) { super.onStartInputView(editorInfo, restarting) + flickInputPreviewCoordinator.cancel(restore = true) clearZeroQueryAllState(refresh = false) // The input view can restart without onStartInput() after returning from settings. // Re-read preferences that may change while the existing input session is retained. @@ -4416,6 +4540,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } override fun onFinishInputView(finishingInput: Boolean) { + flickInputPreviewCoordinator.cancel(restore = false) gemmaMediaPanelController?.onInputViewHidden() gemmaHandwritingController?.onInputViewHidden() candidateRequestTracker.invalidate() @@ -4447,6 +4572,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } override fun onWindowHidden() { + flickInputPreviewCoordinator.cancel(restore = true) gemmaMediaPanelController?.onInputViewHidden() gemmaHandwritingController?.onInputViewHidden() clearAndPauseSuminagashiInkEffects() @@ -4454,6 +4580,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } override fun onDestroy() { + flickInputPreviewCoordinator.cancel(restore = false) Timber.d("onUpdate onDestroy") if (runtimeInputPreferenceListenerRegistered) { runtimeInputSharedPreferences.unregisterOnSharedPreferenceChangeListener( @@ -4904,18 +5031,18 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, override fun onEvaluateFullscreenMode(): Boolean { - val isFullscreenModeAllowed: Boolean = - resources.getBoolean(R.bool.config_allow_fullscreen_mode) - - if (super.onEvaluateFullscreenMode() && isFullscreenModeAllowed) { - // TODO: Remove this hack. Actually we should not really assume NO_EXTRACT_UI - // implies NO_FULLSCREEN. However, the framework mistakenly does. i.e. NO_EXTRACT_UI - // without NO_FULLSCREEN doesn't work as expected. Because of this we need this - // hack for now. Let's get rid of this once the framework gets fixed. - val ei = getCurrentInputEditorInfo() - return !(ei != null && ((ei.imeOptions and EditorInfo.IME_FLAG_NO_EXTRACT_UI) != 0)) - } - return false + val deviceDefault = resources.getBoolean(R.bool.config_allow_fullscreen_mode) + val isFullscreenModeAllowed = + appPreference.isFullscreenModeAllowed(defaultValue = deviceDefault) + val imeOptions = currentInputEditorInfo?.imeOptions ?: 0 + + // Keep treating NO_EXTRACT_UI as NO_FULLSCREEN. Android's framework does not + // reliably support the former without the latter. + return FullscreenModePolicy.shouldUseFullscreenMode( + frameworkRequestsFullscreen = super.onEvaluateFullscreenMode(), + fullscreenModeAllowed = isFullscreenModeAllowed, + imeOptions = imeOptions, + ) } /** @@ -5112,7 +5239,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private fun updateComposingText(text: String) { // 途中経過の表示用(変換中のようなイメージ) - currentInputConnection?.setComposingText(text, 1) + setComposingText(text, 1) } private fun commitRecognizedText(text: String) { @@ -7485,7 +7612,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, flickView.setKeyboard(applyDeleteKeyFlickPreferences(layout)) } - private fun syncCustomKeyboardToggleKeyIcons(flickView: FlickKeyboardView) { + private fun syncCustomKeyboardTogglePresentation(flickView: FlickKeyboardView) { + flickView.setKeyCharacterCase(customKeyboardShiftState.keyCharacterCase) flickView.updateKeyIconByAction( KeyAction.SwitchDirectMode, if (isCustomLayoutDirectMode) { @@ -7504,10 +7632,13 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) flickView.updateKeyIconByAction( KeyAction.ShiftKey, - if (isCustomLayoutShiftPressed) { - com.kazumaproject.core.R.drawable.shift_fill_24px - } else { - com.kazumaproject.core.R.drawable.shift_24px + when (customKeyboardShiftState) { + CustomKeyboardShiftState.OFF -> + com.kazumaproject.core.R.drawable.shift_24px + CustomKeyboardShiftState.ONE_SHOT -> + com.kazumaproject.core.R.drawable.shift_fill_24px + CustomKeyboardShiftState.LOCKED -> + com.kazumaproject.core.R.drawable.caps_lock } ) flickView.updateKeyIconByAction( @@ -7520,13 +7651,13 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) } - private fun syncCustomKeyboardToggleKeyIconsOnAvailableSurfaces() { + private fun syncCustomKeyboardTogglePresentationOnAvailableSurfaces() { getNormalKeyboardSurface() ?.customLayout - ?.let(::syncCustomKeyboardToggleKeyIcons) + ?.let(::syncCustomKeyboardTogglePresentation) getFloatingKeyboardSurface() ?.customLayout - ?.let(::syncCustomKeyboardToggleKeyIcons) + ?.let(::syncCustomKeyboardTogglePresentation) } private fun applyCurrentFlickGuidePreference(flickView: FlickKeyboardView) { @@ -7549,6 +7680,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private fun setSumireLayoutTo(flickView: FlickKeyboardView) { val layoutType = sumireInputKeyLayoutType ?: "toggle" + flickView.setKeyCharacterCase(KeyCharacterCase.AS_DEFINED) applyCurrentFlickGuidePreference(flickView) flickView.setSumireSpecialKeyActionResolver( resolver = SumireSpecialKeyActionResolver(sumireSpecialKeyActionOverrides)::resolve, @@ -7559,6 +7691,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } private fun setNumberLayoutTo(flickView: FlickKeyboardView) { + flickView.setKeyCharacterCase(KeyCharacterCase.AS_DEFINED) applyCurrentFlickGuidePreference(flickView) val numberCustomLayout = numberUsageCustomKeyboardLayoutOrNull() if (numberCustomLayout != null) { @@ -7655,14 +7788,13 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, stableId = expectedStableId, defaultValue = finalLayout.isDirectMode ) - isCustomLayoutShiftPressed = false - isCustomLayoutCapLock = false + customKeyboardShiftState = CustomKeyboardShiftState.OFF withContext(Dispatchers.Main) { if (!isCurrentCustomKeyboardSelection(layoutId = id, stableId = expectedStableId)) { return@withContext } setKeyboardWithDeleteKeyFlickPreferences(flickView, finalLayout) - syncCustomKeyboardToggleKeyIcons(flickView) + syncCustomKeyboardTogglePresentation(flickView) refreshBaselineInputBehaviorForCurrentKeyboard("custom layout input mode loaded") } } @@ -7822,6 +7954,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private fun configureFloatingTenKeyView( floatingKeyboardLayoutBinding: FloatingKeyboardLayoutBinding ) { + floatingKeyboardLayoutBinding.keyboardViewFloating.setOnFlickTextPreviewListener( + tenKeyFlickTextPreviewListener + ) floatingKeyboardLayoutBinding.keyboardViewFloating.applyKeyboardTheme( themeMode = keyboardThemeMode ?: "default", currentNightMode = currentNightMode, @@ -7965,6 +8100,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mainView: MainLayoutBinding ) { mainView.keyboardView.apply { + setOnFlickTextPreviewListener(tenKeyFlickTextPreviewListener) applyKeyboardTheme( themeMode = keyboardThemeMode ?: "default", currentNightMode = currentNightMode, @@ -10573,8 +10709,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private var isCustomLayoutRomajiMode = false private var isCustomLayoutDirectMode = false - private var isCustomLayoutShiftPressed = false - private var isCustomLayoutCapLock = false + private var customKeyboardShiftState = CustomKeyboardShiftState.OFF + private val isCustomLayoutShiftPressed: Boolean + get() = customKeyboardShiftState == CustomKeyboardShiftState.ONE_SHOT + private val isCustomLayoutCapLock: Boolean + get() = customKeyboardShiftState == CustomKeyboardShiftState.LOCKED private fun customKeyboardInputModePersistenceKey(layoutId: Long, stableId: String): String { return stableId.takeIf { it.isNotBlank() } ?: layoutId.toString() @@ -10794,15 +10933,14 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, stableId = expectedStableId, defaultValue = finalLayout.isDirectMode ) - isCustomLayoutShiftPressed = false - isCustomLayoutCapLock = false + customKeyboardShiftState = CustomKeyboardShiftState.OFF withContext(Dispatchers.Main) { if (!isCurrentCustomKeyboardSelection(layoutId = id, stableId = expectedStableId)) { Timber.d("renderCustomKeyboardLayout: skip stale render id=$id stableId=$expectedStableId") return@withContext } setCustomLayoutOnAvailableSurfaces(finalLayout) - syncCustomKeyboardToggleKeyIconsOnAvailableSurfaces() + syncCustomKeyboardTogglePresentationOnAvailableSurfaces() refreshBaselineInputBehaviorForCurrentKeyboard("custom layout input mode loaded") } } @@ -11040,6 +11178,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mainView: MainLayoutBinding, isFloatingView: Boolean ) { + flickView.setOnFlickTextPreviewListener(sumireFlickTextPreviewListener) flickView.bindRuntimeGestureSettings(runtimeGestureSettingsSource) if (isFloatingView) { Timber.d("Configuring floating FlickKeyboardView mirror surface") @@ -11714,15 +11853,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } KeyAction.ShiftKey -> { - isCustomLayoutShiftPressed = !isCustomLayoutShiftPressed - - Handler(mainLooper).post { - getActiveKeyboardSurface()?.customLayout?.updateKeyIconByAction( - KeyAction.ShiftKey, - if (isCustomLayoutShiftPressed) com.kazumaproject.core.R.drawable.shift_fill_24px - else com.kazumaproject.core.R.drawable.shift_24px - ) - } + handleCustomKeyboardShiftTap() } KeyAction.MoveCustomKeyboardTab -> { @@ -11857,15 +11988,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } KeyAction.CapLockKey -> { - isCustomLayoutCapLock = !isCustomLayoutCapLock - - Handler(mainLooper).post { - getActiveKeyboardSurface()?.customLayout?.updateKeyIconByAction( - KeyAction.CapLockKey, - if (isCustomLayoutCapLock) com.kazumaproject.core.R.drawable.caps_lock - else com.kazumaproject.core.R.drawable.caps_lock_outline - ) - } + handleCustomKeyboardCapsLockTap() } KeyAction.ForceHalfWidthSpace -> { @@ -11900,18 +12023,14 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, if (text.isEmpty()) return val shiftedText = applyCustomLayoutShiftAndCapLock(text) if (dispatchDirectTextIfNeeded(shiftedText)) { - if (isCustomLayoutShiftPressed) { - isCustomLayoutShiftPressed = false - } + consumeCustomKeyboardOneShotShift() return } if (isCustomLayoutDirectMode) { finishComposingText() setComposingText("", 0) commitText(shiftedText, 1) - if (isCustomLayoutShiftPressed) { - isCustomLayoutShiftPressed = false - } + consumeCustomKeyboardOneShotShift() return } if (text.length == 1) { @@ -11963,20 +12082,16 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) } - if (isCustomLayoutShiftPressed) { - isCustomLayoutShiftPressed = false - } - } else { if (isCustomKeyboardTwoWordsOutputEnable == true) { finishComposingText() setComposingText("", 0) - commitText(text, 1) + commitText(shiftedText, 1) } else { if (isCustomLayoutRomajiMode) { val insertString = inputString.value val sb = StringBuilder() - sb.append(insertString).append(text) + sb.append(insertString).append(shiftedText) romajiConverter?.let { converter -> if (isDefaultRomajiHenkanMap) { _inputString.update { @@ -11997,11 +12112,12 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } else { val insertString = inputString.value val sb = StringBuilder() - sb.append(insertString).append(text) + sb.append(insertString).append(shiftedText) _inputString.update { sb.toString() } } } } + consumeCustomKeyboardOneShotShift() } TenKeyQWERTYMode.Sumire -> { @@ -12284,15 +12400,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } KeyAction.ShiftKey -> { - isCustomLayoutShiftPressed = !isCustomLayoutShiftPressed - - Handler(mainLooper).post { - getActiveKeyboardSurface()?.customLayout?.updateKeyIconByAction( - KeyAction.ShiftKey, - if (isCustomLayoutShiftPressed) com.kazumaproject.core.R.drawable.shift_fill_24px - else com.kazumaproject.core.R.drawable.shift_24px - ) - } + handleCustomKeyboardShiftTap() } KeyAction.SwitchDirectMode -> { @@ -12312,15 +12420,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } KeyAction.CapLockKey -> { - isCustomLayoutCapLock = !isCustomLayoutCapLock - - Handler(mainLooper).post { - getActiveKeyboardSurface()?.customLayout?.updateKeyIconByAction( - KeyAction.CapLockKey, - if (isCustomLayoutCapLock) com.kazumaproject.core.R.drawable.caps_lock - else com.kazumaproject.core.R.drawable.caps_lock_outline - ) - } + handleCustomKeyboardCapsLockTap() } KeyAction.SwitchRomajiEnglish -> { @@ -12434,31 +12534,32 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, }) } + private fun handleCustomKeyboardShiftTap() { + customKeyboardShiftState = customKeyboardShiftState.onShiftTap() + Handler(mainLooper).post(::syncCustomKeyboardTogglePresentationOnAvailableSurfaces) + } + + private fun handleCustomKeyboardCapsLockTap() { + customKeyboardShiftState = customKeyboardShiftState.onCapsLockTap() + Handler(mainLooper).post(::syncCustomKeyboardTogglePresentationOnAvailableSurfaces) + } + + private fun consumeCustomKeyboardOneShotShift() { + val consumedState = customKeyboardShiftState.consumeOneShot() + if (consumedState == customKeyboardShiftState) return + customKeyboardShiftState = consumedState + Handler(mainLooper).post(::syncCustomKeyboardTogglePresentationOnAvailableSurfaces) + } + private fun applyCustomLayoutShiftAndCapLock(text: String): String { - if (!isCustomLayoutShiftPressed && !isCustomLayoutCapLock) { - return text - } - if (isCustomLayoutShiftPressed) { - Handler(mainLooper).post { - getActiveKeyboardSurface()?.customLayout?.updateKeyIconByAction( - KeyAction.ShiftKey, - com.kazumaproject.core.R.drawable.shift_24px - ) - } - } - return text.map { char -> - if (char in 'a'..'z' || char in 'A'..'Z') { - char.uppercaseChar() - } else { - char - } - }.joinToString("") + return customKeyboardShiftState.transformAsciiLetters(text) } private fun handleOnKeyForSumire( text: String, mainView: MainLayoutBinding, isFlick: Boolean ) { if (dispatchDirectTextIfNeeded(text)) return + if (applyPendingFlickTextMutation(text, isFlick)) return val insertString = inputString.value val sb = StringBuilder() if (text.isNotEmpty()) { @@ -14307,6 +14408,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, launch { var prevFlag: CandidateShowFlag? = null candidateRefreshRequests.collectLatest { request -> + deleteLongPressConversionGate.onCandidateRefreshStarted() val currentFlag = request.flag val insertString = request.input Timber.d( @@ -14384,7 +14486,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, CandidateShowFlag.Idle -> { var resetCandidateTabSelection = false clearZenzLiveSlot("suggestion idle") - setSuggestionAdapterSuggestionsOnMain(emptyList()) + setSuggestionAdaptersOnMain(emptyList()) if (stringInTail.get().isEmpty()) { shortcutToolbarHiddenForCandidates = false resetCandidateTabSelection = true @@ -15120,7 +15222,12 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, leftContext = zenzContext.leftContext, rightContext = zenzContext.rightContext, cacheKey = cacheKey, - rerankTargets = rerankTargets + rerankTargets = rerankTargets, + candidateSegmentsByString = if (latestCandidateSegmentInput == insertString) { + latestCandidateSegmentsByString + } else { + emptyMap() + }, ) } @@ -15193,7 +15300,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, rawScores.toList() ) - reranked + applyMergedCandidateOrder( + input = insertString, + candidates = reranked, + candidateSegmentsByString = plan.candidateSegmentsByString, + ) } private suspend fun getZenzContext( @@ -15313,7 +15424,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, requestInput: String, token: CandidateRequestToken? = null, ): Boolean { - return !suppressSuggestions && + return deleteLongPressConversionGate.mayApplyCandidateResult() && + !suppressSuggestions && requestInput.isNotEmpty() && inputString.value == requestInput && (token == null || candidateRequestTracker.isCurrent(token)) @@ -16205,6 +16317,12 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } else { clearBunsetsuReconversionDraft() } + if (deleteLongPressConversionGate.shouldRenderRawComposing(string)) { + Timber.d("deleteLongPress: render raw composing input=[%s]", string) + applyRawComposingFallback(string) + refreshReconversionUi() + return + } if (suppressSuggestions) { setComposingText(string, 1) refreshReconversionUi() @@ -21271,6 +21389,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, closeConnection() scope.cancel() ioScope.cancel() + kanaKanjiConversionDispatcher.close() } private fun resetFlagsSuggestionClick() { @@ -21360,6 +21479,29 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, clearBunsetsuConversionSession() } + /** Down/Moveプレビューにも通常入力と同じPreEdit装飾を適用する。 */ + private fun createFlickPreviewComposingText( + text: String, + composingTail: String, + ): CharSequence { + return applyPreEditComposingSpans( + spannableString = SpannableString(text + composingTail), + inputLength = text.length, + underlineEnd = text.length + composingTail.length, + backgroundColor = if (customComposingTextPreference == true) { + inputCompositionBackgroundColor + ?: getColor(com.kazumaproject.core.R.color.char_in_edit_color) + } else { + getColor(com.kazumaproject.core.R.color.char_in_edit_color) + }, + textColor = if (customComposingTextPreference == true) { + inputCompositionTextColor + } else { + null + }, + ) + } + /** * 編集前(PreEdit)のテキスト装飾を設定する * * @param backgroundColor 背景色 (Color Int) @@ -21373,37 +21515,48 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) { val inputLength = inputString.length val tailLength = stringInTail.get().length + applyPreEditComposingSpans( + spannableString = spannableString, + inputLength = inputLength, + underlineEnd = inputLength + tailLength, + backgroundColor = backgroundColor, + textColor = textColor, + ) - spannableString.apply { - // 背景色の設定 + Timber.d("launchInputString: setComposingTextPreEdit $spannableString") + setComposingText(spannableString, 1) + } + + private fun applyPreEditComposingSpans( + spannableString: SpannableString, + inputLength: Int, + underlineEnd: Int, + @ColorInt backgroundColor: Int, + @ColorInt textColor: Int? = null, + ): SpannableString { + val spanFlag = Spannable.SPAN_EXCLUSIVE_EXCLUSIVE or Spannable.SPAN_COMPOSING + return spannableString.apply { setSpan( BackgroundColorSpan(backgroundColor), 0, inputLength, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE or Spannable.SPAN_COMPOSING + spanFlag, ) - - // テキスト色の設定(指定がある場合のみ) textColor?.let { color -> setSpan( ForegroundColorSpan(color), 0, inputLength, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE or Spannable.SPAN_COMPOSING + spanFlag, ) } - - // 下線の設定 setSpan( UnderlineSpan(), 0, - inputLength + tailLength, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE or Spannable.SPAN_COMPOSING + underlineEnd, + spanFlag, ) } - - Timber.d("launchInputString: setComposingTextPreEdit $spannableString") - setComposingText(spannableString, 1) } /** @@ -21830,9 +21983,24 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, if (!shouldApplyCandidateResult(insertString, token)) { return } + val shouldApplyLiveConversion = + shouldStartLiveConversion(insertString) && !hasConvertedKatakana + val applyLiveConversionBeforeCandidateStrip = + shouldApplyLiveConversion && liveConversionApplyDelayMillis() == 0L + if (applyLiveConversionBeforeCandidateStrip) { + // This is the completed conversion candidate, not a raw composing fallback. Keep the + // candidate-strip DiffUtil work out of the visible live-conversion critical path. + delayBeforeApplyingLiveConversion() + if (!shouldApplyCandidateResult(insertString, token)) return + if (!applyFirstSuggestionOnMainIfCurrent( + insertString = insertString, + candidate = displayedCandidates.firstOrNull(), + ) + ) return + } updateDisplayedCandidates(insertString, displayedCandidates) - if (shouldStartLiveConversion(insertString) && !hasConvertedKatakana) { + if (shouldApplyLiveConversion && !applyLiveConversionBeforeCandidateStrip) { delayBeforeApplyingLiveConversion() if (!shouldApplyCandidateResult(insertString, token)) { return @@ -21896,9 +22064,22 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, if (!shouldApplyCandidateResult(insertString, token)) { return } + val shouldApplyLiveConversion = + shouldStartLiveConversion(insertString) && !hasConvertedKatakana + val applyLiveConversionBeforeCandidateStrip = + shouldApplyLiveConversion && liveConversionApplyDelayMillis() == 0L + if (applyLiveConversionBeforeCandidateStrip) { + delayBeforeApplyingLiveConversion() + if (!shouldApplyCandidateResult(insertString, token)) return + if (!applyFirstSuggestionOnMainIfCurrent( + insertString = insertString, + candidate = displayedCandidates.firstOrNull(), + ) + ) return + } updateDisplayedCandidates(insertString, displayedCandidates) - if (shouldStartLiveConversion(insertString) && !hasConvertedKatakana) { + if (shouldApplyLiveConversion && !applyLiveConversionBeforeCandidateStrip) { delayBeforeApplyingLiveConversion() if (!shouldApplyCandidateResult(insertString, token)) { return @@ -22109,7 +22290,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, (enableTypoCorrectionQwertyEnglishKeyboardPreference == true) && (qwertyMode.value == TenKeyQWERTYMode.TenKeyQWERTY || (qwertyMode.value == TenKeyQWERTYMode.TenKeyQWERTYRomaji && !currentQwertyRomajiModeForSession)) - val coreResult = withContext(Dispatchers.Default) { + val coreResult = withContext(kanaKanjiConversionDispatcher) { queryKanaKanjiCore( input = insertString, mode = CandidateQueryMode.NO_TAB_DEFAULT, @@ -22141,17 +22322,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } }.withoutHentaiganaCandidatesIfNeeded().distinctBy { it.string } - val orderedCandidates = - if (appPreference.candidate_order_override_enable_preference == true) { - measureDebugStage("IMEService.candidateOrderOverride") { - candidateOrderOverrideRepository.applyOrderFromSnapshot( - input = insertString, - candidates = filteredCandidates - ) - } - } else { - filteredCandidates - } + val orderedCandidates = applyMergedCandidateOrder( + input = insertString, + candidates = filteredCandidates, + candidateSegmentsByString = coreResult.candidateSegmentsByString, + ) if (candidateRequestTracker.isCurrent(token)) { updateBunsetsuStateAfterCandidateMerge( @@ -22168,44 +22343,53 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, insertString: String, mainView: MainLayoutBinding, token: CandidateRequestToken? = null, - ): List = measureDebugStage("IMEService.getSuggestionList") { - val resultFromUserDictionary = if (isUserDictionaryEnable == true) { - measureDebugStage("IMEService.getSuggestionList.userDictionary") { - withContext(Dispatchers.IO) { + ): List = measureDebugStage("IMEService.getSuggestionList") { coroutineScope { + // These lookups do not depend on one another. Starting them together moves Room and + // template latency under the converter's CPU time instead of paying it serially first. + val userDictionaryDeferred = async(Dispatchers.IO) { + if (isUserDictionaryEnable == true) { + measureDebugStage("IMEService.getSuggestionList.userDictionary") { val prefixMatchNumber = (userDictionaryPrefixMatchNumber ?: 2) - 1 - if (insertString.length <= prefixMatchNumber) return@withContext emptyList() - userDictionaryRepository.searchByReadingPrefixSuspend( - prefix = insertString, limit = userDictionaryPredictionCandidateLimit - ).map { - Candidate( - string = it.word, - type = CANDIDATE_TYPE_USER_DICTIONARY, - length = (it.reading.length).toUByte(), - score = it.posScore - ) - }.sortedBy { it.score } + if (insertString.length <= prefixMatchNumber) { + emptyList() + } else { + userDictionaryRepository.searchByReadingPrefixSuspend( + prefix = insertString, + limit = userDictionaryPredictionCandidateLimit, + ).map { + Candidate( + string = it.word, + type = CANDIDATE_TYPE_USER_DICTIONARY, + length = it.reading.length.toUByte(), + score = it.posScore, + ) + }.sortedBy { it.score } + } } + } else { + emptyList() } - } else { - emptyList() } - val resultFromUserTemplate = + val userTemplateDeferred = async { measureDebugStage("IMEService.getSuggestionList.userTemplate") { getUserTemplateCandidates(insertString) } + } val suggestionLearnRepository = learnedRepositoryForSuggestion() - val resultFromLearnDictionary = + val learnDictionaryDeferred = async(Dispatchers.IO) { if (enablePredictionSearchLearnDictionaryPreference == true && suggestionLearnRepository != null ) { measureDebugStage("IMEService.getSuggestionList.learnDictionary") { - withContext(Dispatchers.IO) { - val prefixMatchNumber = (learnPredictionPreference ?: 4) - 1 - if (insertString.length <= prefixMatchNumber) return@withContext emptyList() + val prefixMatchNumber = (learnPredictionPreference ?: 4) - 1 + if (insertString.length <= prefixMatchNumber) { + emptyList() + } else { suggestionLearnRepository.predictiveSearchByInput( - prefix = insertString, limit = learnDictionaryPredictionCandidateLimit + prefix = insertString, + limit = learnDictionaryPredictionCandidateLimit, ).map { Candidate( string = it.out, @@ -22220,6 +22404,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } else { emptyList() } + } val ngWords = measureDebugStage("IMEService.getSuggestionList.ngWordSnapshot") { if (isNgWordEnable == true) ngWordsList.value.map { it.tango } else emptyList() @@ -22233,8 +22418,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, (enableTypoCorrectionQwertyEnglishKeyboardPreference == true) && (qwertyMode.value == TenKeyQWERTYMode.TenKeyQWERTY || (qwertyMode.value == TenKeyQWERTYMode.TenKeyQWERTYRomaji && !currentQwertyRomajiModeForSession)) - val coreResult = measureDebugStage("IMEService.getSuggestionList.kanaKanjiEngine") { - withContext(Dispatchers.Default) { + val coreResultDeferred = async(kanaKanjiConversionDispatcher) { + measureDebugStage("IMEService.getSuggestionList.kanaKanjiEngine") { queryKanaKanjiCore( input = insertString, mode = CandidateQueryMode.PREDICTION, @@ -22245,6 +22430,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) } } + val coreResult = coreResultDeferred.await() + val resultFromUserDictionary = userDictionaryDeferred.await() + val resultFromUserTemplate = userTemplateDeferred.await() + val resultFromLearnDictionary = learnDictionaryDeferred.await() val engineResult = coreResult.bunsetsuResult val engineCandidates = coreResult.candidates engineResult?.let { @@ -22273,17 +22462,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, }.withoutHentaiganaCandidatesIfNeeded().distinctBy { it.string } } - val orderedCandidates = - if (appPreference.candidate_order_override_enable_preference == true) { - measureDebugStage("IMEService.candidateOrderOverride") { - candidateOrderOverrideRepository.applyOrderFromSnapshot( - input = insertString, - candidates = filteredCandidates - ) - } - } else { - filteredCandidates - } + val orderedCandidates = applyMergedCandidateOrder( + input = insertString, + candidates = filteredCandidates, + candidateSegmentsByString = coreResult.candidateSegmentsByString, + ) if (token == null || candidateRequestTracker.isCurrent(token)) { updateBunsetsuStateAfterCandidateMerge( @@ -22294,7 +22477,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } orderedCandidates - } + } } private fun getLeftContext(inputLength: Int): String { val ic = currentInputConnection ?: return "" @@ -22373,7 +22556,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, val ngWords = if (isNgWordEnable == true) ngWordsList.value.map { it.tango } else emptyList() - val coreResult = withContext(Dispatchers.Default) { + val coreResult = withContext(kanaKanjiConversionDispatcher) { queryKanaKanjiCore( input = insertString, mode = CandidateQueryMode.CONVERSION, @@ -22402,17 +22585,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } }.withoutHentaiganaCandidatesIfNeeded().distinctBy { it.string } - val orderedCandidates = - if (appPreference.candidate_order_override_enable_preference == true) { - measureDebugStage("IMEService.candidateOrderOverride") { - candidateOrderOverrideRepository.applyOrderFromSnapshot( - input = insertString, - candidates = filteredCandidates - ) - } - } else { - filteredCandidates - } + val orderedCandidates = applyMergedCandidateOrder( + input = insertString, + candidates = filteredCandidates, + candidateSegmentsByString = coreResult.candidateSegmentsByString, + ) if (candidateRequestTracker.isCurrent(token)) { updateBunsetsuStateAfterCandidateMerge( @@ -22425,10 +22602,38 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, return orderedCandidates } + private suspend fun applyMergedCandidateOrder( + input: String, + candidates: List, + candidateSegmentsByString: Map> = emptyMap(), + ): List { + val promotedCandidates = measureDebugStage("IMEService.exactInputPromotion") { + ExactInputCandidatePromotionPolicy.promote( + input = input, + candidates = candidates, + ) + } + return if (appPreference.candidate_order_override_enable_preference == true) { + if (candidateSegmentsByString.isNotEmpty()) { + latestCandidateSegmentInput = input + latestCandidateSegmentsByString = candidateSegmentsByString + } + measureDebugStage("IMEService.candidateOrderOverride") { + candidateOrderOverrideRepository.applyOrderFromSnapshot( + input = input, + candidates = promotedCandidates, + candidateSegmentsByString = candidateSegmentsByString, + ) + } + } else { + promotedCandidates + } + } + private suspend fun getSuggestionListEnglishKana( insertString: String, ): List { - val engineCandidates = withContext(Dispatchers.Default) { + val engineCandidates = withContext(kanaKanjiConversionDispatcher) { queryKanaKanjiCore( input = insertString, mode = CandidateQueryMode.EISUKANA, @@ -22452,7 +22657,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ).also { kanaKanjiConversionSession = it } - return session.query( + val result = session.query( KanaKanjiQueryRequest( input = input, mode = mode, @@ -22473,8 +22678,25 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, omissionSearchOffsetScore = omissionSearchOffsetScorePreference ?: 1900, beamWidth = conversionBeamWidth, predictionConfig = predictionConfig, + collectCandidateSegments = + appPreference.candidate_order_override_enable_preference == true, ) ) + if (BuildConfig.DEBUG) { + session.performanceSnapshot()?.let { snapshot -> + Timber.d( + "conversionStages inputLength=%d graph=%.2fms penalty=%.2fms forward=%.2fms backward=%.2fms graphReused=%s forwardReused=%s", + input.length, + snapshot.graphNs / 1_000_000.0, + snapshot.penaltyNs / 1_000_000.0, + snapshot.forwardDpNs / 1_000_000.0, + snapshot.backwardSearchNs / 1_000_000.0, + snapshot.graphAppendReused, + snapshot.forwardDpReused, + ) + } + } + return result } private fun List.withoutHentaiganaCandidatesIfNeeded(): List { @@ -22595,45 +22817,56 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, invalidateZeroQueryForEditorMutation() if (isKeyboardLayoutEditModeActive()) return if (deleteLongPressJob?.isActive == true) return + val behavior = deleteLongPressConversionBehavior + activeDeleteLongPressConversionBehavior = behavior + deleteLongPressFinalRefreshRequested = false + if (behavior.suspendsCandidateResultsDuringRepeat) { + if (!deleteLongPressConversionGate.beginRepeat()) { + activeDeleteLongPressConversionBehavior = null + return + } + candidateRequestTracker.invalidate() + } activeDeleteHistoryBatch = DeleteHistoryBatch( initialInput = inputString.value, initialTail = stringInTail.get(), deletesCommittedText = inputString.value.isEmpty() ) deleteLongPressJob = scope.launch { - while (isActive && deleteKeyLongKeyPressed.get()) { - val current = inputString.value - val tailIsEmpty = stringInTail.get().isEmpty() - - if (current.isEmpty()) { - if (tailIsEmpty) { - if (isEditHistoryEnabled()) { - val beforeChar = - captureDeletedTextFromConnection(currentInputConnection) - if (beforeChar.isNotEmpty()) { - activeDeleteHistoryBatch?.deletedText?.insert(0, beforeChar) + try { + while (isActive && deleteKeyLongKeyPressed.get()) { + val current = inputString.value + val tailIsEmpty = stringInTail.get().isEmpty() + + if (current.isEmpty()) { + if (tailIsEmpty) { + if (isEditHistoryEnabled()) { + val beforeChar = + captureDeletedTextFromConnection(currentInputConnection) + if (beforeChar.isNotEmpty()) { + activeDeleteHistoryBatch?.deletedText?.insert(0, beforeChar) + } } + deleteLastGraphemeOrSelection() + } else { + break } - deleteLastGraphemeOrSelection() } else { - break - } - } else { - val newString = current.dropLast(1) - _inputString.update { newString } - if (newString.isEmpty() && tailIsEmpty) { - setComposingText("", 0) + val newString = current.dropLast(1) + _inputString.update { newString } + if (newString.isEmpty() && tailIsEmpty) { + clearStyledComposingTextAfterFinalDelete(current) + } } - } - delay(LONG_DELAY_TIME) + delay(LONG_DELAY_TIME) + } + } finally { + // The key-up path cancels this job. Final candidate cleanup must therefore also + // live in finally so cancellation cannot skip it. + enableContinuousTapInput() + requestFinalDeleteLongPressRefresh() } - // (連続タップ入力解除など) - enableContinuousTapInput() - - val flag = if (inputString.value.isEmpty()) CandidateShowFlag.Idle - else CandidateShowFlag.Updating - requestCandidateRefresh(flag) } deleteLongPressJob?.invokeOnCompletion { if (selectMode.value || !isEditHistoryEnabled()) { @@ -22653,11 +22886,60 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } + private fun requestFinalDeleteLongPressRefresh() { + if (deleteLongPressFinalRefreshRequested) return + + val behavior = activeDeleteLongPressConversionBehavior ?: return + val finalInput = inputString.value + val refreshFlag = when (behavior) { + DeleteLongPressConversionBehavior.Deferred -> + deleteLongPressConversionGate.finishRepeat(finalInput) + + DeleteLongPressConversionBehavior.Continuous -> + if (finalInput.isEmpty()) { + CandidateShowFlag.Idle + } else { + CandidateShowFlag.Updating + } + } ?: return + + deleteLongPressFinalRefreshRequested = true + candidateRequestTracker.invalidate() + Timber.d( + "deleteLongPress: final candidate refresh input=[%s] flag=%s", + finalInput, + refreshFlag, + ) + requestCandidateRefresh(refreshFlag, finalInput) + } + + /** + * Removes the final composing character without collapsing composing spans to length zero. + * + * Some editor implementations warn when a styled composing range is replaced directly with an + * empty string. Finishing and deleting inside one batch removes those spans first and does not + * expose the temporary committed state to the display. + */ + private fun clearStyledComposingTextAfterFinalDelete(previousInput: String) { + beginBatchEdit() + try { + finishComposingText() + val codePointCount = previousInput.codePointCount(0, previousInput.length) + if (codePointCount > 0) { + deleteSurroundingTextInCodePoints(codePointCount, 0) + } + } finally { + endBatchEdit() + } + } + private fun stopDeleteLongPress() { deleteKeyLongKeyPressed.set(false) onDeleteLongPressUp.set(true) deleteLongPressJob?.cancel() deleteLongPressJob = null + requestFinalDeleteLongPressRefresh() + activeDeleteLongPressConversionBehavior = null } private fun enableContinuousTapInput() { @@ -23891,10 +24173,54 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } + private fun currentFlickPreviewContext(source: FlickPreviewSource): FlickPreviewContext { + val surfaceEligible = when (source) { + FlickPreviewSource.TENKEY -> qwertyMode.value == TenKeyQWERTYMode.Default + FlickPreviewSource.SUMIRE -> qwertyMode.value == TenKeyQWERTYMode.Sumire || + qwertyMode.value == TenKeyQWERTYMode.Number + } + return FlickPreviewContext( + source = source, + editorSessionId = flickPreviewEditorSessionId, + settingEnabled = flickEditorPreviewPreference, + surfaceEligible = surfaceEligible && !isKeyboardLayoutEditModeActive(), + inputBehaviorUsesComposingText = + currentInputBehavior == ResolvedInputBehavior.COMPOSING_TEXT, + safeInputType = currentInputType !in passwordTypes && + currentInputType !in numberTypes && + currentInputType != InputTypeForIME.None, + isHenkan = isHenkan.get(), + selectMode = selectMode.value, + cursorMoveMode = cursorMoveMode.value, + composingTail = stringInTail.get(), + hasInputConnection = currentInputConnection != null, + baseInput = inputString.value, + isFlickOnlyMode = isFlickOnlyMode == true, + isContinuousTapInputEnabled = isContinuousTapInputEnabled.get(), + lastFlickConvertedNextHiragana = lastFlickConvertedNextHiragana.get(), + ) + } + + private fun applyPendingFlickTextMutation(text: String, isFlick: Boolean): Boolean { + val mutation = flickInputPreviewCoordinator.consumePendingCommit(text, isFlick) + ?: return false + suggestionClickNum = 0 + _dakutenPressed.value = false + englishSpaceKeyPressed.set(false) + onDeleteLongPressUp.set(false) + mutation.effects.continuousTapInputEnabled?.let(isContinuousTapInputEnabled::set) + mutation.effects.lastFlickConvertedNextHiragana?.let( + lastFlickConvertedNextHiragana::set + ) + _inputString.update { mutation.resultInput } + return true + } + private fun sendCharTap( charToSend: Char, insertString: String, sb: StringBuilder ) { if (dispatchDirectTextIfNeeded(charToSend.toString())) return + if (applyPendingFlickTextMutation(charToSend.toString(), isFlick = false)) return when (currentInputType) { InputTypeForIME.None, InputTypeForIME.Number, @@ -23943,6 +24269,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, charToSend: Char, insertString: String, sb: StringBuilder ) { if (dispatchDirectTextIfNeeded(charToSend.toString())) return + if (applyPendingFlickTextMutation(charToSend.toString(), isFlick = true)) return when (currentInputType) { InputTypeForIME.None, InputTypeForIME.Number, @@ -24934,12 +25261,14 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, override fun deleteSurroundingText(p0: Int, p1: Int): Boolean { if (currentInputConnection == null) return false + flickInputPreviewCoordinator.cancel(restore = true) cancelCandidateTranslationIfPreEditMutates() return currentInputConnection.deleteSurroundingText(p0, p1) } override fun deleteSurroundingTextInCodePoints(p0: Int, p1: Int): Boolean { if (currentInputConnection == null) return false + flickInputPreviewCoordinator.cancel(restore = true) cancelCandidateTranslationIfPreEditMutates() return currentInputConnection.deleteSurroundingTextInCodePoints(p0, p1) } @@ -24947,40 +25276,48 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, override fun setComposingText(p0: CharSequence?, p1: Int): Boolean { if (currentInputConnection == null) return false cancelCandidateTranslationIfComposingChanges(p0) - return currentInputConnection.setComposingText(p0, p1) + return composingTextArbiter.setCanonical(p0, p1) } override fun setComposingRegion(p0: Int, p1: Int): Boolean { if (currentInputConnection == null) return false + flickInputPreviewCoordinator.cancel(restore = true) return currentInputConnection.setComposingRegion(p0, p1) } override fun finishComposingText(): Boolean { if (currentInputConnection == null) return false + flickInputPreviewCoordinator.cancel(restore = true) clearFunctionKeyConversionSource() cancelCandidateTranslationIfPreEditMutates() - return currentInputConnection.finishComposingText() + return composingTextArbiter.finishCanonical() } override fun commitText(p0: CharSequence?, p1: Int): Boolean { if (currentInputConnection == null) return false + flickInputPreviewCoordinator.cancel(restore = true) clearFunctionKeyConversionSource() cancelCandidateTranslationIfPreEditMutates() - return currentInputConnection.commitText(p0, p1) + val committed = currentInputConnection.commitText(p0, p1) + if (committed) composingTextArbiter.markCanonicalFinished() + return committed } override fun commitCompletion(p0: CompletionInfo?): Boolean { if (currentInputConnection == null) return false + flickInputPreviewCoordinator.cancel(restore = true) return currentInputConnection.commitCompletion(p0) } override fun commitCorrection(p0: CorrectionInfo?): Boolean { if (currentInputConnection == null) return false + flickInputPreviewCoordinator.cancel(restore = true) return currentInputConnection.commitCorrection(p0) } override fun setSelection(p0: Int, p1: Int): Boolean { if (currentInputConnection == null) return false + flickInputPreviewCoordinator.cancel(restore = true) return currentInputConnection.setSelection(p0, p1) } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt index 86a5b0fdd..e4f0dfd03 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt @@ -21,6 +21,7 @@ data class ImePreferencesSnapshot( val mozcUTNeologd: Boolean, val mozcUTWeb: Boolean, val isFlickOnlyMode: Boolean, + val flickEditorPreviewPreference: Boolean, val isOmissionSearchEnable: Boolean, val delayTime: Int, val isLearnDictionaryMode: Boolean, @@ -289,6 +290,7 @@ data class ImePreferencesSnapshot( appPreference.mozc_ut_web_preference, ), isFlickOnlyMode = appPreference.flick_input_only_preference ?: false, + flickEditorPreviewPreference = appPreference.flick_editor_preview_preference, isOmissionSearchEnable = appPreference.omission_search_preference ?: false, delayTime = appPreference.time_same_pronounce_typing_preference ?: 1000, isLearnDictionaryMode = appPreference.learn_dictionary_preference ?: true, @@ -317,7 +319,10 @@ data class ImePreferencesSnapshot( appPreference.live_conversion_candidate_yomi_preference ?: false, nBest = appPreference.n_best_preference ?: 4, conversionBeamWidth = appPreference.conversion_beam_width_preference, - conversionBackend = if (appPreference.incremental_conversion_session_preference) { + conversionBackend = if ( + appPreference.incremental_conversion_session_preference || + appPreference.live_conversion_preference == true + ) { ConversionBackend.INCREMENTAL_SESSION } else { ConversionBackend.LEGACY diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateRefreshCoordinator.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateRefreshCoordinator.kt index 8335e6c8b..4d4687653 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateRefreshCoordinator.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateRefreshCoordinator.kt @@ -68,4 +68,5 @@ internal data class CandidateRefreshRequest( val revision: Long, val input: String, val flag: CandidateShowFlag, + val publishedAtNanos: Long = System.nanoTime(), ) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/di/AppModule.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/di/AppModule.kt index 87d948cbd..92bc912a9 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/di/AppModule.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/di/AppModule.kt @@ -55,6 +55,9 @@ import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.M import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_38_39 import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_39_40 import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_40_41 +import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_41_42 +import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_42_43 +import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_43_44 import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_2_3 import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_3_4 import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase.Companion.MIGRATION_4_5 @@ -151,6 +154,9 @@ object AppModule { MIGRATION_38_39, MIGRATION_39_40, MIGRATION_40_41, + MIGRATION_41_42, + MIGRATION_42_43, + MIGRATION_43_44, ) .build() diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/di/KanaKanjiEngineEntryPoint.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/di/KanaKanjiEngineEntryPoint.kt index 22f8dc853..f710e89a5 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/di/KanaKanjiEngineEntryPoint.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/di/KanaKanjiEngineEntryPoint.kt @@ -1,10 +1,11 @@ package com.kazumaproject.markdownhelperkeyboard.ime_service.di import com.kazumaproject.markdownhelperkeyboard.converter.engine.KanaKanjiEngine -import com.kazumaproject.markdownhelperkeyboard.repository.UserDictionaryRepository -import com.kazumaproject.markdownhelperkeyboard.repository.NgramRuleRepository -import com.kazumaproject.markdownhelperkeyboard.ngram_rule.NgramRuleScorerManager import com.kazumaproject.markdownhelperkeyboard.dictionary_override.DictionaryBinaryReader +import com.kazumaproject.markdownhelperkeyboard.ngram_rule.NgramRuleScorerManager +import com.kazumaproject.markdownhelperkeyboard.repository.CandidateOrderOverrideRepository +import com.kazumaproject.markdownhelperkeyboard.repository.NgramRuleRepository +import com.kazumaproject.markdownhelperkeyboard.repository.UserDictionaryRepository import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent @@ -17,4 +18,5 @@ interface KanaKanjiEngineEntryPoint { fun ngramRuleRepository(): NgramRuleRepository fun ngramRuleScorerManager(): NgramRuleScorerManager fun dictionaryBinaryReader(): DictionaryBinaryReader + fun candidateOrderOverrideRepository(): CandidateOrderOverrideRepository } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/extensions/NumberExtenstion.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/extensions/NumberExtenstion.kt index cf9532c6c..cc0c6a87c 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/extensions/NumberExtenstion.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/extensions/NumberExtenstion.kt @@ -7,125 +7,148 @@ fun detectMultipleSen(input: String): Boolean { return regex.matches(input) } -private val japaneseNumberDigits = mapOf( - "ぜろ" to 0L, "れい" to 0L, - "いち" to 1L, "いっ" to 1L, - "に" to 2L, - "さん" to 3L, - "よん" to 4L, "よ" to 4L, "し" to 4L, - "ご" to 5L, - "ろく" to 6L, "ろっ" to 6L, - "なな" to 7L, "しち" to 7L, - "はち" to 8L, "はっ" to 8L, - "きゅう" to 9L, "く" to 9L -) - -private val japaneseNumberUnits = mapOf( - "じゅう" to 10L, - "ひゃく" to 100L, "びゃく" to 100L, "ぴゃく" to 100L, - "せん" to 1000L, "ぜん" to 1000L -) - -private val japaneseNumberBigUnits = listOf( - "ちょう" to 1_000_000_000_000L, - "おく" to 100_000_000L, - "まん" to 10_000L -) - -private val japaneseNumberDigitPatterns = - japaneseNumberDigits.keys.sortedByDescending { it.length } - -private fun normalizeJapaneseNumberReading(input: String): String { - return input - .replace("じゅっ", "じゅう") - .replace("じっ", "じゅう") -} - -private fun parseJapaneseDigitSequence(text: String): Long? { - if (text.isEmpty()) return null - - var number = 0L - var remaining = text - - while (remaining.isNotEmpty()) { - val matched = japaneseNumberDigitPatterns.firstOrNull { remaining.startsWith(it) } ?: return null - number = number * 10 + japaneseNumberDigits.getValue(matched) - remaining = remaining.substring(matched.length) - } - - return number +private enum class JapaneseNumberReadingType { + UNIT, + SMALL_PLACE, + BIG_PLACE, } -private fun parseJapaneseNumberSection(sectionText: String): Long? { - if (sectionText.isEmpty()) return 0L - - var sectionTotal = 0L - var remaining = sectionText +private data class JapaneseNumberReading( + val reading: String, + val type: JapaneseNumberReadingType, + val value: Long, + val placeOrder: Int = 1, +) - val orderedUnits = listOf("せん", "ぜん", "ひゃく", "びゃく", "ぴゃく", "じゅう") +/** + * Number readings accepted by Mozc's NumberDecoder, limited to the range that + * can be represented by this app's Long-based number candidate API. + * + * Sound changes such as 「いっ」「ろっ」「はっ」 are units here, not global + * string replacements. Decoder state determines where they are usable. + */ +private val japaneseNumberReadings = listOf( + JapaneseNumberReading("ぜろ", JapaneseNumberReadingType.UNIT, 0L), + JapaneseNumberReading("れい", JapaneseNumberReadingType.UNIT, 0L), + JapaneseNumberReading("いち", JapaneseNumberReadingType.UNIT, 1L), + JapaneseNumberReading("いっ", JapaneseNumberReadingType.UNIT, 1L), + JapaneseNumberReading("に", JapaneseNumberReadingType.UNIT, 2L), + JapaneseNumberReading("さん", JapaneseNumberReadingType.UNIT, 3L), + JapaneseNumberReading("し", JapaneseNumberReadingType.UNIT, 4L), + JapaneseNumberReading("よん", JapaneseNumberReadingType.UNIT, 4L), + JapaneseNumberReading("よ", JapaneseNumberReadingType.UNIT, 4L), + JapaneseNumberReading("ご", JapaneseNumberReadingType.UNIT, 5L), + JapaneseNumberReading("ろく", JapaneseNumberReadingType.UNIT, 6L), + JapaneseNumberReading("ろっ", JapaneseNumberReadingType.UNIT, 6L), + JapaneseNumberReading("なな", JapaneseNumberReadingType.UNIT, 7L), + JapaneseNumberReading("しち", JapaneseNumberReadingType.UNIT, 7L), + JapaneseNumberReading("はち", JapaneseNumberReadingType.UNIT, 8L), + JapaneseNumberReading("はっ", JapaneseNumberReadingType.UNIT, 8L), + JapaneseNumberReading("きゅう", JapaneseNumberReadingType.UNIT, 9L), + JapaneseNumberReading("きゅー", JapaneseNumberReadingType.UNIT, 9L), + JapaneseNumberReading("く", JapaneseNumberReadingType.UNIT, 9L), + JapaneseNumberReading("じゅう", JapaneseNumberReadingType.SMALL_PLACE, 10L, 2), + JapaneseNumberReading("じゅー", JapaneseNumberReadingType.SMALL_PLACE, 10L, 2), + JapaneseNumberReading("じゅっ", JapaneseNumberReadingType.SMALL_PLACE, 10L, 2), + JapaneseNumberReading("じっ", JapaneseNumberReadingType.SMALL_PLACE, 10L, 2), + JapaneseNumberReading("ひゃく", JapaneseNumberReadingType.SMALL_PLACE, 100L, 3), + JapaneseNumberReading("ひゃっ", JapaneseNumberReadingType.SMALL_PLACE, 100L, 3), + JapaneseNumberReading("びゃく", JapaneseNumberReadingType.SMALL_PLACE, 100L, 3), + JapaneseNumberReading("びゃっ", JapaneseNumberReadingType.SMALL_PLACE, 100L, 3), + JapaneseNumberReading("ぴゃく", JapaneseNumberReadingType.SMALL_PLACE, 100L, 3), + JapaneseNumberReading("ぴゃっ", JapaneseNumberReadingType.SMALL_PLACE, 100L, 3), + JapaneseNumberReading("せん", JapaneseNumberReadingType.SMALL_PLACE, 1_000L, 4), + JapaneseNumberReading("ぜん", JapaneseNumberReadingType.SMALL_PLACE, 1_000L, 4), + JapaneseNumberReading("まん", JapaneseNumberReadingType.BIG_PLACE, 10_000L, 5), + JapaneseNumberReading("おく", JapaneseNumberReadingType.BIG_PLACE, 100_000_000L, 9), + JapaneseNumberReading("おっ", JapaneseNumberReadingType.BIG_PLACE, 100_000_000L, 9), + JapaneseNumberReading("ちょう", JapaneseNumberReadingType.BIG_PLACE, 1_000_000_000_000L, 13), +).sortedByDescending { it.reading.length } - for (unit in orderedUnits) { - while (remaining.contains(unit)) { - val index = remaining.indexOf(unit) - val digitText = remaining.substring(0, index) - val digitValue = if (digitText.isEmpty()) { - 1L - } else { - parseJapaneseDigitSequence(digitText) ?: return null - } +private fun parseJapaneseNumberValue(input: String): Long? { + if (input.isEmpty()) return null - sectionTotal += digitValue * japaneseNumberUnits.getValue(unit) - remaining = remaining.substring(index + unit.length) + var index = 0 + var total = 0L + var section = -1L + var smallPlaceOrder = -1 + var bigPlaceOrder = Int.MAX_VALUE + var hasNumber = false + var restrictedUnit: String? = null + + while (index < input.length) { + val entry = japaneseNumberReadings.firstOrNull { + input.startsWith(it.reading, index) + } ?: return null + + // Mozc accepts these historical readings only in constrained places: + // 「よ」「く」 must finish the number, while 「し」 may additionally + // precede canonical 「じゅう」 (e.g. 四十 = しじゅう). + when (restrictedUnit) { + "よ", "く" -> return null + "し" -> if (entry.reading != "じゅう") return null + } + restrictedUnit = if ( + entry.type == JapaneseNumberReadingType.UNIT && + (entry.reading == "し" || entry.reading == "よ" || entry.reading == "く") + ) { + entry.reading + } else { + null } - } - - if (remaining.isNotEmpty()) { - sectionTotal += parseJapaneseDigitSequence(remaining) ?: return null - } - - return sectionTotal -} -private fun parseJapaneseNumberValue(input: String): Long? { - if (input == "ちょうせん" || detectMultipleSen(input) || input == "おくせん") return null + when (entry.type) { + JapaneseNumberReadingType.UNIT -> { + if (hasNumber && entry.value == 0L) return null + if (section == 0L || (section >= 0L && section % 10L != 0L)) return null - val normalized = normalizeJapaneseNumberReading(input) - if (normalized.isEmpty()) return null + section = if (section < 0L) { + entry.value + } else { + Math.addExact(section, entry.value) + } + hasNumber = true + } - var remaining = normalized - var total = 0L + JapaneseNumberReadingType.SMALL_PLACE -> { + if (smallPlaceOrder > 1 && entry.placeOrder >= smallPlaceOrder) return null + if (section == 0L) return null - while (remaining.isNotEmpty()) { - var matchedBigUnit = false + section = if (section < 0L) { + entry.value + } else { + val unit = maxOf(1L, section % 10L) + val base = section / 10L * 10L + Math.addExact(base, Math.multiplyExact(unit, entry.value)) + } + smallPlaceOrder = entry.placeOrder + hasNumber = true + } - for ((bigUnit, bigUnitValue) in japaneseNumberBigUnits) { - if (!remaining.contains(bigUnit)) continue + JapaneseNumberReadingType.BIG_PLACE -> { + if (entry.placeOrder >= bigPlaceOrder || section <= 0L) return null - val index = remaining.indexOf(bigUnit) - val leftText = remaining.substring(0, index) - val sectionValue = if (leftText.isEmpty()) { - 1L - } else { - parseJapaneseNumberSection(leftText) ?: return null + total = Math.addExact(total, Math.multiplyExact(section, entry.value)) + section = -1L + smallPlaceOrder = -1 + bigPlaceOrder = entry.placeOrder + hasNumber = true } - - total += sectionValue * bigUnitValue - remaining = remaining.substring(index + bigUnit.length) - matchedBigUnit = true - break } - if (!matchedBigUnit) { - total += parseJapaneseNumberSection(remaining) ?: return null - break - } + index += entry.reading.length } - return total + if (!hasNumber) return null + return if (section >= 0L) Math.addExact(total, section) else total } fun String.toNumber(): Pair? { - val total = parseJapaneseNumberValue(this) ?: return null + val total = try { + parseJapaneseNumberValue(this) + } catch (_: ArithmeticException) { + null + } ?: return null val fullWidth = total.toString().map { it.toFullWidthChar() }.joinToString("") val halfWidth = total.toString() @@ -134,7 +157,11 @@ fun String.toNumber(): Pair? { } fun String.toNumberExponent(): Pair? { - val total = parseJapaneseNumberValue(this) ?: return null + val total = try { + parseJapaneseNumberValue(this) + } catch (_: ArithmeticException) { + null + } ?: return null val result = if (total < 100_000_000L) { null diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/extensions/StringExtenstion.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/extensions/StringExtenstion.kt index 9f1a4eea2..21c9115da 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/extensions/StringExtenstion.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/extensions/StringExtenstion.kt @@ -287,27 +287,6 @@ fun String.replaceJapaneseCharactersForEnglish(): String { .replace('!', '!') } -fun String.groupAndReplaceJapaneseForNumber(): String { - val result = StringBuilder() - for (char in this) { - when (char) { - in 'あ'..'お' -> result.append('1') - in 'か'..'こ' -> result.append('2') - in 'さ'..'そ' -> result.append('3') - in 'た'..'と' -> result.append('4') - in 'な'..'の' -> result.append('5') - in 'は'..'ほ' -> result.append('6') - in 'ま'..'も' -> result.append('7') - 'や', 'ゆ', 'よ' -> result.append('8') - in 'ら'..'ろ' -> result.append('9') - 'わ', 'を', 'ん' -> result.append('0') - // 上記以外の文字はそのまま追加する - else -> result.append(char) - } - } - return result.toString() -} - /** * 数字文字列を上付き文字(¹²³)に変換します。 */ diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/ComposingTextArbiter.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/ComposingTextArbiter.kt new file mode 100644 index 000000000..b02f70b11 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/ComposingTextArbiter.kt @@ -0,0 +1,80 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.flick_preview + +internal sealed interface CanonicalComposingState { + data class Text( + val value: CharSequence, + val cursorPosition: Int, + ) : CanonicalComposingState + + data object Finished : CanonicalComposingState +} + +/** Keeps transient flick previews separate from the IME's canonical composing state. */ +internal class ComposingTextArbiter( + private val writeComposingText: (CharSequence?, Int) -> Boolean, + private val finishComposingText: () -> Boolean, + private val copyText: (CharSequence) -> CharSequence = { it.toString() }, +) { + private var canonicalState: CanonicalComposingState = CanonicalComposingState.Finished + private var previewVisible = false + + fun setCanonical(text: CharSequence?, cursorPosition: Int): Boolean { + val copied = copyText(text ?: "") + canonicalState = CanonicalComposingState.Text(copied, cursorPosition) + return if (previewVisible) true else writeComposingText(copied, cursorPosition) + } + + fun showPreview(text: CharSequence, cursorPosition: Int): Boolean { + val written = writeComposingText(copyText(text), cursorPosition) + previewVisible = written + return written + } + + fun suspendPreviewAndRestore(): Boolean { + if (!previewVisible) return true + previewVisible = false + return restoreCanonical() + } + + fun releasePreview(leaveDisplayedText: Boolean) { + if (!previewVisible) return + previewVisible = false + if (!leaveDisplayedText) restoreCanonical() + } + + fun cancelPreviewAndRestore(): Boolean { + if (!previewVisible) return true + previewVisible = false + return restoreCanonical() + } + + fun restoreCanonical(): Boolean { + return when (val state = canonicalState) { + is CanonicalComposingState.Text -> writeComposingText( + state.value, + state.cursorPosition, + ) + + // finishComposingText() would commit the currently displayed preview. Replacing the + // composing region with an empty value removes it and restores the no-composition + // state without inserting preview text into the editor. + CanonicalComposingState.Finished -> writeComposingText("", 0) + } + } + + fun finishCanonical(): Boolean { + canonicalState = CanonicalComposingState.Finished + return if (previewVisible) true else finishComposingText() + } + + fun markCanonicalFinished() { + canonicalState = CanonicalComposingState.Finished + } + + fun resetForEditorSession() { + previewVisible = false + canonicalState = CanonicalComposingState.Finished + } + + fun isPreviewVisible(): Boolean = previewVisible +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/FlickInputPreviewCoordinator.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/FlickInputPreviewCoordinator.kt new file mode 100644 index 000000000..287bd8ce7 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/FlickInputPreviewCoordinator.kt @@ -0,0 +1,190 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.flick_preview + +import com.kazumaproject.core.domain.flick.FlickTextPreviewEvent +import com.kazumaproject.core.domain.flick.FlickTextSelection + +internal class FlickInputPreviewCoordinator( + private val composingTextArbiter: ComposingTextArbiter, + private val createPreviewText: (input: String, tail: String) -> CharSequence = + { input, _ -> input }, + private val mutationResolver: ( + FlickMutationSnapshot, + FlickTextSelection, + ) -> FlickTextMutation = FlickTextMutationResolver::resolve, +) { + private data class ActivePreview( + val gestureId: Long, + val editorSessionId: Long, + val source: FlickPreviewSource, + val snapshot: FlickMutationSnapshot, + val composingTail: String, + var lastSelection: FlickTextSelection, + var lastMutation: FlickTextMutation, + var pendingCommitConsumed: Boolean = false, + var previewWasReleased: Boolean = false, + ) + + private var active: ActivePreview? = null + + fun onEvent(event: FlickTextPreviewEvent, context: FlickPreviewContext) { + when (event) { + is FlickTextPreviewEvent.Started -> start(event, context) + is FlickTextPreviewEvent.Changed -> update(event, context) + is FlickTextPreviewEvent.CommitPending -> prepareCommit(event, context) + is FlickTextPreviewEvent.Finished -> finish(event, context) + is FlickTextPreviewEvent.Canceled -> cancelGesture(event, context) + } + } + + fun consumePendingCommit(text: String, isFlick: Boolean): FlickTextMutation.ReplaceComposingInput? { + val current = active ?: return null + if (!current.previewWasReleased || current.pendingCommitConsumed) return null + val mutation = current.lastMutation as? FlickTextMutation.ReplaceComposingInput ?: return null + if (mutation.selectedText != text || mutation.isFlick != isFlick) return null + current.pendingCommitConsumed = true + return mutation + } + + fun cancel(restore: Boolean) { + active?.let { current -> + if (restore) { + if (current.previewWasReleased) { + composingTextArbiter.restoreCanonical() + } else { + composingTextArbiter.cancelPreviewAndRestore() + } + } else { + composingTextArbiter.releasePreview(leaveDisplayedText = true) + } + } + active = null + } + + fun resetForEditorSession() { + active = null + composingTextArbiter.resetForEditorSession() + } + + private fun start( + event: FlickTextPreviewEvent.Started, + context: FlickPreviewContext, + ) { + cancel(restore = true) + if (!FlickPreviewEligibilityPolicy.isEligible(context)) return + + val snapshot = FlickMutationSnapshot( + baseInput = context.baseInput, + isFlickOnlyMode = context.isFlickOnlyMode, + isContinuousTapInputEnabled = context.isContinuousTapInputEnabled, + lastFlickConvertedNextHiragana = context.lastFlickConvertedNextHiragana, + ) + val mutation = mutationResolver(snapshot, event.selection) + active = ActivePreview( + gestureId = event.gestureId, + editorSessionId = context.editorSessionId, + source = context.source, + snapshot = snapshot, + composingTail = context.composingTail, + lastSelection = event.selection, + lastMutation = mutation, + ) + renderMutation(mutation) + } + + private fun update( + event: FlickTextPreviewEvent.Changed, + context: FlickPreviewContext, + ) { + val current = matchingActive(event.gestureId, context) ?: return + val mutation = mutationResolver(current.snapshot, event.selection) + current.lastSelection = event.selection + current.lastMutation = mutation + renderMutation(mutation) + } + + private fun prepareCommit( + event: FlickTextPreviewEvent.CommitPending, + context: FlickPreviewContext, + ) { + val current = matchingActive(event.gestureId, context) ?: return + val previousMutation = current.lastMutation + val mutation = mutationResolver(current.snapshot, event.selection) + current.lastSelection = event.selection + current.lastMutation = mutation + current.pendingCommitConsumed = false + + when (mutation) { + is FlickTextMutation.ReplaceComposingInput -> { + val previousResult = + (previousMutation as? FlickTextMutation.ReplaceComposingInput)?.resultInput + val needsEditorUpdate = !composingTextArbiter.isPreviewVisible() || + previousResult != mutation.resultInput + if (needsEditorUpdate && !showPreview(mutation, current.composingTail)) { + active = null + return + } + composingTextArbiter.releasePreview(leaveDisplayedText = true) + current.previewWasReleased = true + } + + FlickTextMutation.NoTextOutput -> { + composingTextArbiter.cancelPreviewAndRestore() + current.previewWasReleased = false + } + } + } + + private fun finish( + event: FlickTextPreviewEvent.Finished, + context: FlickPreviewContext, + ) { + val current = matchingActive(event.gestureId, context) ?: return + if (current.previewWasReleased && !current.pendingCommitConsumed) { + composingTextArbiter.restoreCanonical() + } + active = null + } + + private fun cancelGesture( + event: FlickTextPreviewEvent.Canceled, + context: FlickPreviewContext, + ) { + matchingActive(event.gestureId, context) ?: return + composingTextArbiter.cancelPreviewAndRestore() + active = null + } + + private fun matchingActive( + gestureId: Long, + context: FlickPreviewContext, + ): ActivePreview? { + return active?.takeIf { + it.gestureId == gestureId && + it.editorSessionId == context.editorSessionId && + it.source == context.source + } + } + + private fun renderMutation(mutation: FlickTextMutation) { + when (mutation) { + is FlickTextMutation.ReplaceComposingInput -> { + val current = active ?: return + if (!showPreview(mutation, current.composingTail)) { + active = null + } + } + + FlickTextMutation.NoTextOutput -> composingTextArbiter.suspendPreviewAndRestore() + } + } + + private fun showPreview( + mutation: FlickTextMutation.ReplaceComposingInput, + composingTail: String, + ): Boolean { + return composingTextArbiter.showPreview( + createPreviewText(mutation.resultInput, composingTail), + 1, + ) + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/FlickPreviewEligibilityPolicy.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/FlickPreviewEligibilityPolicy.kt new file mode 100644 index 000000000..576b83a0f --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/FlickPreviewEligibilityPolicy.kt @@ -0,0 +1,37 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.flick_preview + +internal enum class FlickPreviewSource { + TENKEY, + SUMIRE, +} + +internal data class FlickPreviewContext( + val source: FlickPreviewSource, + val editorSessionId: Long, + val settingEnabled: Boolean, + val surfaceEligible: Boolean, + val inputBehaviorUsesComposingText: Boolean, + val safeInputType: Boolean, + val isHenkan: Boolean, + val selectMode: Boolean, + val cursorMoveMode: Boolean, + val composingTail: String, + val hasInputConnection: Boolean, + val baseInput: String, + val isFlickOnlyMode: Boolean, + val isContinuousTapInputEnabled: Boolean, + val lastFlickConvertedNextHiragana: Boolean, +) + +internal object FlickPreviewEligibilityPolicy { + fun isEligible(context: FlickPreviewContext): Boolean { + return context.settingEnabled && + context.surfaceEligible && + context.inputBehaviorUsesComposingText && + context.safeInputType && + !context.isHenkan && + !context.selectMode && + !context.cursorMoveMode && + context.hasInputConnection + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/FlickTextMutationResolver.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/FlickTextMutationResolver.kt new file mode 100644 index 000000000..c9c88212e --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/FlickTextMutationResolver.kt @@ -0,0 +1,91 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.flick_preview + +import com.kazumaproject.core.domain.flick.FlickTextSelection +import com.kazumaproject.tenkey.extensions.getNextInputChar + +internal data class FlickMutationSnapshot( + val baseInput: String, + val isFlickOnlyMode: Boolean, + val isContinuousTapInputEnabled: Boolean, + val lastFlickConvertedNextHiragana: Boolean, +) + +internal data class FlickInputEffects( + val continuousTapInputEnabled: Boolean?, + val lastFlickConvertedNextHiragana: Boolean?, +) + +internal sealed interface FlickTextMutation { + data class ReplaceComposingInput( + val selectedText: String, + val isFlick: Boolean, + val resultInput: String, + val effects: FlickInputEffects, + ) : FlickTextMutation + + data object NoTextOutput : FlickTextMutation +} + +internal object FlickTextMutationResolver { + fun resolve( + snapshot: FlickMutationSnapshot, + selection: FlickTextSelection, + ): FlickTextMutation { + val selectedText = selection.text?.takeIf(String::isNotEmpty) + ?: return FlickTextMutation.NoTextOutput + + if (selectedText.length != 1) { + return FlickTextMutation.ReplaceComposingInput( + selectedText = selectedText, + isFlick = selection.isFlick, + resultInput = snapshot.baseInput + selectedText, + effects = FlickInputEffects( + continuousTapInputEnabled = null, + lastFlickConvertedNextHiragana = null, + ), + ) + } + + val selectedChar = selectedText.first() + val shouldAppend = selection.isFlick || + snapshot.isFlickOnlyMode || + (snapshot.isContinuousTapInputEnabled && + snapshot.lastFlickConvertedNextHiragana) + + val result = if (shouldAppend || snapshot.baseInput.isEmpty()) { + snapshot.baseInput + selectedChar + } else { + val nextChar = snapshot.baseInput.last().getNextInputChar(selectedChar) + if (nextChar == null) { + snapshot.baseInput + selectedChar + } else { + snapshot.baseInput.dropLast(1) + nextChar + } + } + + val effects = when { + selection.isFlick || snapshot.isFlickOnlyMode -> FlickInputEffects( + continuousTapInputEnabled = true, + lastFlickConvertedNextHiragana = true, + ) + + snapshot.isContinuousTapInputEnabled && + snapshot.lastFlickConvertedNextHiragana -> FlickInputEffects( + continuousTapInputEnabled = true, + lastFlickConvertedNextHiragana = false, + ) + + else -> FlickInputEffects( + continuousTapInputEnabled = false, + lastFlickConvertedNextHiragana = false, + ) + } + + return FlickTextMutation.ReplaceComposingInput( + selectedText = selectedText, + isFlick = selection.isFlick, + resultInput = result, + effects = effects, + ) + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/CandidateOrderOverrideRepository.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/CandidateOrderOverrideRepository.kt index 9dfe9851f..efadaa6ec 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/CandidateOrderOverrideRepository.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/CandidateOrderOverrideRepository.kt @@ -2,11 +2,13 @@ package com.kazumaproject.markdownhelperkeyboard.repository import com.kazumaproject.markdownhelperkeyboard.candidate_order.database.CandidateOrderOverrideDao import com.kazumaproject.markdownhelperkeyboard.candidate_order.database.CandidateOrderOverrideEntity +import com.kazumaproject.markdownhelperkeyboard.candidate_order.model.CandidateOrderScope import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate +import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CandidateConversionSegment import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import java.util.LinkedHashMap import javax.inject.Inject import javax.inject.Singleton @@ -30,23 +32,136 @@ object CandidateOrderOverrideSorter { ) .map { it.value } } + + fun applyByConversionSegment( + input: String, + candidates: List, + overridesByInput: Map>, + candidateSegmentsByString: Map>, + ): List { + if (candidates.size <= 1 || overridesByInput.isEmpty()) return candidates + + val exactOverrides = overridesByInput[input] + .orEmpty() + .filter { it.scope == CandidateOrderScope.EXACT_INPUT.name } + if (exactOverrides.isNotEmpty()) { + return apply(candidates, exactOverrides) + } + if (candidateSegmentsByString.isEmpty()) return candidates + + val referenceSegments = candidateSegmentsByString[candidates.first().string] + ?: return candidates + val referenceBoundarySignature = referenceSegments.boundarySignature(input.length) + ?: return candidates + + val savedInputPrefixes = overridesByInput.keys + .asSequence() + .filter { savedInput -> + savedInput.isNotEmpty() && + savedInput.length <= input.length && + input.startsWith(savedInput) + } + .sortedByDescending { it.length } + + for (savedInput in savedInputPrefixes) { + val rankBySegmentOutput = overridesByInput.getValue(savedInput) + .filter { it.scope == CandidateOrderScope.LEXICAL_UNIT.name } + .associate { it.candidate to it.rank } + if (rankBySegmentOutput.isEmpty()) continue + + val referenceOutput = referenceSegments.leadingOutputAtInputEnd(savedInput.length) + ?: continue + if (referenceOutput !in rankBySegmentOutput) continue + + val cohortIndices = candidates.indices.filter { index -> + candidateSegmentsByString[candidates[index].string] + ?.boundarySignature(input.length) == referenceBoundarySignature + } + if (cohortIndices.size <= 1) continue + + val reorderedCohort = cohortIndices + .map { index -> index to candidates[index] } + .sortedWith( + compareBy> { (_, candidate) -> + candidateSegmentsByString.getValue(candidate.string) + .leadingOutputAtInputEnd(savedInput.length) + ?.let(rankBySegmentOutput::get) + ?: Int.MAX_VALUE + }.thenBy { it.first }, + ) + .map { it.second } + + val result = candidates.toMutableList() + cohortIndices.forEachIndexed { cohortIndex, candidateIndex -> + result[candidateIndex] = reorderedCohort[cohortIndex] + } + + return result + } + + return candidates + } + + private fun List.leadingOutputAtInputEnd( + targetInputEnd: Int, + ): String? { + if (targetInputEnd <= 0) return null + + var expectedInputStart = 0 + val output = StringBuilder() + for (segment in this) { + if ( + segment.inputStart != expectedInputStart || + segment.inputEnd <= segment.inputStart || + segment.inputEnd > targetInputEnd + ) { + return null + } + output.append(segment.output) + expectedInputStart = segment.inputEnd + if (expectedInputStart == targetInputEnd) { + return output.toString() + } + } + return null + } + + private fun List.boundarySignature( + inputLength: Int, + ): List? { + if (isEmpty()) return null + var expectedStart = 0 + val boundaries = ArrayList(size) + for (segment in this) { + if (segment.inputStart != expectedStart || segment.inputEnd <= segment.inputStart) { + return null + } + boundaries += segment.inputEnd + expectedStart = segment.inputEnd + } + return boundaries.takeIf { expectedStart == inputLength } + } } @Singleton class CandidateOrderOverrideRepository @Inject constructor( private val dao: CandidateOrderOverrideDao ) { - @Volatile - private var candidateOrderOverrideMap: Map>? = null - - private val snapshotMutex = Mutex() + private val cacheMutex = Mutex() + private val overridesByInputCache = + LinkedHashMap>( + MAX_CACHED_INPUTS, + 0.75f, + true, + ) - fun observeAll(): Flow> = - dao.observeAll().onEach { entities -> - updateSnapshot(entities) - } + fun observeAll(): Flow> = dao.observeAll() - suspend fun saveOrder(input: String, candidates: List) { + suspend fun saveOrder( + input: String, + candidates: List, + scope: CandidateOrderScope = CandidateOrderScope.EXACT_INPUT, + ) { val normalizedInput = input.trim() if (normalizedInput.isEmpty()) return @@ -54,24 +169,28 @@ class CandidateOrderOverrideRepository @Inject constructor( val entities = candidates.mapIndexed { index, candidate -> CandidateOrderOverrideEntity( input = normalizedInput, + scope = scope.name, candidate = candidate, rank = index + 1, createdAt = now, updatedAt = now ) } - dao.replaceForInput(normalizedInput, entities) - updateSnapshot(dao.getAll()) + dao.replaceForRule(normalizedInput, scope.name, entities) + invalidateInput(normalizedInput) } suspend fun applyOrder( input: String, - candidates: List + candidates: List, + scope: CandidateOrderScope = CandidateOrderScope.EXACT_INPUT, ): List { val normalizedInput = input.trim() if (normalizedInput.isEmpty() || candidates.size <= 1) return candidates - val overrides = dao.findByInput(normalizedInput) + val overrides = loadOverridesForInputs(listOf(normalizedInput))[normalizedInput] + .orEmpty() + .filter { it.scope == scope.name } if (overrides.isEmpty()) return candidates return CandidateOrderOverrideSorter.apply(candidates, overrides) @@ -79,50 +198,95 @@ class CandidateOrderOverrideRepository @Inject constructor( suspend fun applyOrderFromSnapshot( input: String, - candidates: List + candidates: List, + candidateSegmentsByString: Map> = emptyMap(), ): List { val normalizedInput = input.trim() if (normalizedInput.isEmpty() || candidates.size <= 1) return candidates - val overrides = currentSnapshot()[normalizedInput].orEmpty() - if (overrides.isEmpty()) return candidates + val relevantOverrides = loadOverridesForInputs(normalizedInput.leadingPrefixes()) + if (relevantOverrides.isEmpty()) return candidates - return CandidateOrderOverrideSorter.apply(candidates, overrides) + return CandidateOrderOverrideSorter.applyByConversionSegment( + input = normalizedInput, + candidates = candidates, + overridesByInput = relevantOverrides, + candidateSegmentsByString = candidateSegmentsByString, + ) } suspend fun deleteByInput(input: String) { val normalizedInput = input.trim() if (normalizedInput.isNotEmpty()) { dao.deleteByInput(normalizedInput) - updateSnapshot(dao.getAll()) + invalidateInput(normalizedInput) + } + } + + suspend fun deleteRule(input: String, scope: CandidateOrderScope) { + val normalizedInput = input.trim() + if (normalizedInput.isNotEmpty()) { + dao.deleteByRule(normalizedInput, scope.name) + invalidateInput(normalizedInput) } } suspend fun deleteAll() { dao.deleteAll() - updateSnapshot(emptyList()) + cacheMutex.withLock { overridesByInputCache.clear() } } suspend fun deleteById(id: Int) { dao.deleteById(id) - updateSnapshot(dao.getAll()) + cacheMutex.withLock { overridesByInputCache.clear() } } - private suspend fun currentSnapshot(): Map> { - candidateOrderOverrideMap?.let { return it } + private suspend fun loadOverridesForInputs( + inputs: List, + ): Map> = cacheMutex.withLock { + val distinctInputs = inputs.distinct() + val missingInputs = distinctInputs.filterNot(overridesByInputCache::containsKey) + if (missingInputs.isNotEmpty()) { + val loadedByInput = dao.findByInputs(missingInputs) + .groupBy { it.input.trim() } + .mapValues { (_, entities) -> entities.sortedBy { it.rank } } + for (input in missingInputs) { + // Empty entries are cached too, avoiding a query on every conversion. + overridesByInputCache[input] = loadedByInput[input].orEmpty() + } + trimCache() + } + + buildMap { + for (input in distinctInputs) { + overridesByInputCache[input] + ?.takeIf { it.isNotEmpty() } + ?.let { put(input, it) } + } + } + } + + private suspend fun invalidateInput(input: String) { + cacheMutex.withLock { overridesByInputCache.remove(input) } + } + + private fun trimCache() { + val iterator = overridesByInputCache.entries.iterator() + while (overridesByInputCache.size > MAX_CACHED_INPUTS && iterator.hasNext()) { + iterator.next() + iterator.remove() + } + } - return snapshotMutex.withLock { - candidateOrderOverrideMap ?: updateSnapshot(dao.getAll()) + private fun String.leadingPrefixes(): List = buildList { + var end = 0 + while (end < this@leadingPrefixes.length) { + end = this@leadingPrefixes.offsetByCodePoints(end, 1) + add(this@leadingPrefixes.substring(0, end)) } } - private fun updateSnapshot( - entities: List - ): Map> { - val snapshot = entities - .groupBy { it.input.trim() } - .mapValues { (_, values) -> values.sortedBy { it.rank } } - candidateOrderOverrideMap = snapshot - return snapshot + private companion object { + const val MAX_CACHED_INPUTS = 256 } } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepository.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepository.kt index 7b74c0f14..cf6031c94 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepository.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepository.kt @@ -1,6 +1,7 @@ package com.kazumaproject.markdownhelperkeyboard.repository import com.kazumaproject.custom_keyboard.data.CircularFlickDirection +import com.kazumaproject.custom_keyboard.data.DoubleTapBinding import com.kazumaproject.custom_keyboard.data.FlickAction import com.kazumaproject.custom_keyboard.data.FlickDirection import com.kazumaproject.custom_keyboard.data.GridPlacement @@ -18,8 +19,10 @@ import com.kazumaproject.custom_keyboard.data.KeyboardLayoutItem import com.kazumaproject.custom_keyboard.data.KeyboardLayoutUsageMode import com.kazumaproject.custom_keyboard.data.SpecialKeyColorStyle import com.kazumaproject.custom_keyboard.data.SpacerItem +import com.kazumaproject.custom_keyboard.data.automaticDoubleTapPolicy import com.kazumaproject.custom_keyboard.data.copyWithItems import com.kazumaproject.custom_keyboard.data.copyWithKeys +import com.kazumaproject.custom_keyboard.data.effectiveDoubleTapBinding import com.kazumaproject.custom_keyboard.data.toCircularFlickDirection import com.kazumaproject.custom_keyboard.data.toKeyItem import com.kazumaproject.custom_keyboard.data.usesFlexiblePlacement @@ -599,7 +602,21 @@ class KeyboardRepository @Inject constructor( ) } - // 2) flick mapping + // 2) double-tap action + val doubleTapAction = KeyActionMapper.toKeyAction(key.doubleTapAction) + if (doubleTapAction is KeyAction.MoveToCustomKeyboard && + doubleTapAction.stableId == targetStableId + ) { + references += MoveToCustomKeyboardReference( + sourceLayoutId = sourceLayoutId, + sourceLayoutName = sourceLayoutName, + sourceKeyIdentifier = keyIdentifier, + sourceKeyLabel = keyLabel, + targetStableId = targetStableId + ) + } + + // 3) flick mapping for (flick in keyWithFlicks.flicks) { val act = flick.toFlickAction() if (act is FlickAction.Action) { @@ -901,6 +918,17 @@ class KeyboardRepository @Inject constructor( val iconRef = keyIconRefFromDb(dbKey.iconType, dbKey.iconValue) val specialKeyColorStyle = SpecialKeyColorStyle.fromDbValue(dbKey.specialKeyColorStyle) + val doubleTapBinding = KeyActionMapper.toKeyAction(dbKey.doubleTapAction) + ?.takeIf { dbKey.isSpecialKey } + ?.let { + DoubleTapBinding( + action = it, + policy = automaticDoubleTapPolicy( + normalAction = restoredAction, + doubleTapAction = it + ) + ) + } val keyData = if (restoredAction == null) { KeyData( label = dbKey.label, @@ -915,7 +943,8 @@ class KeyboardRepository @Inject constructor( icon = iconRef, keyId = dbKey.keyIdentifier, action = null, - specialKeyColorStyle = specialKeyColorStyle + specialKeyColorStyle = specialKeyColorStyle, + doubleTapBinding = doubleTapBinding ) } else { KeyData( @@ -931,7 +960,8 @@ class KeyboardRepository @Inject constructor( icon = iconRef, keyId = dbKey.keyIdentifier, action = restoredAction, - specialKeyColorStyle = specialKeyColorStyle + specialKeyColorStyle = specialKeyColorStyle, + doubleTapBinding = doubleTapBinding ) } val placement = GridPlacement( @@ -1121,6 +1151,7 @@ class KeyboardRepository @Inject constructor( val keyIdentifier = keyData.keyId ?: UUID.randomUUID().toString() val actionString: String? = KeyActionMapper.fromKeyAction(keyData.action) + val doubleTapBinding = keyData.effectiveDoubleTapBinding(keyData.action) val placement = itemByKeyId[keyIdentifier]?.placement ?: keyData.toKeyItem().placement keys.add( @@ -1145,7 +1176,11 @@ class KeyboardRepository @Inject constructor( columnUnits = placement.columnUnits, rowSpanUnits = placement.rowSpanUnits, columnSpanUnits = placement.columnSpanUnits, - specialKeyColorStyle = keyData.specialKeyColorStyle.dbValue + specialKeyColorStyle = keyData.specialKeyColorStyle.dbValue, + doubleTapAction = KeyActionMapper.fromKeyAction( + doubleTapBinding?.action + ), + doubleTapPolicy = doubleTapBinding?.policy?.serializedName ) ) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/LearnRepository.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/LearnRepository.kt index df3e58efe..994c30187 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/LearnRepository.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/LearnRepository.kt @@ -90,6 +90,14 @@ class LearnRepository @Inject constructor( .toList() } + suspend fun findExactMatchesForConversion(input: String): List { + val firstCharacter = input.firstOrNull() ?: return emptyList() + return getConversionBucket(firstCharacter) + .asSequence() + .filter { it.input == input } + .toList() + } + fun all(): Flow> = learnDao.all() suspend fun allSuspend(): List = learnDao.getAllSuspend() diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/UserDictionaryRepository.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/UserDictionaryRepository.kt index a438cf9a8..b6ec6b284 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/UserDictionaryRepository.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/repository/UserDictionaryRepository.kt @@ -52,6 +52,19 @@ class UserDictionaryRepository @Inject constructor( .orEmpty() } + /** + * Incremental lattice append only needs entries whose reading ends at the new input tail. + * Earlier, shorter common-prefix matches are already present in the committed graph. + */ + suspend fun exactMatchesForConversion(reading: String): List { + val firstCharacter = reading.firstOrNull() ?: return emptyList() + return getConversionSnapshot()[firstCharacter] + ?.asSequence() + ?.filter { it.reading == reading } + ?.toList() + .orEmpty() + } + suspend fun allWordsSuspend(): List { return userWordDao.getAllSuspend() } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt index 601a81163..4bbad17db 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt @@ -67,6 +67,7 @@ object AppPreference { "gemma_handwriting_pen_color_preference" const val FLICK_SENSITIVITY_KEY = "flick_sensitivity_preference" const val FLICK_THRESHOLD_SHAPE_KEY = "flick_threshold_shape_preference" + const val FLICK_EDITOR_PREVIEW_KEY = "flick_editor_preview_preference" const val TENKEY_KEYMAP_GUIDE_JAPANESE_KEY = "tenkey_keymap_guide" const val TENKEY_KEYMAP_GUIDE_ENGLISH_KEY = "tenkey_keymap_guide_english" const val TENKEY_KEYMAP_GUIDE_NUMBER_KEY = "tenkey_keymap_guide_number" @@ -75,10 +76,13 @@ object AppPreference { const val SUMIRE_KEYMAP_GUIDE_NUMBER_KEY = "sumire_keymap_guide_number" const val CUSTOM_KEYMAP_GUIDE_KEY = "flick_keymap_guide" const val LONG_PRESS_TIMEOUT_KEY = "long_press_timeout_preference" + const val DELETE_LONG_PRESS_CONVERSION_BEHAVIOR_KEY = + "delete_long_press_conversion_behavior" const val VIBRATION_KEY = "vibration_preference" const val VIBRATION_TIMING_KEY = "vibration_timing" const val KEY_SOUND_KEY = "key_sound_preference" const val KEY_SOUND_VOLUME_PERCENT_KEY = "key_sound_volume_percent_preference" + const val ALLOW_FULLSCREEN_MODE_KEY = "allow_fullscreen_mode_preference" private const val MIN_CANDIDATE_VISIBLE_HEIGHT_DP = 30 private const val MAX_CANDIDATE_VISIBLE_HEIGHT_DP = 300 @@ -98,6 +102,8 @@ object AppPreference { FlickThresholdShape.Radial.preferenceValue ) private val LONG_PRESS_TIMEOUT = Pair(LONG_PRESS_TIMEOUT_KEY, 300) + private val DELETE_LONG_PRESS_CONVERSION_BEHAVIOR = + Pair(DELETE_LONG_PRESS_CONVERSION_BEHAVIOR_KEY, "deferred") private val VIBRATION_PREFERENCE = Pair(VIBRATION_KEY, true) private val VIBRATION_TIMING_PREFERENCE = Pair(VIBRATION_TIMING_KEY, "both") private val KEY_SOUND_PREFERENCE = Pair(KEY_SOUND_KEY, false) @@ -329,6 +335,7 @@ object AppPreference { Pair("candidate_view_empty_height_dp_landscape_preference", 110) private val FLICK_INPUT_ONLY = Pair("flick_input_only_preference", false) + private val FLICK_EDITOR_PREVIEW = Pair(FLICK_EDITOR_PREVIEW_KEY, false) private val OMISSION_SEARCH = Pair("omission_search_preference", false) private val UNDO_ENABLE = Pair("undo_enable_preference", false) private val SPACE_HANKAKU_ENABLE = Pair("space_key_preference", false) @@ -376,10 +383,7 @@ object AppPreference { private val defaultKeyboardOrderJson = gson.toJson( listOf( KeyboardType.TENKEY, - KeyboardType.SUMIRE, - KeyboardType.QWERTY, - KeyboardType.ROMAJI, - KeyboardType.CUSTOM + KeyboardType.QWERTY ) ) private val KEYBOARD_ORDER = Pair("keyboard_order_preference", defaultKeyboardOrderJson) @@ -1321,6 +1325,9 @@ object AppPreference { it.putBoolean(LANDSCAPE_FORCE_QWERTY_PREFERENCE.first, value) } + fun isFullscreenModeAllowed(defaultValue: Boolean): Boolean = + preferences.getBoolean(ALLOW_FULLSCREEN_MODE_KEY, defaultValue) + var landscape_force_qwerty_romaji_preference: Boolean get() = preferences.getBoolean( LANDSCAPE_FORCE_QWERTY_ROMAJI_PREFERENCE.first, @@ -1441,8 +1448,15 @@ object AppPreference { var keyboard_order: List get() { val json = preferences.getString(KEYBOARD_ORDER.first, KEYBOARD_ORDER.second) - val type = object : TypeToken>() {}.type - return gson.fromJson(json, type) + val type = object : TypeToken>() {}.type + return runCatching { + gson.fromJson>(json, type) + .orEmpty() + .filterNotNull() + .ifEmpty { listOf(KeyboardType.TENKEY, KeyboardType.QWERTY) } + }.getOrElse { + listOf(KeyboardType.TENKEY, KeyboardType.QWERTY) + } } set(value) = preferences.edit { val json = gson.toJson(value) @@ -1613,6 +1627,15 @@ object AppPreference { it.putInt(LONG_PRESS_TIMEOUT.first, value ?: 300) } + var delete_long_press_conversion_behavior: String + get() = preferences.getString( + DELETE_LONG_PRESS_CONVERSION_BEHAVIOR.first, + DELETE_LONG_PRESS_CONVERSION_BEHAVIOR.second, + ) ?: DELETE_LONG_PRESS_CONVERSION_BEHAVIOR.second + set(value) = preferences.edit { + it.putString(DELETE_LONG_PRESS_CONVERSION_BEHAVIOR.first, value) + } + var n_best_preference: Int? get() = preferences.getInt( N_BEST_PREFERENCE.first, N_BEST_PREFERENCE.second @@ -2097,6 +2120,15 @@ object AppPreference { it.putBoolean(FLICK_INPUT_ONLY.first, value ?: false) } + var flick_editor_preview_preference: Boolean + get() = preferences.getBoolean( + FLICK_EDITOR_PREVIEW.first, + FLICK_EDITOR_PREVIEW.second + ) + set(value) = preferences.edit { + it.putBoolean(FLICK_EDITOR_PREVIEW.first, value) + } + var undo_enable_preference: Boolean? get() = preferences.getBoolean(UNDO_ENABLE.first, UNDO_ENABLE.second) set(value) = preferences.edit { diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/opensource/OpenSourceFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/opensource/OpenSourceFragment.kt index 2850e7f18..1b592512d 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/opensource/OpenSourceFragment.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/opensource/OpenSourceFragment.kt @@ -244,12 +244,12 @@ class OpenSourceFragment : Fragment() { } if (AppVariantConfig.hasZenz) { - entries += LicenseEntry("Miwa-Keita/zenz-v3.1-xsmall-gguf") { + entries += LicenseEntry("Miwa-Keita/zenz-v3.2-xsmall-gguf") { showLicenseDialog( - title = "Miwa-Keita/zenz-v3.1-xsmall-gguf", + title = "Miwa-Keita/zenz-v3.2-xsmall-gguf", notice = Notice( - "Miwa-Keita/zenz-v3.1-xsmall-gguf", - "https://huggingface.co/Miwa-Keita/zenz-v3.1-xsmall-gguf", + "Miwa-Keita/zenz-v3.2-xsmall-gguf", + "https://huggingface.co/Miwa-Keita/zenz-v3.2-xsmall-gguf", "Copyright 2025 Miwa-Keita", ZenzLicense() ) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt index 151a24d75..d45b5855d 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt @@ -496,6 +496,7 @@ object SettingDestinations { "setting_route_custom_keyboard_preferences", "setting_route_tablet_preferences", "flick_input_only_preference", + "flick_editor_preview_preference", "flick_sensitivity_preference", "flick_threshold_shape_preference", "long_press_timeout_preference", diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingSearchIndex.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingSearchIndex.kt index 9ec0959f6..c3b8f1104 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingSearchIndex.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingSearchIndex.kt @@ -478,7 +478,12 @@ object SettingSearchIndex { "SwitchPreferenceCompat", "SwitchPreference" -> SettingDestinationType.SwitchPreference( preferenceKey = key, - defaultValue = readBooleanAttribute(parser, "defaultValue", defaultValue = false), + defaultValue = readBooleanAttribute( + context = context, + parser = parser, + name = "defaultValue", + defaultValue = false, + ), destinationId = destinationId, highlightPreferenceKey = highlightPreferenceKey ?: key, ) @@ -610,10 +615,20 @@ object SettingSearchIndex { } private fun readBooleanAttribute( + context: Context, parser: XmlResourceParser, name: String, defaultValue: Boolean, ): Boolean { + listOf(ANDROID_NS, APP_NS).forEach { namespace -> + val resourceId = parser.getAttributeResourceValue(namespace, name, 0) + if (resourceId != 0) { + return runCatching { + context.resources.getBoolean(resourceId) + }.getOrDefault(defaultValue) + } + } + val raw = parser.getAttributeValue(ANDROID_NS, name) ?: parser.getAttributeValue(APP_NS, name) ?: return defaultValue diff --git a/app/src/main/res/layout/fragment_candidate_order_override.xml b/app/src/main/res/layout/fragment_candidate_order_override.xml index 865e8244f..f4bbd6b04 100644 --- a/app/src/main/res/layout/fragment_candidate_order_override.xml +++ b/app/src/main/res/layout/fragment_candidate_order_override.xml @@ -36,6 +36,44 @@ android:text="@string/candidate_order_override_fetch_candidates" /> + + + + + + + + + + + + + + + + + + + + + + + 右上を角丸にする 左下を角丸にする 右下を角丸にする + 横画面で全画面入力を許可 + 横画面で、Androidが必要と判断した場合に入力画面を全画面表示します。 横画面では常に QWERTY に切り替える 端末が横画面のとき、一時的に QWERTY キーボードへ切り替えます。 横画面 QWERTY の入力モード @@ -153,6 +155,7 @@ 削除キータップ:ハイライト解除 機能 + フリック入力 ローマ字変換 QWERTY キーボード 記号キーボード @@ -179,6 +182,9 @@ フリック・トグル入力 フリック入力のみを使用し、トグル入力を無効にする トグル入力を有効にします + 入力欄でフリック文字をプレビュー + 従来方式:指を離したときに文字を入力します + TenKey・Sumireで、選択中のタップ/フリック文字を入力欄に仮表示します 元に戻す機能 元に戻す機能を無効にする 読みを戻す @@ -203,6 +209,9 @@ 長押し時間 長押しと判定するまでの時間を変更します。 %1$d ms + 削除キー長押し時の変換方式 + スムーズ(推奨):指を離した後に1回変換 + 従来方式:1文字削除するたびにライブ変換 ローマ字変換のカスタマイズ ローマ字の変換表を作成、選択します @@ -668,6 +677,8 @@ 特別なキー キーの表示ラベル キーのアクション + ダブルタップのアクション + なし フリック設定 タップ 左フリック @@ -838,7 +849,7 @@ 下フリックを有効にする 下方向のフリック入力で大文字を入力します。 AI による予測変換を有効にする - ニューラルかな漢字モデル「zenz-v3.1-xsmall」を使った逐次的な予測変換を有効にします。 + ニューラルかな漢字モデル「zenz-v3.2-xsmall」を使った逐次的な予測変換を有効にします。 zenz で使用するプロフィールの設定 zenz が予測変換に利用するプロフィールを設定できます。\n例:この欄に「職業:医者」と入力すると、「ちゅうしゃ」と入力したときに「注射」が候補に出やすくなります。 zenz で変換候補を並び替える @@ -1309,6 +1320,13 @@ 保存済み候補順を削除しました 保存済み候補 並び替え + 適用範囲 + この読みだけ(完全一致) + 同じ語として使われる場合 + この読みだけ + 同じ語 + 入力全体がこの読みと一致した場合だけ適用します。最も安全で予測可能な設定です。 + 通常変換の第一候補がこの読みを独立した同じ語として解析した場合だけ、長い入力にも適用します。異なる語区切りには影響しません。 特殊キー編集 Sumire の特殊キーの動作と配置を編集します Sumire 特殊キー編集 diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 55f475e7c..1f688d490 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -1,5 +1,15 @@ + + @string/delete_long_press_conversion_behavior_deferred + @string/delete_long_press_conversion_behavior_continuous + + + + deferred + continuous + + @string/physical_keyboard_input_mode_romaji @string/physical_keyboard_input_mode_kana diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dc90f03c6..5bfb2e581 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -119,6 +119,8 @@ Round top-right corner Round bottom-left corner Round bottom-right corner + Allow fullscreen input in landscape + Allow Android to use a fullscreen input view in landscape when needed. Always use QWERTY in landscape When the device is in landscape, temporarily switch to a QWERTY keyboard. Landscape QWERTY input mode @@ -151,6 +153,7 @@ Long press Delete: Unhighlight\nTap Delete: Move selection back one place Tap Delete: Unhighlight Features + Flick input Romaji Conversion QWERTY Keyboard Symbol Keyboard @@ -177,6 +180,9 @@ Flick/Toggle Input Use only flick input and disable toggle input Enable toggle input + Preview flick characters in the text field + Enter the character when you release the key + Preview the selected tap or flick text while holding a TenKey or Sumire key Undo Function Disable the undo function Restore Reading @@ -204,6 +210,9 @@ Long-press Time Adjust how long a key must be held before long-press is triggered. %1$d ms + Conversion while holding Delete + Smooth (recommended): convert once after release + Legacy: live-convert after every deletion Customize Romaji Conversion Create and select a Romaji conversion table @@ -710,6 +719,8 @@ Special Key Label for Key Display Action for Key + Double-tap action + None Flick Tap Flick Left @@ -884,7 +895,7 @@ Enable Down Flick Input uppercase letters with downward flick. Enable AI Predictive Conversion - Enable predictive text conversion using the neural kana-kanji model “zenz-v3.1-xsmall.” + Enable predictive text conversion using the neural kana-kanji model “zenz-v3.2-xsmall.” Profile Settings for zenz Set a profile that zenz will use for predictive conversion.\nExample: If you enter “Occupation: Doctor,” typing “chūsha” will make the candidate “injection” appear more frequently. Rerank conversion candidates with zenz @@ -1355,6 +1366,13 @@ 保存済み候補順を削除しました 保存済み候補 並び替え + 適用範囲 + この読みだけ(完全一致) + 同じ語として使われる場合 + この読みだけ + 同じ語 + 入力全体がこの読みと一致した場合だけ適用します。最も安全で予測可能な設定です。 + 通常変換の第一候補がこの読みを独立した同じ語として解析した場合だけ、長い入力にも適用します。異なる語区切りには影響しません。 Special key editor Edit Sumire special key actions and placement Sumire special keys diff --git a/app/src/main/res/xml/pref_common_legacy.xml b/app/src/main/res/xml/pref_common_legacy.xml index 5283ae064..558c8d9e8 100644 --- a/app/src/main/res/xml/pref_common_legacy.xml +++ b/app/src/main/res/xml/pref_common_legacy.xml @@ -44,6 +44,12 @@ android:summary="@string/keyboard_screen_preference_summary" android:title="@string/keyboard_screen_landscape_size_preference_title" /> + + + + + + + + + + + + + + - - - - - @@ -714,6 +732,15 @@ android:summary="@string/long_press_timeout_preference_summary" android:title="@string/long_press_timeout_preference_title" /> + + diff --git a/app/src/main/res/xml/pref_keyboard_display.xml b/app/src/main/res/xml/pref_keyboard_display.xml index 26aa09f58..d4581ce54 100644 --- a/app/src/main/res/xml/pref_keyboard_display.xml +++ b/app/src/main/res/xml/pref_keyboard_display.xml @@ -16,6 +16,12 @@ android:summary="@string/keyboard_screen_landscape_preference_summary" android:title="@string/keyboard_screen_landscape_size_preference_title" /> + + - + + + + + + + + + () + val name = "candidate-order-scope-migration-${System.nanoTime()}.db" + val helper = FrameworkSQLiteOpenHelperFactory().create( + SupportSQLiteOpenHelper.Configuration.builder(context) + .name(name) + .callback(object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE candidate_order_override ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + input TEXT NOT NULL, + candidate TEXT NOT NULL, + rank INTEGER NOT NULL, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL + ) + """.trimIndent(), + ) + db.execSQL( + "CREATE INDEX index_candidate_order_override_input ON candidate_order_override (input)", + ) + db.execSQL( + "CREATE UNIQUE INDEX index_candidate_order_override_input_candidate ON candidate_order_override (input, candidate)", + ) + } + + override fun onUpgrade( + db: SupportSQLiteDatabase, + oldVersion: Int, + newVersion: Int, + ) = Unit + }) + .build(), + ) + try { + val db = helper.writableDatabase + db.execSQL( + "INSERT INTO candidate_order_override (input, candidate, rank, createdAt, updatedAt) VALUES ('ひ', '火', 1, 1, 1)", + ) + + AppDatabase.MIGRATION_43_44.migrate(db) + + db.query( + "SELECT input, scope, candidate, rank FROM candidate_order_override", + ).use { cursor -> + cursor.moveToFirst() + assertEquals("ひ", cursor.getString(0)) + assertEquals(CandidateOrderScope.EXACT_INPUT.name, cursor.getString(1)) + assertEquals("火", cursor.getString(2)) + assertEquals(1, cursor.getInt(3)) + } + + db.execSQL( + "INSERT INTO candidate_order_override (input, scope, candidate, rank, createdAt, updatedAt) VALUES ('ひ', 'LEXICAL_UNIT', '火', 1, 2, 2)", + ) + db.query( + "SELECT COUNT(*) FROM candidate_order_override WHERE input = 'ひ' AND candidate = '火'", + ).use { cursor -> + cursor.moveToFirst() + assertEquals(2, cursor.getInt(0)) + } + } finally { + helper.close() + context.deleteDatabase(name) + } + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/CandidateOrderOverrideSavedOrderEditTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/CandidateOrderOverrideSavedOrderEditTest.kt index fcf1c9c40..8b612d1d8 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/CandidateOrderOverrideSavedOrderEditTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/CandidateOrderOverrideSavedOrderEditTest.kt @@ -2,6 +2,7 @@ package com.kazumaproject.markdownhelperkeyboard.candidate_order import com.kazumaproject.markdownhelperkeyboard.candidate_order.database.CandidateOrderOverrideDao import com.kazumaproject.markdownhelperkeyboard.candidate_order.database.CandidateOrderOverrideEntity +import com.kazumaproject.markdownhelperkeyboard.candidate_order.model.CandidateOrderScope import com.kazumaproject.markdownhelperkeyboard.candidate_order.model.SavedCandidateOrderGroup import com.kazumaproject.markdownhelperkeyboard.candidate_order.ui.toCandidateOrderEditingState import com.kazumaproject.markdownhelperkeyboard.repository.CandidateOrderOverrideRepository @@ -9,6 +10,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Test @@ -25,6 +27,7 @@ class CandidateOrderOverrideSavedOrderEditTest { val editingState = savedOrder.toCandidateOrderEditingState() assertEquals("きょう", editingState?.reading) + assertEquals(CandidateOrderScope.EXACT_INPUT, editingState?.scope) assertEquals(listOf("今日", "京", "教"), editingState?.candidates?.map { it.candidate }) assertEquals(listOf(0, 1, 2), editingState?.candidates?.map { it.originalIndex }) } @@ -66,9 +69,18 @@ class CandidateOrderOverrideSavedOrderEditTest { candidates = listOf("今日", "京") ) - assertEquals(listOf("きょう"), dao.deletedInputs) - assertEquals(listOf("今日", "京"), dao.rowsForInput("きょう").map { it.candidate }) - assertEquals(listOf(1, 2), dao.rowsForInput("きょう").map { it.rank }) + assertEquals( + listOf("きょう" to CandidateOrderScope.EXACT_INPUT.name), + dao.deletedRules, + ) + assertEquals( + listOf("今日", "京"), + dao.rowsForRule("きょう", CandidateOrderScope.EXACT_INPUT).map { it.candidate }, + ) + assertEquals( + listOf(1, 2), + dao.rowsForRule("きょう", CandidateOrderScope.EXACT_INPUT).map { it.rank }, + ) assertEquals(listOf("明日"), dao.rowsForInput("あす").map { it.candidate }) } @@ -118,13 +130,60 @@ class CandidateOrderOverrideSavedOrderEditTest { ) } + @Test + fun segmentOrderQueriesOnlyInputPrefixesAndReusesBoundedLookup() = runTest { + val dao = FakeCandidateOrderOverrideDao( + initialRows = mutableListOf( + entity( + input = "ひ", + candidate = "火", + rank = 1, + scope = CandidateOrderScope.LEXICAL_UNIT, + ), + entity( + input = "ひ", + candidate = "日", + rank = 2, + scope = CandidateOrderScope.LEXICAL_UNIT, + ), + entity(input = "べんちまっぷ00001", candidate = "候補", rank = 1), + ), + ) + val repository = CandidateOrderOverrideRepository(dao) + val candidates = listOf(candidate("日を"), candidate("火を")) + val segments = mapOf( + "日を" to listOf( + candidateSegment(0, 1, "日"), + candidateSegment(1, 2, "を"), + ), + "火を" to listOf( + candidateSegment(0, 1, "火"), + candidateSegment(1, 2, "を"), + ), + ) + + repeat(2) { + assertEquals( + listOf("火を", "日を"), + repository.applyOrderFromSnapshot("ひを", candidates, segments).map { it.string }, + ) + } + + assertEquals(1, dao.findByInputsCalls.size) + assertEquals(setOf("ひ", "ひを"), dao.findByInputsCalls.single().toSet()) + assertFalse(dao.findByInputsCalls.single().contains("べんちまっぷ00001")) + assertEquals(0, dao.getAllCallCount) + } + private fun entity( input: String, candidate: String, - rank: Int + rank: Int, + scope: CandidateOrderScope = CandidateOrderScope.EXACT_INPUT, ): CandidateOrderOverrideEntity { return CandidateOrderOverrideEntity( input = input, + scope = scope.name, candidate = candidate, rank = rank, createdAt = 1L, @@ -140,19 +199,54 @@ class CandidateOrderOverrideSavedOrderEditTest { score = 0 ) + private fun candidateSegment(inputStart: Int, inputEnd: Int, output: String) = + com.kazumaproject.markdownhelperkeyboard.converter.candidate.CandidateConversionSegment( + inputStart = inputStart, + inputEnd = inputEnd, + output = output, + ) + private class FakeCandidateOrderOverrideDao( initialRows: MutableList ) : CandidateOrderOverrideDao { private val rows = initialRows private val rowsFlow = MutableStateFlow(rows.toList()) val deletedInputs = mutableListOf() + val deletedRules = mutableListOf>() + val findByInputsCalls = mutableListOf>() + var getAllCallCount = 0 fun rowsForInput(input: String): List { return rows.filter { it.input == input }.sortedBy { it.rank } } - override suspend fun findByInput(input: String): List { - return rowsForInput(input) + fun rowsForRule( + input: String, + scope: CandidateOrderScope, + ): List { + return rows + .filter { it.input == input && it.scope == scope.name } + .sortedBy { it.rank } + } + + override suspend fun findByRule( + input: String, + scope: String, + ): List { + return rows.filter { it.input == input && it.scope == scope }.sortedBy { it.rank } + } + + override suspend fun findByInputs( + inputs: List, + ): List { + findByInputsCalls += inputs.toList() + return rows + .filter { it.input in inputs } + .sortedWith( + compareBy { it.input } + .thenBy { it.scope } + .thenBy { it.rank }, + ) } override fun observeAll(): Flow> { @@ -160,7 +254,12 @@ class CandidateOrderOverrideSavedOrderEditTest { } override suspend fun getAll(): List { - return rows.sortedWith(compareBy { it.input }.thenBy { it.rank }) + getAllCallCount++ + return rows.sortedWith( + compareBy { it.input } + .thenBy { it.scope } + .thenBy { it.rank }, + ) } override suspend fun deleteByInput(input: String) { @@ -169,6 +268,12 @@ class CandidateOrderOverrideSavedOrderEditTest { rowsFlow.value = rows.toList() } + override suspend fun deleteByRule(input: String, scope: String) { + deletedRules += input to scope + rows.removeAll { it.input == input && it.scope == scope } + rowsFlow.value = rows.toList() + } + override suspend fun deleteAll() { rows.clear() rowsFlow.value = emptyList() diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/CandidateOrderOverrideSorterTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/CandidateOrderOverrideSorterTest.kt index f6ff4877f..edb9c11ea 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/CandidateOrderOverrideSorterTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/candidate_order/CandidateOrderOverrideSorterTest.kt @@ -1,7 +1,9 @@ package com.kazumaproject.markdownhelperkeyboard.candidate_order import com.kazumaproject.markdownhelperkeyboard.candidate_order.database.CandidateOrderOverrideEntity +import com.kazumaproject.markdownhelperkeyboard.candidate_order.model.CandidateOrderScope import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate +import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CandidateConversionSegment import com.kazumaproject.markdownhelperkeyboard.repository.CandidateOrderOverrideSorter import org.junit.Assert.assertEquals import org.junit.Assert.assertSame @@ -80,6 +82,131 @@ class CandidateOrderOverrideSorterTest { assertEquals(listOf("修道", "主導", "手動", "酒道"), result.words()) } + @Test + fun exactConversionNodePrefixAppliesSavedOrderToLongerInput() { + val candidates = candidates("日を", "火を", "陽を") + val result = CandidateOrderOverrideSorter.applyByConversionSegment( + input = "ひを", + candidates = candidates, + overridesByInput = mapOf( + "ひ" to overridesFor("ひ", "火" to 1, "日" to 2, "陽" to 3), + ), + candidateSegmentsByString = mapOf( + "日を" to segments(0, 1, "日", 1, 2, "を"), + "火を" to segments(0, 1, "火", 1, 2, "を"), + "陽を" to segments(0, 1, "陽", 1, 2, "を"), + ), + ) + + assertEquals(listOf("火を", "日を", "陽を"), result.words()) + } + + @Test + fun textPrefixWithoutConversionBoundaryDoesNotMatch() { + val candidates = candidates("日を", "火を") + val result = CandidateOrderOverrideSorter.applyByConversionSegment( + input = "ひを", + candidates = candidates, + overridesByInput = mapOf("ひ" to overridesFor("ひ", "火" to 1, "日" to 2)), + candidateSegmentsByString = mapOf( + "日を" to segments(0, 2, "日を"), + "火を" to segments(0, 2, "火を"), + ), + ) + + assertEquals(listOf("日を", "火を"), result.words()) + } + + @Test + fun exactFullInputOrderTakesPriorityOverShorterSegmentOrder() { + val candidates = candidates("日を", "火を") + val result = CandidateOrderOverrideSorter.applyByConversionSegment( + input = "ひを", + candidates = candidates, + overridesByInput = mapOf( + "ひ" to overridesFor("ひ", "火" to 1, "日" to 2), + "ひを" to exactOverridesFor("ひを", "日を" to 1, "火を" to 2), + ), + candidateSegmentsByString = mapOf( + "日を" to segments(0, 1, "日", 1, 2, "を"), + "火を" to segments(0, 1, "火", 1, 2, "を"), + ), + ) + + assertEquals(listOf("日を", "火を"), result.words()) + } + + @Test + fun savedOrderDoesNotMatchAConversionNodeLaterInInput() { + val candidates = candidates("亜日", "亜火") + val result = CandidateOrderOverrideSorter.applyByConversionSegment( + input = "あひ", + candidates = candidates, + overridesByInput = mapOf("ひ" to overridesFor("ひ", "火" to 1, "日" to 2)), + candidateSegmentsByString = mapOf( + "亜日" to segments(0, 1, "亜", 1, 2, "日"), + "亜火" to segments(0, 1, "亜", 1, 2, "火"), + ), + ) + + assertEquals(listOf("亜日", "亜火"), result.words()) + } + + @Test + fun exactShortInputOrderDoesNotAffectLongerInput() { + val candidates = candidates("日を", "火を") + val result = CandidateOrderOverrideSorter.applyByConversionSegment( + input = "ひを", + candidates = candidates, + overridesByInput = mapOf( + "ひ" to exactOverridesFor("ひ", "火" to 1, "日" to 2), + ), + candidateSegmentsByString = mapOf( + "日を" to segments(0, 1, "日", 1, 2, "を"), + "火を" to segments(0, 1, "火", 1, 2, "を"), + ), + ) + + assertEquals(listOf("日を", "火を"), result.words()) + } + + @Test + fun lexicalOrderDoesNotPromoteCandidateWithDifferentBaselineSegmentation() { + val candidates = candidates("人多すぎ", "火と多すぎ") + val result = CandidateOrderOverrideSorter.applyByConversionSegment( + input = "ひとおおすぎ", + candidates = candidates, + overridesByInput = mapOf( + "ひ" to overridesFor("ひ", "火" to 1, "日" to 2), + ), + candidateSegmentsByString = mapOf( + "人多すぎ" to segments(0, 2, "人", 2, 6, "多すぎ"), + "火と多すぎ" to segments(0, 1, "火", 1, 2, "と", 2, 6, "多すぎ"), + ), + ) + + assertEquals(listOf("人多すぎ", "火と多すぎ"), result.words()) + } + + @Test + fun lexicalOrderChangesOnlySlotsInBaselineSegmentationCohort() { + val candidates = candidates("日を", "非対象", "火を") + val result = CandidateOrderOverrideSorter.applyByConversionSegment( + input = "ひを", + candidates = candidates, + overridesByInput = mapOf( + "ひ" to overridesFor("ひ", "火" to 1, "日" to 2), + ), + candidateSegmentsByString = mapOf( + "日を" to segments(0, 1, "日", 1, 2, "を"), + "非対象" to segments(0, 2, "非対象"), + "火を" to segments(0, 1, "火", 1, 2, "を"), + ), + ) + + assertEquals(listOf("火を", "非対象", "日を"), result.words()) + } + private fun candidates(vararg words: String): List = words.map { Candidate( @@ -91,9 +218,34 @@ class CandidateOrderOverrideSorterTest { } private fun overrides(vararg ranks: Pair): List = - ranks.map { + overridesFor("しゅどう", *ranks) + + private fun overridesFor( + input: String, + vararg ranks: Pair, + ): List = overridesForScope( + input = input, + scope = CandidateOrderScope.LEXICAL_UNIT, + ranks = ranks, + ) + + private fun exactOverridesFor( + input: String, + vararg ranks: Pair, + ): List = overridesForScope( + input = input, + scope = CandidateOrderScope.EXACT_INPUT, + ranks = ranks, + ) + + private fun overridesForScope( + input: String, + scope: CandidateOrderScope, + ranks: Array>, + ): List = ranks.map { CandidateOrderOverrideEntity( - input = "しゅどう", + input = input, + scope = scope.name, candidate = it.first, rank = it.second, createdAt = 1L, @@ -101,5 +253,15 @@ class CandidateOrderOverrideSorterTest { ) } + private fun segments( + vararg values: Any, + ): List = values.toList().chunked(3).map { chunk -> + CandidateConversionSegment( + inputStart = chunk[0] as Int, + inputEnd = chunk[1] as Int, + output = chunk[2] as String, + ) + } + private fun List.words(): List = map { it.string } } diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/BundledEmojiCategoryTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/BundledEmojiCategoryTest.kt new file mode 100644 index 000000000..7a655bb6f --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/BundledEmojiCategoryTest.kt @@ -0,0 +1,71 @@ +package com.kazumaproject.markdownhelperkeyboard.converter + +import com.kazumaproject.Louds.LOUDS +import com.kazumaproject.data.emoji.EmojiCategory +import com.kazumaproject.dictionary.TokenArray +import com.kazumaproject.domain.categorizeEmoji +import com.kazumaproject.markdownhelperkeyboard.converter.bitset.SuccinctBitVector +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.BufferedInputStream +import java.io.File +import java.io.ObjectInputStream + +class BundledEmojiCategoryTest { + + @Test + fun allBundledEmojiCandidatesAreCountedAgainstUnicodeMetadata() { + val assetsDir = findAssetsDir() + val tangoTrie = ObjectInputStream( + BufferedInputStream(File(assetsDir, "emoji/tango_emoji.dat").inputStream()) + ).use { LOUDS().readExternalNotCompress(it) } + val tokenArray = TokenArray().also { tokenArray -> + ObjectInputStream( + BufferedInputStream(File(assetsDir, "emoji/token_emoji.dat").inputStream()) + ).use(tokenArray::readExternal) + } + val tangoLbs = SuccinctBitVector(tangoTrie.LBS) + val symbols = tokenArray.getNodeIds() + .map { tangoTrie.getLetterShortArray(it, tangoLbs) } + .distinct() + val counts = EmojiCategory.entries.associateWith { category -> + symbols.count { categorizeEmoji(it) == category } + } + val classifiedCount = symbols.size - counts.getValue(EmojiCategory.UNKNOWN) + val expectedCounts = mapOf( + EmojiCategory.SMILEYS_EMOTION to 168, + EmojiCategory.PEOPLE_BODY to 385, + EmojiCategory.ANIMALS_NATURE to 158, + EmojiCategory.FOOD_DRINK to 130, + EmojiCategory.TRAVEL_PLACES to 218, + EmojiCategory.ACTIVITIES to 85, + EmojiCategory.OBJECTS to 262, + EmojiCategory.SYMBOLS to 223, + EmojiCategory.FLAGS to 269, + EmojiCategory.UNKNOWN to 4, + ) + + println("emojiTotal=${symbols.size}") + EmojiCategory.entries.forEach { println("emoji.${it.name}=${counts.getValue(it)}") } + println("emojiClassified=$classifiedCount") + + assertEquals(1_902, symbols.size) + assertEquals(expectedCounts, counts) + assertEquals(1_898, classifiedCount) + assertEquals( + setOf("🦰", "🦱", "🦲", "🦳"), + symbols.filter { categorizeEmoji(it) == EmojiCategory.UNKNOWN }.toSet(), + ) + } + + private fun findAssetsDir(): File { + var current = File(System.getProperty("user.dir") ?: ".").absoluteFile + repeat(6) { + val candidate = File(current, "app/src/main/assets") + if (candidate.isDirectory) return candidate + current = current.parentFile + ?: error("Cannot find app/src/main/assets from ${System.getProperty("user.dir")}") + } + error("Cannot find app/src/main/assets from ${System.getProperty("user.dir")}") + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/MozcCompoundConversionParityTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/MozcCompoundConversionParityTest.kt index 37e497c71..a896ebca0 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/MozcCompoundConversionParityTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/MozcCompoundConversionParityTest.kt @@ -108,6 +108,27 @@ class MozcCompoundConversionParityTest { ) } + @Test + fun hiwoCandidatePathsKeepKanjiAndParticleAsExactNodes() { + val trace = engine.convertWithTrace("ひを") + val relevantCandidates = trace.finalCandidates.filter { + it.candidate == "火を" || it.candidate == "日を" + } + + assertTrue( + trace.finalCandidates.take(40).joinToString("\n"), + relevantCandidates.any { + it.candidate == "火を" && it.path == listOf("火", "を") + }, + ) + assertTrue( + trace.finalCandidates.take(40).joinToString("\n"), + relevantCandidates.any { + it.candidate == "日を" && it.path == listOf("日", "を") + }, + ) + } + companion object { private lateinit var engine: KanaKanjiEngine diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/ExactInputCandidatePromoterPerformanceTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/ExactInputCandidatePromoterPerformanceTest.kt new file mode 100644 index 000000000..2ff4ab98a --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/ExactInputCandidatePromoterPerformanceTest.kt @@ -0,0 +1,234 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.candidate + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Locale + +class ExactInputCandidatePromoterPerformanceTest { + @Volatile + private var listSink: List? = null + + @Volatile + private var setSink: Set? = null + + private val allocationMeter = ThreadAllocationMeter() + + @Test + fun measuresTenThousandAndHundredThousandPromotionRules() { + val warmupCandidates = List(64) { index -> + candidate(if (index == 63) "ウォームアップ" else "候補-$index") + } + val warmupPromoter = ExactInputCandidatePromoter(setOf("ウォームアップ")) + repeat(500) { + listSink = warmupPromoter.promote("ウォームアップ", warmupCandidates) + } + + listOf(22, 10_000, 100_000).forEach { ruleCount -> + val words = List(ruleCount) { index -> "長期運用語-$index" } + val setBuild = measureOnce { + HashSet(words) + } + val rules = setBuild.value + setSink = rules + val target = words.last() + val candidates = List(64) { index -> + candidate(if (index == 63) target else "候補-$index") + } + val promoter = ExactInputCandidatePromoter(rules) + repeat(20) { + listSink = promoter.promote(target, candidates) + } + val hitTiming = measureRepeated(200) { + listSink = promoter.promote(target, candidates) + } + val missAllocation = measureOnce { + promoter.promote("登録外", candidates) + } + val missTiming = measureRepeated(200) { + listSink = promoter.promote("登録外", candidates) + } + + println( + "EXACT_PROMOTION_RULE_SCALE " + + "rules=$ruleCount " + + "setBuildMs=${setBuild.elapsedNs.toMilliseconds()} " + + "setIndexAllocatedBytes=${setBuild.allocatedBytes} " + + "hitMedianUs=${hitTiming.medianNs.toMicroseconds()} " + + "hitP95Us=${hitTiming.p95Ns.toMicroseconds()} " + + "missMedianUs=${missTiming.medianNs.toMicroseconds()} " + + "missP95Us=${missTiming.p95Ns.toMicroseconds()} " + + "missAllocatedBytes=${missAllocation.allocatedBytes}", + ) + + assertEquals(target, promoter.promote(target, candidates).first().string) + assertSame(candidates, missAllocation.value) + assertTrue(setBuild.allocatedBytes > 0) + assertTrue(hitTiming.medianNs < 100_000_000L) + assertTrue(missAllocation.allocatedBytes < 1_024L) + } + } + + @Test + fun measuresTenThousandAndHundredThousandCandidates() { + val promoter = ExactInputCandidatePromoter(setOf("ふ")) + val scenarios = listOf(10_000, 100_000).associateWith { candidateCount -> + List(candidateCount) { index -> + candidate( + string = if (index == candidateCount - 1) "ふ" else "候補-$index", + type = if (index % 1_000 == 0) { + CANDIDATE_TYPE_LEARNED_DICTIONARY + } else { + 1 + }, + ) + } + } + repeat(30) { + listSink = promoter.promote("ふ", checkNotNull(scenarios[100_000])) + } + + scenarios.forEach { (candidateCount, candidates) -> + repeat(if (candidateCount == 10_000) 20 else 5) { + listSink = promoter.promote("ふ", candidates) + } + val allocation = measureOnce { + promoter.promote("ふ", candidates) + } + listSink = allocation.value + val timing = measureRepeated(if (candidateCount == 10_000) 50 else 15) { + listSink = promoter.promote("ふ", candidates) + } + val learnedCount = candidates.count { + it.type == CANDIDATE_TYPE_LEARNED_DICTIONARY + } + + println( + "EXACT_PROMOTION_CANDIDATE_SCALE " + + "candidates=$candidateCount " + + "medianMs=${timing.medianNs.toMilliseconds()} " + + "p95Ms=${timing.p95Ns.toMilliseconds()} " + + "allocatedBytes=${allocation.allocatedBytes} " + + "bytesPerCandidate=" + + String.format( + Locale.ROOT, + "%.2f", + allocation.allocatedBytes.toDouble() / candidateCount, + ), + ) + + assertEquals(candidateCount, listSink?.size) + assertEquals("ふ", listSink?.get(learnedCount)?.string) + assertTrue(timing.medianNs < 100_000_000L) + assertTrue(allocation.allocatedBytes in 1..candidateCount * 16L) + } + } + + private fun candidate( + string: String, + type: Byte = 1, + ): Candidate = Candidate( + string = string, + type = type, + length = string.length.toUByte(), + score = 0, + ) + + private fun measureOnce(block: () -> T): AllocationMeasurement { + val allocatedBefore = allocationMeter.currentThreadAllocatedBytes() + val startedNs = System.nanoTime() + val value = block() + val elapsedNs = System.nanoTime() - startedNs + val allocatedBytes = + allocationMeter.currentThreadAllocatedBytes() - allocatedBefore + return AllocationMeasurement( + value = value, + elapsedNs = elapsedNs, + allocatedBytes = allocatedBytes, + ) + } + + private fun measureRepeated( + iterations: Int, + block: () -> Unit, + ): TimingMeasurement { + val samples = LongArray(iterations) + repeat(iterations) { index -> + val startedNs = System.nanoTime() + block() + samples[index] = System.nanoTime() - startedNs + } + samples.sort() + return TimingMeasurement( + medianNs = samples[samples.size / 2], + p95Ns = samples[((samples.size - 1) * 95) / 100], + ) + } + + private fun Long.toMicroseconds(): String = + String.format(Locale.ROOT, "%.3f", this / 1_000.0) + + private fun Long.toMilliseconds(): String = + String.format(Locale.ROOT, "%.3f", this / 1_000_000.0) + + private data class AllocationMeasurement( + val value: T, + val elapsedNs: Long, + val allocatedBytes: Long, + ) + + private data class TimingMeasurement( + val medianNs: Long, + val p95Ns: Long, + ) + + /** + * Android's unit-test compile classpath does not expose java.lang.management, even though + * these tests run on a HotSpot JVM. Reflection keeps the benchmark out of production code + * while still using the JVM's precise per-thread allocation counter at test runtime. + */ + private class ThreadAllocationMeter { + private val bean: Any + private val allocatedBytesMethod: java.lang.reflect.Method + + init { + val managementFactoryClass = + Class.forName("java.lang.management.ManagementFactory") + bean = requireNotNull( + managementFactoryClass + .getMethod("getThreadMXBean") + .invoke(null) + ) + + val threadMxBeanClass = Class.forName("com.sun.management.ThreadMXBean") + check( + threadMxBeanClass + .getMethod("isThreadAllocatedMemorySupported") + .invoke(bean) as Boolean + ) { + "The test JVM must support per-thread allocation measurement" + } + if ( + !(threadMxBeanClass + .getMethod("isThreadAllocatedMemoryEnabled") + .invoke(bean) as Boolean) + ) { + threadMxBeanClass + .getMethod( + "setThreadAllocatedMemoryEnabled", + Boolean::class.javaPrimitiveType, + ) + .invoke(bean, true) + } + allocatedBytesMethod = threadMxBeanClass.getMethod( + "getThreadAllocatedBytes", + Long::class.javaPrimitiveType, + ) + } + + @Suppress("DEPRECATION") + fun currentThreadAllocatedBytes(): Long = + allocatedBytesMethod.invoke(bean, Thread.currentThread().id) as Long + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/ExactInputCandidatePromotionPolicyTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/ExactInputCandidatePromotionPolicyTest.kt new file mode 100644 index 000000000..49fff19b8 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/ExactInputCandidatePromotionPolicyTest.kt @@ -0,0 +1,137 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.candidate + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class ExactInputCandidatePromotionPolicyTest { + + @Test + fun promotesEveryConfiguredLiteralInput() { + configuredInputs.forEach { input -> + val candidates = listOf( + candidate("変換候補"), + candidate(input), + candidate("別候補"), + ) + + val result = ExactInputCandidatePromotionPolicy.promote(input, candidates) + + assertEquals(input, result.first().string) + assertEquals(candidates.toSet(), result.toSet()) + } + } + + @Test + fun keepsAllLearnedCandidatesAheadOfExactInput() { + val candidates = listOf( + candidate("通常候補"), + candidate("学習2", CANDIDATE_TYPE_LEARNED_DICTIONARY), + candidate("ふ"), + candidate("学習1", CANDIDATE_TYPE_LEARNED_DICTIONARY), + candidate("その他"), + ) + + val result = ExactInputCandidatePromotionPolicy.promote("ふ", candidates) + + assertEquals( + listOf("学習2", "学習1", "ふ", "通常候補", "その他"), + result.map { it.string }, + ) + } + + @Test + fun learnedExactInputStaysInLearnedGroupWithoutDuplication() { + val candidates = listOf( + candidate("通常候補"), + candidate("む", CANDIDATE_TYPE_LEARNED_DICTIONARY), + candidate("別の学習", CANDIDATE_TYPE_LEARNED_DICTIONARY), + ) + + val result = ExactInputCandidatePromotionPolicy.promote("む", candidates) + + assertEquals(listOf("む", "別の学習", "通常候補"), result.map { it.string }) + assertEquals(1, result.count { it.string == "む" }) + } + + @Test + fun segmentedAndFallbackOutputsNeedOnlyFinalStringEquality() { + val candidates = listOf( + candidate("過?"), + candidate("か?"), + candidate("可?"), + ) + + val result = ExactInputCandidatePromotionPolicy.promote("か?", candidates) + + assertEquals("か?", result.first().string) + } + + @Test + fun returnsOriginalListWhenInputIsNotConfigured() { + val candidates = listOf(candidate("候補"), candidate("そのまま")) + + val result = ExactInputCandidatePromotionPolicy.promote("そのまま", candidates) + + assertSame(candidates, result) + } + + @Test + fun returnsOriginalListWhenExactCandidateIsMissing() { + val candidates = listOf(candidate("無"), candidate("夢")) + + val result = ExactInputCandidatePromotionPolicy.promote("む", candidates) + + assertSame(candidates, result) + } + + @Test + fun returnsOriginalListWhenPriorityOrderIsAlreadySatisfied() { + val candidates = listOf( + candidate("学習", CANDIDATE_TYPE_LEARNED_DICTIONARY), + candidate("ほ"), + candidate("補"), + ) + + val result = ExactInputCandidatePromotionPolicy.promote("ほ", candidates) + + assertSame(candidates, result) + } + + private fun candidate( + string: String, + type: Byte = 1, + ): Candidate = Candidate( + string = string, + type = type, + length = string.length.toUByte(), + score = 0, + ) + + private companion object { + val configuredInputs = listOf( + "ての", + "か?", + "え", + "き", + "く", + "け", + "こ", + "さ", + "し", + "せ", + "そ", + "た", + "ち", + "て", + "ひ", + "ふ", + "ほ", + "み", + "む", + "め", + "ゆ", + "り", + ) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngineQwertyGlideConfigurationTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngineQwertyGlideConfigurationTest.kt index 91458bae1..6482a6c53 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngineQwertyGlideConfigurationTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngineQwertyGlideConfigurationTest.kt @@ -4,6 +4,7 @@ import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlideIndex import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlidePrebuiltDictionaryLoader import com.kazumaproject.markdownhelperkeyboard.converter.glide.characterMask import com.kazumaproject.markdownhelperkeyboard.converter.glide.transitionMask +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -12,6 +13,8 @@ import java.io.ByteArrayOutputStream import java.io.DataOutputStream import java.io.FileNotFoundException import java.io.InputStream +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit class EnglishEngineQwertyGlideConfigurationTest { @Test @@ -115,6 +118,62 @@ class EnglishEngineQwertyGlideConfigurationTest { assertTrue(engine.isQwertyGlideDictionaryReady()) } + @Test + fun asyncConfigurationLoadsPrebuiltWithoutBlockingCaller() = runBlocking { + val engine = EnglishEngine() + val loadStarted = CountDownLatch(1) + val allowLoadToFinish = CountDownLatch(1) + + engine.configureQwertyGlideDecoderAsync( + enabled = true, + canUseBundledPrebuiltIndex = true, + prebuiltDictionaryLoader = loader { + loadStarted.countDown() + check(allowLoadToFinish.await(2, TimeUnit.SECONDS)) + validIndex("an").inputStream() + }, + ) + + assertTrue(engine.isQwertyGlideInputEnabled()) + assertTrue(loadStarted.await(2, TimeUnit.SECONDS)) + assertFalse(engine.isQwertyGlideDictionaryReady()) + allowLoadToFinish.countDown() + engine.awaitQwertyGlideWarmup() + assertTrue(engine.isQwertyGlideDictionaryReady()) + assertTrue(engine.hasQwertyGlideDecoder()) + } + + @Test + fun repeatedAsyncConfigurationSharesInFlightLoad() = runBlocking { + var opens = 0 + val engine = EnglishEngine() + val loadStarted = CountDownLatch(1) + val allowLoadToFinish = CountDownLatch(1) + val prebuiltLoader = loader { + opens += 1 + loadStarted.countDown() + check(allowLoadToFinish.await(2, TimeUnit.SECONDS)) + validIndex("an").inputStream() + } + + engine.configureQwertyGlideDecoderAsync( + enabled = true, + canUseBundledPrebuiltIndex = true, + prebuiltDictionaryLoader = prebuiltLoader, + ) + assertTrue(loadStarted.await(2, TimeUnit.SECONDS)) + engine.configureQwertyGlideDecoderAsync( + enabled = true, + canUseBundledPrebuiltIndex = true, + prebuiltDictionaryLoader = prebuiltLoader, + ) + + allowLoadToFinish.countDown() + engine.awaitQwertyGlideWarmup() + assertTrue(engine.isQwertyGlideDictionaryReady()) + assertTrue(opens == 1) + } + private fun loader(openBundledIndex: () -> InputStream) = QwertyGlidePrebuiltDictionaryLoader( openBundledIndex = openBundledIndex, isBundledEnglishDictionaryActive = { true }, diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/KanaKanjiEngineEnglishKanaNumberTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/KanaKanjiEngineEnglishKanaNumberTest.kt index 73b5baafd..e18ce6c02 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/KanaKanjiEngineEnglishKanaNumberTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/KanaKanjiEngineEnglishKanaNumberTest.kt @@ -59,6 +59,67 @@ class KanaKanjiEngineEnglishKanaNumberTest { assertTrue(engine.getCandidatesEnglishKana("にじゅっふん").any { it.string == "20分" }) assertTrue(engine.getCandidatesEnglishKana("ろくじ").any { it.string == "6時" }) assertTrue(engine.getCandidatesEnglishKana("にじゅうよじ").any { it.string == "24時" }) + assertTrue(engine.getCandidatesEnglishKana("くじ").any { it.string == "9時" }) + assertTrue(engine.getCandidatesEnglishKana("じゅうくじ").any { it.string == "19時" }) + assertTrue(engine.getCandidatesEnglishKana("よにん").any { it.string == "4人" }) + assertTrue(engine.getCandidatesEnglishKana("よえん").any { it.string == "4円" }) + assertTrue(engine.getCandidatesEnglishKana("くえん").any { it.string == "9円" }) + assertTrue(engine.getCandidatesEnglishKana("くにん").any { it.string == "9人" }) + assertTrue(engine.getCandidatesEnglishKana("いっぷん").any { it.string == "1分" }) + assertTrue(engine.getCandidatesEnglishKana("ろっぷん").any { it.string == "6分" }) + assertTrue(engine.getCandidatesEnglishKana("はっぷん").any { it.string == "8分" }) + } + + @Test + fun getCandidatesEnglishKana_does_not_generate_numeric_candidates_for_ordinary_words() { + val cases = mapOf( + "よしよし" to setOf("4444", "4444", "8383", "8383", "四千四百四十四"), + "しせん" to setOf("4000", "4000", "4,000", "四千"), + "くちょう" to setOf("9000000000000", "9000000000000", "九兆", "9兆"), + "ちょうせん" to setOf("1000000001000", "一兆一千"), + "ごご" to setOf("55", "55", "五十五"), + "さんご" to setOf("35", "35", "三十五"), + "しえん" to setOf("4円", "4円"), + "しじ" to setOf("4時", "4時"), + "いちいち" to setOf("11", "11", "十一"), + "さんさん" to setOf("33", "33", "三十三"), + "ろくろく" to setOf("66", "66", "六十六"), + "よせん" to setOf("4000", "4000", "4,000", "四千"), + "くせん" to setOf("9000", "9000", "9,000", "九千"), + ) + + cases.forEach { (input, forbidden) -> + val candidates = engine.getCandidatesEnglishKana(input).map { it.string } + assertEquals(input, candidates.first()) + assertTrue("$input: $candidates", candidates.none { it in forbidden }) + } + } + + @Test + fun getCandidatesEnglishKana_keeps_valid_cardinal_and_counter_candidates() { + val fourThousand = engine.getCandidatesEnglishKana("よんせん") + assertTrue(fourThousand.any { it.string == "4000" }) + assertTrue(fourThousand.any { it.string == "四千" }) + assertTrue(fourThousand.any { + it.string == "四千" && it.leftId == 2046.toShort() && it.rightId == 2046.toShort() + }) + + val nineTrillion = engine.getCandidatesEnglishKana("きゅうちょう") + assertTrue(nineTrillion.any { it.string == "9000000000000" }) + assertTrue(nineTrillion.any { it.string == "九兆" }) + + val oneTrillion = engine.getCandidatesEnglishKana("いっちょう") + assertTrue(oneTrillion.any { it.string == "1000000000000" }) + assertTrue(oneTrillion.any { it.string == "一兆" }) + + val historicalForty = engine.getCandidatesEnglishKana("しじゅう") + assertTrue(historicalForty.any { it.string == "40" }) + assertTrue(historicalForty.any { it.string == "四十" }) + + val threePeople = engine.getCandidatesEnglishKana("さんにん") + assertTrue(threePeople.any { + it.string == "3人" && it.leftId == 2044.toShort() && it.rightId == 2011.toShort() + }) } @Test diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/path_algorithm/FindPathSessionScratchTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/path_algorithm/FindPathSessionScratchTest.kt index 4bb899385..876697a7b 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/path_algorithm/FindPathSessionScratchTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/path_algorithm/FindPathSessionScratchTest.kt @@ -28,14 +28,40 @@ class FindPathSessionScratchTest { fun stateTableKeepsOnlyTheLowestCostForAnExactState() { val table = FindPath.StateCostTable(initialCapacity = 16) assertTrue(table.putIfLower(1, 2, 3, 4, 5, 0, 100)) + assertTrue(table.containsExactCost(1, 2, 3, 4, 5, 0, 100)) assertFalse(table.putIfLower(1, 2, 3, 4, 5, 0, 101)) assertTrue(table.putIfLower(1, 2, 3, 4, 5, 0, 99)) + assertFalse(table.containsExactCost(1, 2, 3, 4, 5, 0, 100)) + assertTrue(table.containsExactCost(1, 2, 3, 4, 5, 0, 99)) assertTrue("a different canonical output remains a candidate", table.putIfLower(1, 2, 3, 4, 6, 0, 101)) table.clear() assertTrue(table.putIfLower(1, 2, 3, 4, 5, 0, 200)) } + @Test + fun expansionCacheReusesOnlyTheSamePredecessorList() { + val cache = FindPath.ExpansionCache(initialCapacity = 16) + val predecessor = Node( + l = 0, + r = 0, + score = 0, + f = 0, + tango = "前", + len = 1, + yomiUsed = "まえ", + sPos = 0, + ) + val firstList = mutableListOf(predecessor) + val restoredFrontier = mutableListOf(predecessor) + val expansion = FindPath.ExpansionList(arrayOf(predecessor), intArrayOf(123), 1) + + cache.put(1, 2, 3, 4, 5, firstList, expansion) + + assertSame(expansion, cache.get(1, 2, 3, 4, 5, firstList)) + assertNull(cache.get(1, 2, 3, 4, 5, restoredFrontier)) + } + @Test fun kBestStateTableRetainsOnlyTheExactLowestDistinctSuffixes() { val table = FindPath.KBestStateCostTable(k = 2, initialCapacity = 16) diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/session/KanaKanjiConversionSessionParityTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/session/KanaKanjiConversionSessionParityTest.kt index f4d89f422..c6f107af6 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/session/KanaKanjiConversionSessionParityTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/session/KanaKanjiConversionSessionParityTest.kt @@ -58,10 +58,30 @@ class KanaKanjiConversionSessionParityTest { legacyResult.bunsetsuResult?.splitPatternByCandidateString, incrementalResult.bunsetsuResult?.splitPatternByCandidateString, ) + assertEquals( + "$input/$mode/bunsetsu=$bunsetsu conversion segments", + legacyResult.candidateSegmentsByString, + incrementalResult.candidateSegmentsByString, + ) } } } + @Test + fun segmentCollectionUsesExactPathNodesWhenBunsetsuDisplayIsDisabled() = runBlocking { + val result = KanaKanjiConversionSession(engine, ConversionBackend.LEGACY).query( + request("ひを", CandidateQueryMode.CONVERSION, bunsetsu = false), + ) + + assertEquals( + listOf(Triple(0, 1, "火"), Triple(1, 2, "を")), + result.candidateSegmentsByString.getValue("火を").map { + Triple(it.inputStart, it.inputEnd, it.output) + }, + ) + assertEquals(null, result.bunsetsuResult) + } + @Test fun cancelledInPlaceAppendIsDiscardedBeforeNextRequest() = runBlocking { val localEngine = TestEngineFactory.create() @@ -75,6 +95,13 @@ class KanaKanjiConversionSessionParityTest { } emptyList() } + whenever(repository.exactMatchesForConversion(any())).thenAnswer { + lookupCount++ + if (failDuringAppend && lookupCount == 3) { + throw kotlinx.coroutines.CancellationException("controlled partial append") + } + emptyList() + } val incremental = KanaKanjiConversionSession( localEngine, ConversionBackend.INCREMENTAL_SESSION, @@ -98,21 +125,84 @@ class KanaKanjiConversionSessionParityTest { cancellationObserved = true } assertTrue(cancellationObserved) + assertEquals("きょ", incremental.committedInput()) failDuringAppend = false lookupCount = 0 val recovered = incremental.query( - request("きょう", CandidateQueryMode.PREDICTION, true).copy( + request("きょうは", CandidateQueryMode.PREDICTION, true).copy( userDictionaryRepository = repository, ), ) val rebuilt = KanaKanjiConversionSession(localEngine, ConversionBackend.LEGACY).query( - request("きょう", CandidateQueryMode.PREDICTION, true).copy( + request("きょうは", CandidateQueryMode.PREDICTION, true).copy( userDictionaryRepository = repository, ), ) assertEquals(rebuilt.candidates.fingerprint(), recovered.candidates.fingerprint()) assertEquals(rebuilt.bunsetsuResult?.splitPatterns, recovered.bunsetsuResult?.splitPatterns) + assertEquals("きょうは", incremental.committedInput()) + } + + @Test + fun completedGraphSurvivesCancellationAfterForwardDp() = runBlocking { + val localEngine = TestEngineFactory.create() + val incremental = KanaKanjiConversionSession( + localEngine, + ConversionBackend.INCREMENTAL_SESSION, + ) + incremental.query(request("きょ", CandidateQueryMode.PREDICTION, true)) + + var cancelAfterForwardDp = true + incremental.setAfterForwardDpForTest { + if (cancelAfterForwardDp) { + cancelAfterForwardDp = false + throw kotlinx.coroutines.CancellationException("controlled post-forward cancellation") + } + } + var cancellationObserved = false + try { + incremental.query(request("きょう", CandidateQueryMode.PREDICTION, true)) + } catch (_: kotlinx.coroutines.CancellationException) { + cancellationObserved = true + } + + assertTrue(cancellationObserved) + assertEquals("きょう", incremental.committedInput()) + + incremental.setAfterForwardDpForTest(null) + val recovered = incremental.query(request("きょうは", CandidateQueryMode.PREDICTION, true)) + val rebuilt = KanaKanjiConversionSession(localEngine, ConversionBackend.LEGACY).query( + request("きょうは", CandidateQueryMode.PREDICTION, true), + ) + assertEquals(rebuilt.candidates.fingerprint(), recovered.candidates.fingerprint()) + assertEquals(rebuilt.bunsetsuResult?.splitPatterns, recovered.bunsetsuResult?.splitPatterns) + assertEquals("きょうは", incremental.committedInput()) + } + + @Test + fun oneCharacterAppendReusesCommittedForwardDpAndMatchesLegacy() = runBlocking { + val localEngine = TestEngineFactory.create() + val incremental = KanaKanjiConversionSession( + localEngine, + ConversionBackend.INCREMENTAL_SESSION, + ).also { it.enablePerformanceProbe() } + val legacy = KanaKanjiConversionSession(localEngine, ConversionBackend.LEGACY) + + incremental.query(request("きょう", CandidateQueryMode.PREDICTION, true)) + val incrementalResult = incremental.query( + request("きょうは", CandidateQueryMode.PREDICTION, true), + ) + val legacyResult = legacy.query( + request("きょうは", CandidateQueryMode.PREDICTION, true), + ) + + assertTrue(incremental.performanceSnapshot()?.forwardDpReused == true) + assertEquals(legacyResult.candidates.fingerprint(), incrementalResult.candidates.fingerprint()) + assertEquals( + legacyResult.bunsetsuResult?.splitPatterns, + incrementalResult.bunsetsuResult?.splitPatterns, + ) } private fun request( @@ -137,6 +227,7 @@ class KanaKanjiConversionSessionParityTest { typoCorrectionOffsetScore = 3000, omissionSearchOffsetScore = 1900, beamWidth = 20, + collectCandidateSegments = true, ) private fun List.fingerprint(): List> = map { candidate -> @@ -163,6 +254,8 @@ class KanaKanjiConversionSessionParityTest { runBlocking { whenever(userDictionaryRepository.commonPrefixSearchInUserDict(any())) .thenReturn(emptyList()) + whenever(userDictionaryRepository.exactMatchesForConversion(any())) + .thenReturn(emptyList()) } } } diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/database/DoubleTapMigrationTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/database/DoubleTapMigrationTest.kt new file mode 100644 index 000000000..ad29c9f73 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/database/DoubleTapMigrationTest.kt @@ -0,0 +1,133 @@ +package com.kazumaproject.markdownhelperkeyboard.custom_keyboard.database + +import android.content.Context +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.core.app.ApplicationProvider +import com.kazumaproject.markdownhelperkeyboard.database.AppDatabase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class DoubleTapMigrationTest { + @Test + fun migration41To42_addsColumnsAndConfiguresExistingShiftKeys() { + val context = ApplicationProvider.getApplicationContext() + val name = "double-tap-migration-${System.nanoTime()}.db" + val helper = FrameworkSQLiteOpenHelperFactory().create( + SupportSQLiteOpenHelper.Configuration.builder(context) + .name(name) + .callback(object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE key_definitions (keyId INTEGER PRIMARY KEY NOT NULL, action TEXT)" + ) + } + + override fun onUpgrade( + db: SupportSQLiteDatabase, + oldVersion: Int, + newVersion: Int + ) = Unit + }) + .build() + ) + try { + val db = helper.writableDatabase + db.execSQL("INSERT INTO key_definitions VALUES (1, 'ShiftKeyPressed')") + db.execSQL("INSERT INTO key_definitions VALUES (2, 'Delete')") + + AppDatabase.MIGRATION_41_42.migrate(db) + + db.query( + "SELECT doubleTapAction, doubleTapPolicy FROM key_definitions WHERE keyId = 1" + ).use { cursor -> + cursor.moveToFirst() + assertEquals("CapLockKey", cursor.getString(0)) + assertEquals("PROMOTE", cursor.getString(1)) + } + db.query( + "SELECT doubleTapAction, doubleTapPolicy FROM key_definitions WHERE keyId = 2" + ).use { cursor -> + cursor.moveToFirst() + assertNull(cursor.getString(0)) + assertNull(cursor.getString(1)) + } + } finally { + helper.close() + context.deleteDatabase(name) + } + } + + @Test + fun migration42To43_removesNormalKeyBindingsAndNormalizesSpecialKeyPolicies() { + val context = ApplicationProvider.getApplicationContext() + val name = "double-tap-capability-migration-${System.nanoTime()}.db" + val helper = FrameworkSQLiteOpenHelperFactory().create( + SupportSQLiteOpenHelper.Configuration.builder(context) + .name(name) + .callback(object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE key_definitions ( + keyId INTEGER PRIMARY KEY NOT NULL, + isSpecialKey INTEGER NOT NULL, + action TEXT, + doubleTapAction TEXT, + doubleTapPolicy TEXT + ) + """.trimIndent() + ) + } + + override fun onUpgrade( + db: SupportSQLiteDatabase, + oldVersion: Int, + newVersion: Int + ) = Unit + }) + .build() + ) + try { + val db = helper.writableDatabase + db.execSQL("INSERT INTO key_definitions VALUES (1, 0, 'Text:a', 'Copy', 'PROMOTE')") + db.execSQL( + "INSERT INTO key_definitions VALUES (2, 1, 'ShiftKeyPressed', 'CapLockKey', 'EXCLUSIVE')" + ) + db.execSQL("INSERT INTO key_definitions VALUES (3, 1, 'SelectAll', 'Copy', 'PROMOTE')") + db.execSQL("INSERT INTO key_definitions VALUES (4, 1, 'Delete', NULL, 'PROMOTE')") + + AppDatabase.MIGRATION_42_43.migrate(db) + + db.query( + "SELECT doubleTapAction, doubleTapPolicy FROM key_definitions ORDER BY keyId" + ).use { cursor -> + cursor.moveToNext() + assertNull(cursor.getString(0)) + assertNull(cursor.getString(1)) + + cursor.moveToNext() + assertEquals("CapLockKey", cursor.getString(0)) + assertEquals("PROMOTE", cursor.getString(1)) + + cursor.moveToNext() + assertEquals("Copy", cursor.getString(0)) + assertEquals("EXCLUSIVE", cursor.getString(1)) + + cursor.moveToNext() + assertNull(cursor.getString(0)) + assertNull(cursor.getString(1)) + } + } finally { + helper.close() + context.deleteDatabase(name) + } + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonImporterTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonImporterTest.kt index 86915ed55..a9b4dc2c7 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonImporterTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/custom_keyboard/import_export/KeyboardLayoutJsonImporterTest.kt @@ -14,6 +14,7 @@ import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.SpacerDefin import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -254,12 +255,228 @@ class KeyboardLayoutJsonImporterTest { assertTrue("root must be object", root.isJsonObject) val obj = root.asJsonObject - assertEquals(2, obj["schemaVersion"].asInt) + assertEquals(3, obj["schemaVersion"].asInt) assertNotNull(obj["layouts"]) assertTrue("layouts must be array", obj["layouts"].isJsonArray) assertEquals(0, obj["layouts"].asJsonArray.size()) } + @Test + fun parse_legacyShift_addsPromoteCapsLockDoubleTapDefault() { + val json = """ + [ + { + "layout": { + "layoutId": 1, + "name": "Legacy Shift", + "columnCount": 1, + "rowCount": 1, + "stableId": "legacy-shift" + }, + "keysWithFlicks": [ + { + "key": { + "keyIdentifier": "shift", + "label": "", + "row": 0, + "column": 0, + "keyType": "NORMAL", + "isSpecialKey": true, + "action": "ShiftKeyPressed" + } + } + ] + } + ] + """.trimIndent() + + val key = KeyboardLayoutJsonImporter.parse(json) + .layoutsOrThrow() + .single() + .keysWithFlicks + .single() + .key + + assertEquals("CapLockKey", key.doubleTapAction) + assertEquals("PROMOTE", key.doubleTapPolicy) + } + + @Test + fun parse_schema3ShiftWithExplicitDisable_doesNotRestoreDefault() { + val json = """ + { + "schemaVersion": 3, + "layouts": [ + { + "layout": { + "layoutId": 1, + "name": "Disabled Shift", + "columnCount": 1, + "rowCount": 1, + "stableId": "disabled-shift" + }, + "keysWithFlicks": [ + { + "key": { + "keyIdentifier": "shift", + "label": "", + "row": 0, + "column": 0, + "keyType": "NORMAL", + "isSpecialKey": true, + "action": "ShiftKeyPressed", + "doubleTapEnabled": false + } + } + ] + } + ] + } + """.trimIndent() + + val key = KeyboardLayoutJsonImporter.parse(json) + .layoutsOrThrow() + .single() + .keysWithFlicks + .single() + .key + + assertEquals(null, key.doubleTapAction) + assertEquals(null, key.doubleTapPolicy) + } + + @Test + fun exportImport_roundTrip_preservesConfiguredDoubleTapShortcut() { + val fullLayout = FullKeyboardLayout( + layout = CustomKeyboardLayout( + layoutId = 1, + name = "Double Tap", + columnCount = 1, + rowCount = 1, + stableId = "double-tap" + ), + keysWithFlicks = listOf( + KeyWithFlicks( + key = KeyDefinition( + keyId = 1, + ownerLayoutId = 1, + label = "Select", + row = 0, + column = 0, + keyType = KeyType.NORMAL, + isSpecialKey = true, + keyIdentifier = "select", + action = "SelectAll", + doubleTapAction = "Copy", + doubleTapPolicy = "EXCLUSIVE" + ), + flicks = emptyList(), + circularFlicks = emptyList(), + twoStepFlicks = emptyList(), + longPressFlicks = emptyList(), + twoStepLongPressFlicks = emptyList() + ) + ) + ) + + val importedKey = KeyboardLayoutJsonImporter.parse( + KeyboardLayoutJsonExporter.toJson(listOf(fullLayout)) + ).layoutsOrThrow().single().keysWithFlicks.single().key + + assertEquals("Copy", importedKey.doubleTapAction) + assertEquals("EXCLUSIVE", importedKey.doubleTapPolicy) + } + + @Test + fun exportImport_dropsDoubleTapConfigurationFromNormalCharacterKey() { + val fullLayout = FullKeyboardLayout( + layout = CustomKeyboardLayout( + layoutId = 1, + name = "Normal Key", + columnCount = 1, + rowCount = 1, + stableId = "normal-key" + ), + keysWithFlicks = listOf( + KeyWithFlicks( + key = KeyDefinition( + keyId = 1, + ownerLayoutId = 1, + label = "a", + row = 0, + column = 0, + keyType = KeyType.NORMAL, + isSpecialKey = false, + keyIdentifier = "a", + action = "Text:a", + doubleTapAction = "Copy", + doubleTapPolicy = "PROMOTE" + ), + flicks = emptyList(), + circularFlicks = emptyList(), + twoStepFlicks = emptyList(), + longPressFlicks = emptyList(), + twoStepLongPressFlicks = emptyList() + ) + ) + ) + + val exportedJson = KeyboardLayoutJsonExporter.toJson(listOf(fullLayout)) + val exportedKey = JsonParser.parseString(exportedJson) + .asJsonObject["layouts"].asJsonArray[0] + .asJsonObject["keysWithFlicks"].asJsonArray[0] + .asJsonObject["key"].asJsonObject + val importedKey = KeyboardLayoutJsonImporter.parse(exportedJson) + .layoutsOrThrow().single().keysWithFlicks.single().key + + assertFalse(exportedKey["doubleTapEnabled"].asBoolean) + assertTrue(exportedKey["doubleTapAction"].isJsonNull) + assertTrue(exportedKey["doubleTapPolicy"].isJsonNull) + assertNull(importedKey.doubleTapAction) + assertNull(importedKey.doubleTapPolicy) + } + + @Test + fun import_nonShiftSpecialKeyForcesExclusivePolicy() { + val json = """ + { + "schemaVersion": 3, + "layouts": [ + { + "layout": { + "name": "Special Key", + "columnCount": 1, + "rowCount": 1, + "stableId": "special-key" + }, + "keysWithFlicks": [ + { + "key": { + "keyIdentifier": "select", + "label": "Select", + "row": 0, + "column": 0, + "keyType": "NORMAL", + "isSpecialKey": true, + "action": "SelectAll", + "doubleTapEnabled": true, + "doubleTapAction": "Copy", + "doubleTapPolicy": "PROMOTE" + } + } + ] + } + ] + } + """.trimIndent() + + val key = KeyboardLayoutJsonImporter.parse(json) + .layoutsOrThrow().single().keysWithFlicks.single().key + + assertEquals("Copy", key.doubleTapAction) + assertEquals("EXCLUSIVE", key.doubleTapPolicy) + } + @Test fun exporter_withBlankLayoutName_writesFallbackName() { val exported = KeyboardLayoutJsonExporter.toJson( diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CustomKeyboardShiftStateTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CustomKeyboardShiftStateTest.kt new file mode 100644 index 000000000..bfac7651a --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CustomKeyboardShiftStateTest.kt @@ -0,0 +1,41 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service + +import org.junit.Assert.assertEquals +import org.junit.Test + +class CustomKeyboardShiftStateTest { + @Test + fun doubleTapSequence_promotesOneShotToLocked_thenNextShiftTurnsOff() { + val afterFirstTap = CustomKeyboardShiftState.OFF.onShiftTap() + val afterDoubleTapAction = afterFirstTap.onCapsLockTap() + val afterNextShiftTap = afterDoubleTapAction.onShiftTap() + + assertEquals(CustomKeyboardShiftState.ONE_SHOT, afterFirstTap) + assertEquals(CustomKeyboardShiftState.LOCKED, afterDoubleTapAction) + assertEquals(CustomKeyboardShiftState.OFF, afterNextShiftTap) + } + + @Test + fun oneShot_isConsumed_butLockedIsRetained() { + assertEquals( + CustomKeyboardShiftState.OFF, + CustomKeyboardShiftState.ONE_SHOT.consumeOneShot() + ) + assertEquals( + CustomKeyboardShiftState.LOCKED, + CustomKeyboardShiftState.LOCKED.consumeOneShot() + ) + } + + @Test + fun uppercaseTransformation_onlyChangesAsciiLetters() { + assertEquals( + "ABC-あ1", + CustomKeyboardShiftState.LOCKED.transformAsciiLetters("aBc-あ1") + ) + assertEquals( + "aBc-あ1", + CustomKeyboardShiftState.OFF.transformAsciiLetters("aBc-あ1") + ) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/DeleteLongPressConversionBehaviorTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/DeleteLongPressConversionBehaviorTest.kt new file mode 100644 index 000000000..c7cd0b5e6 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/DeleteLongPressConversionBehaviorTest.kt @@ -0,0 +1,34 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DeleteLongPressConversionBehaviorTest { + @Test + fun deferred_isDefaultAndSuspendsCandidateResults() { + assertEquals( + DeleteLongPressConversionBehavior.Deferred, + DeleteLongPressConversionBehavior.fromPreferenceValue(null), + ) + assertEquals( + DeleteLongPressConversionBehavior.Deferred, + DeleteLongPressConversionBehavior.fromPreferenceValue("unknown"), + ) + assertTrue( + DeleteLongPressConversionBehavior.Deferred.suspendsCandidateResultsDuringRepeat + ) + } + + @Test + fun continuous_restoresPerDeleteCandidateConversion() { + assertEquals( + DeleteLongPressConversionBehavior.Continuous, + DeleteLongPressConversionBehavior.fromPreferenceValue("continuous"), + ) + assertFalse( + DeleteLongPressConversionBehavior.Continuous.suspendsCandidateResultsDuringRepeat + ) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/DeleteLongPressConversionGateTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/DeleteLongPressConversionGateTest.kt new file mode 100644 index 000000000..f9ce42f5b --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/DeleteLongPressConversionGateTest.kt @@ -0,0 +1,55 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service + +import com.kazumaproject.markdownhelperkeyboard.ime_service.models.CandidateShowFlag +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class DeleteLongPressConversionGateTest { + @Test + fun repeat_rendersRawTextAndRejectsCandidateResults() { + val gate = DeleteLongPressConversionGate() + + assertTrue(gate.beginRepeat()) + assertTrue(gate.shouldRenderRawComposing("たなか")) + assertFalse(gate.shouldRenderRawComposing("")) + assertFalse(gate.mayApplyCandidateResult()) + } + + @Test + fun refreshObservedDuringRepeat_doesNotReopenCandidateGate() { + val gate = DeleteLongPressConversionGate() + gate.beginRepeat() + + gate.onCandidateRefreshStarted() + + assertFalse(gate.mayApplyCandidateResult()) + } + + @Test + fun partialDelete_resumesWithExactlyOneUpdatingRefresh() { + val gate = DeleteLongPressConversionGate() + gate.beginRepeat() + + assertEquals(CandidateShowFlag.Updating, gate.finishRepeat("たな")) + assertFalse(gate.mayApplyCandidateResult()) + assertNull(gate.finishRepeat("たな")) + + gate.onCandidateRefreshStarted() + assertTrue(gate.mayApplyCandidateResult()) + } + + @Test + fun fullDelete_resumesWithExactlyOneIdleRefresh() { + val gate = DeleteLongPressConversionGate() + gate.beginRepeat() + + assertEquals(CandidateShowFlag.Idle, gate.finishRepeat("")) + assertFalse(gate.mayApplyCandidateResult()) + + gate.onCandidateRefreshStarted() + assertTrue(gate.mayApplyCandidateResult()) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/FullscreenModePolicyTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/FullscreenModePolicyTest.kt new file mode 100644 index 000000000..0e53b1789 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/FullscreenModePolicyTest.kt @@ -0,0 +1,53 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service + +import android.view.inputmethod.EditorInfo +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FullscreenModePolicyTest { + + @Test + fun usesFullscreenWhenFrameworkRequestsItAndUserAllowsIt() { + assertTrue( + FullscreenModePolicy.shouldUseFullscreenMode( + frameworkRequestsFullscreen = true, + fullscreenModeAllowed = true, + imeOptions = 0, + ) + ) + } + + @Test + fun rejectsFullscreenWhenFrameworkDoesNotRequestIt() { + assertFalse( + FullscreenModePolicy.shouldUseFullscreenMode( + frameworkRequestsFullscreen = false, + fullscreenModeAllowed = true, + imeOptions = 0, + ) + ) + } + + @Test + fun rejectsFullscreenWhenUserDisallowsIt() { + assertFalse( + FullscreenModePolicy.shouldUseFullscreenMode( + frameworkRequestsFullscreen = true, + fullscreenModeAllowed = false, + imeOptions = 0, + ) + ) + } + + @Test + fun rejectsFullscreenWhenEditorDisablesExtractUi() { + assertFalse( + FullscreenModePolicy.shouldUseFullscreenMode( + frameworkRequestsFullscreen = true, + fullscreenModeAllowed = true, + imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI, + ) + ) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterUserTemplateTypeTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterUserTemplateTypeTest.kt index 039822570..f76b436cd 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterUserTemplateTypeTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterUserTemplateTypeTest.kt @@ -116,7 +116,7 @@ class SuggestionAdapterUserTemplateTypeTest { awaitItemCount(adapter, 3) val holder = createHolder(adapter) - val expected = listOf("学習", "ユーザー", "定型") + val expected = listOf("[学習]", "[ユーザー]", "[定型]") expected.forEachIndexed { position, label -> adapter.onBindViewHolder(holder, position) assertEquals(label, holder.typeText.text.toString()) @@ -136,7 +136,7 @@ class SuggestionAdapterUserTemplateTypeTest { val holder = createHolder(adapter) adapter.onBindViewHolder(holder, 0) - assertEquals("定型", holder.typeText.text.toString()) + assertEquals("[定型]", holder.typeText.text.toString()) adapter.setShowDictionaryCandidateLabels(false) adapter.onBindViewHolder(holder, 0) diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/extensions/NumberExtensionTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/extensions/NumberExtensionTest.kt index 44f609f17..364220cfa 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/extensions/NumberExtensionTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/extensions/NumberExtensionTest.kt @@ -2,6 +2,7 @@ package com.kazumaproject.markdownhelperkeyboard.ime_service.extensions import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Test class NumberExtensionTest { @@ -10,7 +11,11 @@ class NumberExtensionTest { fun toNumber_converts_basic_hiragana_numbers_to_half_width_digits() { val cases = mapOf( "いち" to "1", + "し" to "4", + "よ" to "4", + "く" to "9", "じゅう" to "10", + "いちじゅう" to "10", "にじゅう" to "20", "ひゃく" to "100", "せん" to "1000", @@ -25,10 +30,12 @@ class NumberExtensionTest { } @Test - fun toNumber_handles_zero_and_digit_by_digit_readings() { + fun toNumber_handles_zero_but_rejects_unstructured_digit_sequences() { assertEquals("0", "ぜろ".toNumber()?.second) assertEquals("0", "れい".toNumber()?.second) - assertEquals("301", "さんぜろいち".toNumber()?.second) + assertNull("さんぜろいち".toNumber()) + assertNull("いちいち".toNumber()) + assertNull("さんさん".toNumber()) } @Test @@ -39,7 +46,14 @@ class NumberExtensionTest { "はっぴゃく" to "800", "さんぜん" to "3000", "はっせん" to "8000", - "じゅうまん" to "100000" + "じゅうまん" to "100000", + "いっちょう" to "1000000000000", + "しじゅう" to "40", + "じゅうし" to "14", + "じゅうよ" to "14", + "じゅうく" to "19", + "さんひゃく" to "300", + "はちせん" to "8000", ) cases.forEach { (input, expected) -> @@ -48,4 +62,46 @@ class NumberExtensionTest { assertEquals(expected, number?.second) } } + + @Test + fun toNumber_rejects_ordinary_words_that_only_look_numeric_by_character() { + val ordinaryReadings = listOf( + "よしよし", + "しせん", + "くちょう", + "ちょうせん", + "ごご", + "さんご", + "しえん", + "しじ", + ) + + ordinaryReadings.forEach { input -> + assertNull(input, input.toNumber()) + } + } + + @Test + fun toNumber_rejects_invalid_unit_order_repetition_and_contextual_readings() { + val invalidReadings = listOf( + "よせん", + "しせん", + "くせん", + "しけい", + "しがいせん", + "じゅうひゃく", + "せんせん", + "いちまんにおく", + "いちまんにまん", + ) + + invalidReadings.forEach { input -> + assertNull(input, input.toNumber()) + } + } + + @Test + fun toNumber_returns_null_instead_of_treating_long_invalid_input_as_digits() { + assertNull("きゅう".repeat(20).toNumber()) + } } diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/ComposingTextArbiterTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/ComposingTextArbiterTest.kt new file mode 100644 index 000000000..7de6a48bc --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/ComposingTextArbiterTest.kt @@ -0,0 +1,71 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.flick_preview + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ComposingTextArbiterTest { + @Test + fun canonicalWritesAreHeldWhilePreviewIsVisibleAndCancelRestoresLatest() { + val writes = mutableListOf() + val arbiter = arbiter(writes) + arbiter.setCanonical("か", 1) + arbiter.showPreview("かあ", 1) + + assertTrue(arbiter.setCanonical("最新", 1)) + assertEquals(listOf("text:か:1", "text:かあ:1"), writes) + + arbiter.cancelPreviewAndRestore() + assertEquals("text:最新:1", writes.last()) + } + + @Test + fun suspendedPreviewRestoresCanonicalAndCanResume() { + val writes = mutableListOf() + val arbiter = arbiter(writes) + arbiter.setCanonical("か", 1) + arbiter.showPreview("かあ", 1) + arbiter.suspendPreviewAndRestore() + arbiter.showPreview("かう", 1) + + assertEquals( + listOf("text:か:1", "text:かあ:1", "text:か:1", "text:かう:1"), + writes, + ) + } + + @Test + fun releaseKeepsPreviewDisplayed() { + val writes = mutableListOf() + val arbiter = arbiter(writes) + arbiter.setCanonical("か", 1) + arbiter.showPreview("かあ", 1) + + arbiter.releasePreview(leaveDisplayedText = true) + + assertEquals("text:かあ:1", writes.last()) + assertEquals(2, writes.size) + } + + @Test + fun cancelFromNoCanonicalCompositionRemovesPreviewInsteadOfFinishingIt() { + val writes = mutableListOf() + val arbiter = arbiter(writes) + arbiter.showPreview("あ", 1) + + arbiter.cancelPreviewAndRestore() + + assertEquals(listOf("text:あ:1", "text::0"), writes) + } + + private fun arbiter(writes: MutableList) = ComposingTextArbiter( + writeComposingText = { text, cursor -> + writes += "text:$text:$cursor" + true + }, + finishComposingText = { + writes += "finish" + true + }, + ) +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/FlickInputPreviewCoordinatorTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/FlickInputPreviewCoordinatorTest.kt new file mode 100644 index 000000000..7070cf1ed --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/FlickInputPreviewCoordinatorTest.kt @@ -0,0 +1,161 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.flick_preview + +import com.kazumaproject.core.domain.flick.FlickTextPreviewEvent +import com.kazumaproject.core.domain.flick.FlickTextSelection +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class FlickInputPreviewCoordinatorTest { + @Test + fun downMoveCancelOnlyChangesEditorPreview() { + val fixture = Fixture() + fixture.arbiter.setCanonical("か", 1) + + fixture.coordinator.onEvent(started("あ"), fixture.context(baseInput = "か")) + fixture.coordinator.onEvent(changed("う"), fixture.context(baseInput = "か")) + fixture.coordinator.onEvent(canceled(), fixture.context(baseInput = "か")) + + assertEquals( + listOf("text:か:1", "text:かあ:1", "text:かう:1", "text:か:1"), + fixture.writes, + ) + } + + @Test + fun matchingCommitCanConsumeExactPreviewMutation() { + val fixture = Fixture() + fixture.arbiter.setCanonical("あ", 1) + fixture.coordinator.onEvent(started("あ"), fixture.context(baseInput = "あ")) + fixture.coordinator.onEvent(commit("あ"), fixture.context(baseInput = "あ")) + + val mutation = fixture.coordinator.consumePendingCommit("あ", false) + fixture.coordinator.onEvent(finished(), fixture.context(baseInput = "あ")) + + assertNotNull(mutation) + assertEquals("い", mutation?.resultInput) + assertEquals("text:い:1", fixture.writes.last()) + } + + @Test + fun unconsumedCommitRestoresCanonicalOnFinished() { + val fixture = Fixture() + fixture.arbiter.setCanonical("か", 1) + fixture.coordinator.onEvent(started("あ"), fixture.context(baseInput = "か")) + fixture.coordinator.onEvent(commit("あ"), fixture.context(baseInput = "か")) + fixture.coordinator.onEvent(finished(), fixture.context(baseInput = "か")) + + assertEquals("text:か:1", fixture.writes.last()) + } + + @Test + fun staleSessionAndMismatchedCommitAreIgnored() { + val fixture = Fixture() + fixture.arbiter.setCanonical("か", 1) + fixture.coordinator.onEvent(started("あ"), fixture.context(baseInput = "か")) + fixture.coordinator.onEvent(changed("う"), fixture.context(sessionId = 2L)) + + assertNull(fixture.coordinator.consumePendingCommit("別", false)) + assertEquals("text:かあ:1", fixture.writes.last()) + } + + @Test + fun downAndMoveUsePreviewTextFactory() { + val fixture = Fixture( + createPreviewText = { text, tail -> "decorated[$text|$tail]" } + ) + + fixture.coordinator.onEvent(started("あ"), fixture.context()) + fixture.coordinator.onEvent(changed("う"), fixture.context()) + + assertEquals( + listOf("text:decorated[あ|]:1", "text:decorated[う|]:1"), + fixture.writes, + ) + } + + @Test + fun tailIsRenderedOnDownAndMoveButExcludedFromCommittedInputMutation() { + val fixture = Fixture( + createPreviewText = { text, tail -> "$text$tail" } + ) + val context = fixture.context(baseInput = "か", composingTail = "な") + fixture.arbiter.setCanonical("かな", 1) + + fixture.coordinator.onEvent(started("あ"), context) + fixture.coordinator.onEvent(changed("う"), context) + fixture.coordinator.onEvent(commit("う", isFlick = true), context) + val mutation = fixture.coordinator.consumePendingCommit("う", isFlick = true) + fixture.coordinator.onEvent(finished(), context) + + assertEquals( + listOf("text:かな:1", "text:かあな:1", "text:かうな:1"), + fixture.writes, + ) + assertEquals("かう", mutation?.resultInput) + } + + private class Fixture( + createPreviewText: (String, String) -> CharSequence = { text, _ -> text }, + ) { + val writes = mutableListOf() + val arbiter = ComposingTextArbiter( + writeComposingText = { text, cursor -> + writes += "text:$text:$cursor" + true + }, + finishComposingText = { + writes += "finish" + true + }, + ) + val coordinator = FlickInputPreviewCoordinator( + composingTextArbiter = arbiter, + createPreviewText = createPreviewText, + ) + + fun context( + baseInput: String = "", + sessionId: Long = 1L, + composingTail: String = "", + ) = FlickPreviewContext( + source = FlickPreviewSource.TENKEY, + editorSessionId = sessionId, + settingEnabled = true, + surfaceEligible = true, + inputBehaviorUsesComposingText = true, + safeInputType = true, + isHenkan = false, + selectMode = false, + cursorMoveMode = false, + composingTail = composingTail, + hasInputConnection = true, + baseInput = baseInput, + isFlickOnlyMode = false, + isContinuousTapInputEnabled = false, + lastFlickConvertedNextHiragana = false, + ) + } + + private fun started(text: String) = FlickTextPreviewEvent.Started( + gestureId = 1L, + selection = FlickTextSelection(text, false), + ) + + private fun changed(text: String) = FlickTextPreviewEvent.Changed( + gestureId = 1L, + selection = FlickTextSelection(text, true), + ) + + private fun commit( + text: String, + isFlick: Boolean = false, + ) = FlickTextPreviewEvent.CommitPending( + gestureId = 1L, + selection = FlickTextSelection(text, isFlick), + ) + + private fun finished() = FlickTextPreviewEvent.Finished(gestureId = 1L) + private fun canceled() = FlickTextPreviewEvent.Canceled(gestureId = 1L) +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/FlickPreviewEligibilityPolicyTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/FlickPreviewEligibilityPolicyTest.kt new file mode 100644 index 000000000..7d01557d9 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/FlickPreviewEligibilityPolicyTest.kt @@ -0,0 +1,59 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.flick_preview + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FlickPreviewEligibilityPolicyTest { + @Test + fun eligibleContextIsAccepted() { + assertTrue(FlickPreviewEligibilityPolicy.isEligible(context())) + } + + @Test + fun settingSurfaceDirectAndUnsafeEditorAreRejected() { + assertFalse(FlickPreviewEligibilityPolicy.isEligible(context(settingEnabled = false))) + assertFalse(FlickPreviewEligibilityPolicy.isEligible(context(surfaceEligible = false))) + assertFalse( + FlickPreviewEligibilityPolicy.isEligible( + context(inputBehaviorUsesComposingText = false) + ) + ) + assertFalse(FlickPreviewEligibilityPolicy.isEligible(context(safeInputType = false))) + } + + @Test + fun conversionSelectionAndCursorStatesAreRejectedButTailIsAccepted() { + assertFalse(FlickPreviewEligibilityPolicy.isEligible(context(isHenkan = true))) + assertFalse(FlickPreviewEligibilityPolicy.isEligible(context(selectMode = true))) + assertFalse(FlickPreviewEligibilityPolicy.isEligible(context(cursorMoveMode = true))) + assertTrue(FlickPreviewEligibilityPolicy.isEligible(context(composingTail = "な"))) + } + + private fun context( + settingEnabled: Boolean = true, + surfaceEligible: Boolean = true, + inputBehaviorUsesComposingText: Boolean = true, + safeInputType: Boolean = true, + isHenkan: Boolean = false, + selectMode: Boolean = false, + cursorMoveMode: Boolean = false, + composingTail: String = "", + ) = FlickPreviewContext( + source = FlickPreviewSource.TENKEY, + editorSessionId = 1L, + settingEnabled = settingEnabled, + surfaceEligible = surfaceEligible, + inputBehaviorUsesComposingText = inputBehaviorUsesComposingText, + safeInputType = safeInputType, + isHenkan = isHenkan, + selectMode = selectMode, + cursorMoveMode = cursorMoveMode, + composingTail = composingTail, + hasInputConnection = true, + baseInput = "", + isFlickOnlyMode = false, + isContinuousTapInputEnabled = false, + lastFlickConvertedNextHiragana = false, + ) +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/FlickTextMutationResolverTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/FlickTextMutationResolverTest.kt new file mode 100644 index 000000000..e8282a41a --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/flick_preview/FlickTextMutationResolverTest.kt @@ -0,0 +1,83 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.flick_preview + +import com.kazumaproject.core.domain.flick.FlickTextSelection +import org.junit.Assert.assertEquals +import org.junit.Test + +class FlickTextMutationResolverTest { + @Test + fun emptyInputTapAppendsCharacter() { + val mutation = resolve(baseInput = "", text = "あ", isFlick = false) + + assertEquals("あ", mutation.resultInput) + assertEquals(false, mutation.effects.continuousTapInputEnabled) + assertEquals(false, mutation.effects.lastFlickConvertedNextHiragana) + } + + @Test + fun repeatedTapUsesExistingToggleRule() { + val mutation = resolve(baseInput = "あ", text = "あ", isFlick = false) + + assertEquals("い", mutation.resultInput) + } + + @Test + fun flickAndFlickOnlyTapAppend() { + assertEquals( + "かあ", + resolve(baseInput = "か", text = "あ", isFlick = true).resultInput, + ) + assertEquals( + "かあ", + resolve( + baseInput = "か", + text = "あ", + isFlick = false, + isFlickOnlyMode = true, + ).resultInput, + ) + } + + @Test + fun firstTapAfterFlickAppendsThenClearsLastFlickFlag() { + val mutation = resolve( + baseInput = "か", + text = "あ", + isFlick = false, + continuous = true, + lastFlick = true, + ) + + assertEquals("かあ", mutation.resultInput) + assertEquals(true, mutation.effects.continuousTapInputEnabled) + assertEquals(false, mutation.effects.lastFlickConvertedNextHiragana) + } + + @Test + fun multiCharacterSumireOutputAppendsWithoutChangingTapFlags() { + val mutation = resolve(baseInput = "か", text = "abc", isFlick = true) + + assertEquals("かabc", mutation.resultInput) + assertEquals(null, mutation.effects.continuousTapInputEnabled) + assertEquals(null, mutation.effects.lastFlickConvertedNextHiragana) + } + + private fun resolve( + baseInput: String, + text: String, + isFlick: Boolean, + isFlickOnlyMode: Boolean = false, + continuous: Boolean = false, + lastFlick: Boolean = false, + ): FlickTextMutation.ReplaceComposingInput { + return FlickTextMutationResolver.resolve( + snapshot = FlickMutationSnapshot( + baseInput = baseInput, + isFlickOnlyMode = isFlickOnlyMode, + isContinuousTapInputEnabled = continuous, + lastFlickConvertedNextHiragana = lastFlick, + ), + selection = FlickTextSelection(text, isFlick), + ) as FlickTextMutation.ReplaceComposingInput + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositoryDeleteImpactTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositoryDeleteImpactTest.kt index 04ba7322a..e2b9377a1 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositoryDeleteImpactTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositoryDeleteImpactTest.kt @@ -161,6 +161,47 @@ class KeyboardRepositoryDeleteImpactTest { assertEquals("key-source-circ", impact.references.single().sourceKeyIdentifier) } + @Test + fun getDeleteImpactForLayout_referencedByDoubleTapAction_isDetected() = runBlocking { + val targetStableId = "target-via-double-tap" + val target = layout(layoutId = 15, stableId = targetStableId, name = "Target") + val source = layout(layoutId = 25, stableId = "source-stable", name = "Source") + val sourceFull = FullKeyboardLayout( + layout = source, + keysWithFlicks = listOf( + KeyWithFlicks( + key = KeyDefinition( + keyId = 250, + ownerLayoutId = 25, + label = "→", + row = 0, + column = 0, + keyType = KeyType.NORMAL, + isSpecialKey = true, + keyIdentifier = "key-source-double", + action = "MoveCustomKeyboardTab", + doubleTapAction = "MoveToCustomKeyboard:$targetStableId", + doubleTapPolicy = "EXCLUSIVE" + ), + flicks = emptyList(), + circularFlicks = emptyList(), + twoStepFlicks = emptyList(), + longPressFlicks = emptyList(), + twoStepLongPressFlicks = emptyList() + ) + ), + spacers = emptyList() + ) + val targetFull = FullKeyboardLayout(target, emptyList(), emptyList()) + whenever(dao.getFullLayoutOneShot(15L)).thenReturn(targetFull) + whenever(dao.getAllFullLayoutsOneShot()).thenReturn(listOf(targetFull, sourceFull)) + + val impact = repository.getDeleteImpactForLayout(15L) + + assertTrue(impact.hasReferences) + assertEquals("key-source-double", impact.references.single().sourceKeyIdentifier) + } + @Test fun getDeleteImpactForLayout_noReferences_returnsEmpty() = runBlocking { val target = layout(layoutId = 13, stableId = "target-no-ref", name = "Target") diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositoryGridPlacementTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositoryGridPlacementTest.kt index a3d8bcde0..dedc988e2 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositoryGridPlacementTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositoryGridPlacementTest.kt @@ -1,6 +1,8 @@ package com.kazumaproject.markdownhelperkeyboard.repository import com.kazumaproject.custom_keyboard.data.GridPlacement +import com.kazumaproject.custom_keyboard.data.DoubleTapBinding +import com.kazumaproject.custom_keyboard.data.DoubleTapPolicy import com.kazumaproject.custom_keyboard.data.KeyAction import com.kazumaproject.custom_keyboard.data.KeyData import com.kazumaproject.custom_keyboard.data.KeyItem @@ -115,6 +117,99 @@ class KeyboardRepositoryGridPlacementTest { assertEquals(2, restored.keys.single().colSpan) } + @Test + fun convertRoundTrip_preservesDoubleTapActionAndPolicy() { + val shift = KeyData( + label = "", + row = 0, + column = 0, + isFlickable = false, + action = KeyAction.ShiftKey, + keyType = KeyType.NORMAL, + isSpecialKey = true, + keyId = "shift", + doubleTapBinding = DoubleTapBinding( + action = KeyAction.CapLockKey, + policy = DoubleTapPolicy.PROMOTE + ) + ) + val layout = KeyboardLayout( + keys = listOf(shift), + flickKeyMaps = emptyMap(), + columnCount = 1, + rowCount = 1 + ) + + val dbKey = convertToDbKeys(layout).single() + val restored = convertToUiLayout(listOf(dbKey)).keys.single() + + assertEquals("CapLockKey", dbKey.doubleTapAction) + assertEquals("PROMOTE", dbKey.doubleTapPolicy) + assertEquals(KeyAction.CapLockKey, restored.doubleTapBinding?.action) + assertEquals(DoubleTapPolicy.PROMOTE, restored.doubleTapBinding?.policy) + } + + @Test + fun convertRoundTrip_dropsDoubleTapBindingFromNormalCharacterKey() { + val characterKey = KeyData( + label = "a", + row = 0, + column = 0, + isFlickable = false, + action = KeyAction.Text("a"), + keyType = KeyType.NORMAL, + isSpecialKey = false, + keyId = "a", + doubleTapBinding = DoubleTapBinding( + action = KeyAction.Copy, + policy = DoubleTapPolicy.PROMOTE + ) + ) + val layout = KeyboardLayout( + keys = listOf(characterKey), + flickKeyMaps = emptyMap(), + columnCount = 1, + rowCount = 1 + ) + + val dbKey = convertToDbKeys(layout).single() + val restored = convertToUiLayout(listOf(dbKey)).keys.single() + + assertNull(dbKey.doubleTapAction) + assertNull(dbKey.doubleTapPolicy) + assertNull(restored.doubleTapBinding) + } + + @Test + fun convertRoundTrip_normalizesNonShiftShortcutToExclusivePolicy() { + val selectKey = KeyData( + label = "Select", + row = 0, + column = 0, + isFlickable = false, + action = KeyAction.SelectAll, + keyType = KeyType.NORMAL, + isSpecialKey = true, + keyId = "select", + doubleTapBinding = DoubleTapBinding( + action = KeyAction.Copy, + policy = DoubleTapPolicy.PROMOTE + ) + ) + val layout = KeyboardLayout( + keys = listOf(selectKey), + flickKeyMaps = emptyMap(), + columnCount = 1, + rowCount = 1 + ) + + val dbKey = convertToDbKeys(layout).single() + val restored = convertToUiLayout(listOf(dbKey)).keys.single() + + assertEquals("EXCLUSIVE", dbKey.doubleTapPolicy) + assertEquals(DoubleTapPolicy.EXCLUSIVE, restored.doubleTapBinding?.policy) + } + @Test fun dbRoundTrip_preservesFlexibleOnePointFiveSpanAfterCrush() { val key = KeyData( diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceKeyboardOrderTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceKeyboardOrderTest.kt new file mode 100644 index 000000000..7c7c65941 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceKeyboardOrderTest.kt @@ -0,0 +1,46 @@ +package com.kazumaproject.markdownhelperkeyboard.setting_activity + +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.PreferenceManager +import androidx.test.core.app.ApplicationProvider +import com.kazumaproject.markdownhelperkeyboard.ime_service.state.KeyboardType +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AppPreferenceKeyboardOrderTest { + private lateinit var context: Context + private lateinit var preferences: SharedPreferences + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + preferences = PreferenceManager.getDefaultSharedPreferences(context) + preferences.edit().clear().commit() + AppPreference.init(context) + } + + @Test + fun freshInstallDefaultsToTenKeyAndQwertyOnly() { + assertEquals( + listOf(KeyboardType.TENKEY, KeyboardType.QWERTY), + AppPreference.keyboard_order + ) + } + + @Test + fun savedKeyboardOrderIsPreserved() { + AppPreference.keyboard_order = listOf(KeyboardType.SUMIRE, KeyboardType.ROMAJI) + + assertEquals( + listOf(KeyboardType.SUMIRE, KeyboardType.ROMAJI), + AppPreference.keyboard_order + ) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/IncrementalConversionSessionPreferenceTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/IncrementalConversionSessionPreferenceTest.kt index 1ea8e6a39..cd528e83c 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/IncrementalConversionSessionPreferenceTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/IncrementalConversionSessionPreferenceTest.kt @@ -47,6 +47,17 @@ class IncrementalConversionSessionPreferenceTest { ) } + @Test + fun liveConversionAlwaysUsesIncrementalSession() { + AppPreference.incremental_conversion_session_preference = false + AppPreference.live_conversion_preference = true + + assertEquals( + ConversionBackend.INCREMENTAL_SESSION, + ImePreferencesSnapshot.from(AppPreference).conversionBackend, + ) + } + @Test fun switchIsSharedByNewAndLegacyConversionEngineSettings() { assertTrue(conversionEngineXmlContainsDefaultOffSwitch()) diff --git a/core/src/main/java/com/kazumaproject/core/domain/extensions/Drawable.kt b/core/src/main/java/com/kazumaproject/core/domain/extensions/Drawable.kt index 16f739b6c..5605f7478 100644 --- a/core/src/main/java/com/kazumaproject/core/domain/extensions/Drawable.kt +++ b/core/src/main/java/com/kazumaproject/core/domain/extensions/Drawable.kt @@ -176,8 +176,10 @@ fun View.setDrawableSolidColor(@ColorInt color: Int, index: Int = 0) { */ fun View.setDrawableAlpha(alpha: Int) { val background = this.background ?: return - // mutate() してこのView専用のインスタンスにする(他のViewへの影響を防ぐ) - background.mutate().alpha = alpha + // Key backgrounds are assigned per View. Mutating a nested LayerDrawable whose child has no + // ConstantState makes Android clone the layer hierarchy and emit an exception stack for every + // key. Alpha itself is a property of the top-level Drawable, so it can be updated directly. + background.alpha = alpha.coerceIn(0, 255) } /** diff --git a/core/src/main/java/com/kazumaproject/core/domain/flick/FlickTextPreviewEmitter.kt b/core/src/main/java/com/kazumaproject/core/domain/flick/FlickTextPreviewEmitter.kt new file mode 100644 index 000000000..b4d396fa7 --- /dev/null +++ b/core/src/main/java/com/kazumaproject/core/domain/flick/FlickTextPreviewEmitter.kt @@ -0,0 +1,60 @@ +package com.kazumaproject.core.domain.flick + +class FlickTextPreviewEmitter { + var listener: FlickTextPreviewListener? = null + + private var nextGestureId = 1L + private var activeGestureId: Long? = null + private var lastSelection: FlickTextSelection? = null + + fun begin(selection: FlickTextSelection) { + cancel() + val normalized = selection.normalized() + val gestureId = nextGestureId++ + activeGestureId = gestureId + lastSelection = normalized + listener?.onFlickTextPreview( + FlickTextPreviewEvent.Started(gestureId, normalized) + ) + } + + fun update(selection: FlickTextSelection) { + val gestureId = activeGestureId ?: return + val normalized = selection.normalized() + if (normalized == lastSelection) return + lastSelection = normalized + listener?.onFlickTextPreview( + FlickTextPreviewEvent.Changed(gestureId, normalized) + ) + } + + fun commit(selection: FlickTextSelection, dispatch: () -> Unit) { + val gestureId = activeGestureId + if (gestureId == null) { + dispatch() + return + } + val normalized = selection.normalized() + lastSelection = normalized + listener?.onFlickTextPreview( + FlickTextPreviewEvent.CommitPending(gestureId, normalized) + ) + try { + dispatch() + } finally { + listener?.onFlickTextPreview(FlickTextPreviewEvent.Finished(gestureId)) + clear() + } + } + + fun cancel() { + val gestureId = activeGestureId ?: return + listener?.onFlickTextPreview(FlickTextPreviewEvent.Canceled(gestureId)) + clear() + } + + private fun clear() { + activeGestureId = null + lastSelection = null + } +} diff --git a/core/src/main/java/com/kazumaproject/core/domain/flick/FlickTextPreviewEvent.kt b/core/src/main/java/com/kazumaproject/core/domain/flick/FlickTextPreviewEvent.kt new file mode 100644 index 000000000..a08c78c22 --- /dev/null +++ b/core/src/main/java/com/kazumaproject/core/domain/flick/FlickTextPreviewEvent.kt @@ -0,0 +1,39 @@ +package com.kazumaproject.core.domain.flick + +data class FlickTextSelection( + val text: String?, + val isFlick: Boolean, +) { + fun normalized(): FlickTextSelection = copy(text = text?.takeIf(String::isNotEmpty)) +} + +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) +} diff --git a/core/src/test/java/com/kazumaproject/core/domain/flick/FlickTextPreviewEmitterTest.kt b/core/src/test/java/com/kazumaproject/core/domain/flick/FlickTextPreviewEmitterTest.kt new file mode 100644 index 000000000..f49af25ba --- /dev/null +++ b/core/src/test/java/com/kazumaproject/core/domain/flick/FlickTextPreviewEmitterTest.kt @@ -0,0 +1,45 @@ +package com.kazumaproject.core.domain.flick + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FlickTextPreviewEmitterTest { + @Test + fun emitsLifecycleAndSuppressesDuplicateSelection() { + val events = mutableListOf() + val emitter = FlickTextPreviewEmitter().apply { + listener = FlickTextPreviewListener(events::add) + } + + emitter.begin(FlickTextSelection("あ", false)) + emitter.update(FlickTextSelection("あ", false)) + emitter.update(FlickTextSelection("い", true)) + emitter.commit(FlickTextSelection("い", true)) { + events += FlickTextPreviewEvent.Finished(-1L) + } + + assertEquals(5, events.size) + assert(events[0] is FlickTextPreviewEvent.Started) + assert(events[1] is FlickTextPreviewEvent.Changed) + assert(events[2] is FlickTextPreviewEvent.CommitPending) + assertEquals(-1L, events[3].gestureId) + assert(events[4] is FlickTextPreviewEvent.Finished) + } + + @Test + fun cancelEndsGestureAndEmptyTextIsNormalized() { + val events = mutableListOf() + val emitter = FlickTextPreviewEmitter().apply { + listener = FlickTextPreviewListener(events::add) + } + + emitter.begin(FlickTextSelection("", false)) + emitter.cancel() + emitter.update(FlickTextSelection("い", true)) + + val started = events[0] as FlickTextPreviewEvent.Started + assertEquals(null, started.selection.text) + assert(events[1] is FlickTextPreviewEvent.Canceled) + assertEquals(2, events.size) + } +} diff --git a/custom_keyboard/src/androidTest/java/com/kazumaproject/custom_keyboard/DoubleTapFlickKeyboardInstrumentedTest.kt b/custom_keyboard/src/androidTest/java/com/kazumaproject/custom_keyboard/DoubleTapFlickKeyboardInstrumentedTest.kt new file mode 100644 index 000000000..3021023c9 --- /dev/null +++ b/custom_keyboard/src/androidTest/java/com/kazumaproject/custom_keyboard/DoubleTapFlickKeyboardInstrumentedTest.kt @@ -0,0 +1,248 @@ +package com.kazumaproject.custom_keyboard + +import android.os.SystemClock +import android.os.Debug +import android.view.ContextThemeWrapper +import android.view.MotionEvent +import android.view.View +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.kazumaproject.custom_keyboard.data.DoubleTapBinding +import com.kazumaproject.custom_keyboard.data.DoubleTapPolicy +import com.kazumaproject.custom_keyboard.data.FlickDirection +import com.kazumaproject.custom_keyboard.data.KeyAction +import com.kazumaproject.custom_keyboard.data.KeyCharacterCase +import com.kazumaproject.custom_keyboard.data.KeyData +import com.kazumaproject.custom_keyboard.data.KeyType +import com.kazumaproject.custom_keyboard.data.KeyboardLayout +import com.kazumaproject.custom_keyboard.view.FlickKeyboardView +import com.kazumaproject.custom_keyboard.view.AutoSizeButton +import com.kazumaproject.custom_keyboard.layout.KeyboardDefaultLayouts +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CopyOnWriteArrayList + +@RunWith(AndroidJUnit4::class) +class DoubleTapFlickKeyboardInstrumentedTest { + @Test + fun qwertyCharacterCaseRefresh_measuresRuntimeAndAllocationOnAndroidRuntime() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + var elapsedNanos = 0L + var allocatedBytes = 0L + val iterations = 200 + + instrumentation.runOnMainSync { + val context = ContextThemeWrapper( + instrumentation.targetContext, + com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar + ) + val keyboard = FlickKeyboardView(context).apply { + setKeyboard(KeyboardDefaultLayouts.createQwertyTemplateLayout()) + val widthSpec = View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY) + val heightSpec = View.MeasureSpec.makeMeasureSpec(480, View.MeasureSpec.EXACTLY) + measure(widthSpec, heightSpec) + layout(0, 0, 1080, 480) + } + + repeat(10) { + keyboard.setKeyCharacterCase(KeyCharacterCase.UPPERCASE) + keyboard.setKeyCharacterCase(KeyCharacterCase.AS_DEFINED) + } + val allocatedBefore = + Debug.getRuntimeStat("art.gc.bytes-allocated")?.toLongOrNull() ?: 0L + val startNanos = SystemClock.elapsedRealtimeNanos() + repeat(iterations) { index -> + keyboard.setKeyCharacterCase( + if (index % 2 == 0) { + KeyCharacterCase.UPPERCASE + } else { + KeyCharacterCase.AS_DEFINED + } + ) + } + elapsedNanos = SystemClock.elapsedRealtimeNanos() - startNanos + val allocatedAfter = + Debug.getRuntimeStat("art.gc.bytes-allocated")?.toLongOrNull() ?: allocatedBefore + allocatedBytes = allocatedAfter - allocatedBefore + assertEquals("q", (keyboard.getChildAt(0) as AutoSizeButton).text.toString()) + } + + val averageMicros = elapsedNanos / iterations / 1_000.0 + val averageAllocatedBytes = allocatedBytes / iterations + println( + "KEY_CHARACTER_CASE_PERF iterations=$iterations " + + "averageUs=$averageMicros allocatedBytesPerToggle=$averageAllocatedBytes" + ) + } + + @Test + fun qwertyKeyLabel_updatesWithoutRebuildingOnAndroidRuntime() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + + instrumentation.runOnMainSync { + val context = ContextThemeWrapper( + instrumentation.targetContext, + com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar + ) + val keyboard = FlickKeyboardView(context).apply { + setKeyboard( + KeyboardLayout( + keys = listOf( + KeyData( + label = "q", + row = 0, + column = 0, + isFlickable = false, + action = KeyAction.Text("q"), + keyId = "q", + keyType = KeyType.NORMAL + ) + ), + flickKeyMaps = emptyMap(), + columnCount = 1, + rowCount = 1 + ) + ) + } + val key = keyboard.getChildAt(0) as AutoSizeButton + assertEquals("q", key.text.toString()) + + keyboard.setKeyCharacterCase(KeyCharacterCase.UPPERCASE) + assertEquals("Q", key.text.toString()) + + keyboard.setKeyCharacterCase(KeyCharacterCase.AS_DEFINED) + assertEquals("q", key.text.toString()) + } + } + + @Test + fun shiftDoubleTap_dispatchesPromotedCapsLockActionOnAndroidRuntime() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val actions = CopyOnWriteArrayList() + lateinit var keyboard: FlickKeyboardView + + instrumentation.runOnMainSync { + val context = ContextThemeWrapper( + instrumentation.targetContext, + com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar + ) + keyboard = FlickKeyboardView(context).apply { + setOnKeyboardActionListener(RecordingListener(actions)) + setKeyboard( + KeyboardLayout( + keys = listOf( + KeyData( + label = "", + row = 0, + column = 0, + isFlickable = false, + action = KeyAction.ShiftKey, + isSpecialKey = true, + keyId = "shift", + keyType = KeyType.NORMAL, + doubleTapBinding = DoubleTapBinding( + action = KeyAction.CapLockKey, + policy = DoubleTapPolicy.PROMOTE + ) + ) + ), + flickKeyMaps = emptyMap(), + columnCount = 1, + rowCount = 1 + ) + ) + val widthSpec = View.MeasureSpec.makeMeasureSpec(240, View.MeasureSpec.EXACTLY) + val heightSpec = View.MeasureSpec.makeMeasureSpec(120, View.MeasureSpec.EXACTLY) + measure(widthSpec, heightSpec) + layout(0, 0, 240, 120) + } + tapCenter(keyboard) + } + + SystemClock.sleep(100) + instrumentation.runOnMainSync { + tapCenter(keyboard) + } + + assertEquals(listOf(KeyAction.ShiftKey, KeyAction.CapLockKey), actions) + } + + @Test + fun normalCharacterKey_dispatchesRepeatedTextEvenWithInvalidPersistedBinding() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val actions = CopyOnWriteArrayList() + + instrumentation.runOnMainSync { + val context = ContextThemeWrapper( + instrumentation.targetContext, + com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar + ) + val keyboard = FlickKeyboardView(context).apply { + setOnKeyboardActionListener(RecordingListener(actions)) + setKeyboard( + KeyboardLayout( + keys = listOf( + KeyData( + label = "a", + row = 0, + column = 0, + isFlickable = false, + action = KeyAction.Text("a"), + isSpecialKey = false, + keyId = "a", + keyType = KeyType.NORMAL, + doubleTapBinding = DoubleTapBinding( + action = KeyAction.Copy, + policy = DoubleTapPolicy.PROMOTE + ) + ) + ), + flickKeyMaps = emptyMap(), + columnCount = 1, + rowCount = 1 + ) + ) + val widthSpec = View.MeasureSpec.makeMeasureSpec(240, View.MeasureSpec.EXACTLY) + val heightSpec = View.MeasureSpec.makeMeasureSpec(120, View.MeasureSpec.EXACTLY) + measure(widthSpec, heightSpec) + layout(0, 0, 240, 120) + } + + tapCenter(keyboard) + assertEquals(listOf(KeyAction.Text("a")), actions) + tapCenter(keyboard) + } + + assertEquals(listOf(KeyAction.Text("a"), KeyAction.Text("a")), actions) + } + + private fun tapCenter(keyboard: FlickKeyboardView) { + val key = keyboard.getChildAt(0) + val x = key.left + key.width / 2f + val y = key.top + key.height / 2f + val downTime = SystemClock.uptimeMillis() + MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, y, 0).also { + keyboard.onTouchEvent(it) + it.recycle() + } + MotionEvent.obtain(downTime, downTime + 20, MotionEvent.ACTION_UP, x, y, 0).also { + keyboard.onTouchEvent(it) + it.recycle() + } + } + + private class RecordingListener( + private val actions: MutableList + ) : FlickKeyboardView.OnKeyboardActionListener { + override fun onPress(action: KeyAction) = Unit + override fun onAction(action: KeyAction, isFlick: Boolean) { + actions += action + } + override fun onActionLongPress(action: KeyAction) = Unit + override fun onActionUpAfterLongPress(action: KeyAction) = Unit + override fun onFlickDirectionChanged(direction: FlickDirection) = Unit + override fun onFlickActionLongPress(action: KeyAction) = Unit + override fun onFlickActionUpAfterLongPress(action: KeyAction, isFlick: Boolean) = Unit + } +} diff --git a/custom_keyboard/src/androidTest/java/com/kazumaproject/custom_keyboard/view/DirectionalKeyPopupViewInstrumentedTest.kt b/custom_keyboard/src/androidTest/java/com/kazumaproject/custom_keyboard/view/DirectionalKeyPopupViewInstrumentedTest.kt new file mode 100644 index 000000000..afc8fc5b7 --- /dev/null +++ b/custom_keyboard/src/androidTest/java/com/kazumaproject/custom_keyboard/view/DirectionalKeyPopupViewInstrumentedTest.kt @@ -0,0 +1,108 @@ +package com.kazumaproject.custom_keyboard.view + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.view.ContextThemeWrapper +import android.view.View +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.custom_keyboard.data.FlickDirection +import com.kazumaproject.custom_keyboard.data.FlickPopupColorTheme +import kotlin.math.roundToInt +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DirectionalKeyPopupViewInstrumentedTest { + + @Test + fun outlineIsFullyDrawnInsidePopupBoundsForEveryDirection() { + val context = ContextThemeWrapper( + ApplicationProvider.getApplicationContext(), + com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar + ) + val strokeSample = (context.resources.displayMetrics.density * 0.75f) + .roundToInt() + .coerceAtLeast(1) + + val tapBitmap = render(context, FlickDirection.TAP) + val upBitmap = render(context, FlickDirection.UP) + val downBitmap = render(context, FlickDirection.DOWN) + val leftBitmap = render(context, FlickDirection.UP_LEFT_FAR) + val rightBitmap = render(context, FlickDirection.UP_RIGHT_FAR) + + assertStroke(tapBitmap.getPixel(POPUP_WIDTH / 2, strokeSample)) + assertStroke(upBitmap.getPixel(POPUP_WIDTH / 2, strokeSample)) + assertStroke( + downBitmap.getPixel(POPUP_WIDTH / 2, POPUP_HEIGHT - 1 - strokeSample) + ) + assertStroke(leftBitmap.getPixel(strokeSample, POPUP_HEIGHT / 2)) + assertStroke( + rightBitmap.getPixel(POPUP_WIDTH - 1 - strokeSample, POPUP_HEIGHT / 2) + ) + } + + private fun render(context: Context, direction: FlickDirection): Bitmap { + val view = DirectionalKeyPopupView(context).apply { + text = "" + applyPopupViewStyle( + PopupViewStyle( + sizeScalePercent = 100, + textSizeSp = 28f, + backgroundColor = Color.RED, + textColor = Color.WHITE + ) + ) + setColors( + FlickPopupColorTheme( + segmentColor = Color.RED, + segmentHighlightGradientStartColor = Color.RED, + segmentHighlightGradientEndColor = Color.RED, + centerGradientStartColor = Color.RED, + centerGradientEndColor = Color.RED, + centerHighlightGradientStartColor = Color.RED, + centerHighlightGradientEndColor = Color.RED, + separatorColor = Color.GREEN, + textColor = Color.WHITE + ) + ) + setFlickDirection(direction) + measure( + View.MeasureSpec.makeMeasureSpec(POPUP_WIDTH, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(POPUP_HEIGHT, View.MeasureSpec.EXACTLY) + ) + layout(0, 0, POPUP_WIDTH, POPUP_HEIGHT) + } + return Bitmap.createBitmap( + POPUP_WIDTH, + POPUP_HEIGHT, + Bitmap.Config.ARGB_8888 + ).also { bitmap -> + view.draw(Canvas(bitmap)) + } + } + + private fun assertStroke(actual: Int) { + val distanceToStroke = colorDistance(actual, Color.GREEN) + val distanceToFill = colorDistance(actual, Color.RED) + assertTrue( + "Expected a stroke-dominant pixel but was #${actual.toUInt().toString(16)}", + distanceToStroke < distanceToFill + ) + } + + private fun colorDistance(first: Int, second: Int): Int { + return kotlin.math.abs(Color.red(first) - Color.red(second)) + + kotlin.math.abs(Color.green(first) - Color.green(second)) + + kotlin.math.abs(Color.blue(first) - Color.blue(second)) + } + + private companion object { + const val POPUP_WIDTH = 401 + const val POPUP_HEIGHT = 120 + } +} diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CrossFlickInputController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CrossFlickInputController.kt index 2201bc234..427884ffc 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CrossFlickInputController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CrossFlickInputController.kt @@ -2,16 +2,11 @@ package com.kazumaproject.custom_keyboard.controller import android.annotation.SuppressLint import android.content.Context -import android.graphics.Color import android.graphics.PointF -import android.view.Gravity import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration -import android.view.WindowManager import android.widget.Button -import android.widget.PopupWindow -import androidx.core.graphics.drawable.toDrawable import com.kazumaproject.core.data.popup.PopupViewStyle import com.kazumaproject.core.domain.flick.FixedGestureSessionConfigSource import com.kazumaproject.core.domain.flick.FlickDirection as CoreFlickDirection @@ -90,6 +85,8 @@ class CrossFlickInputController( action: KeyAction, direction: FlickDirection ) {} + fun onTextSelectionChanged(text: String?, isFlick: Boolean) {} + fun onCanceled() {} } private enum class InputMode { @@ -99,7 +96,7 @@ class CrossFlickInputController( var listener: CrossFlickListener? = null - private var popupWindowAnchorProvider: (() -> View?)? = null + private var popupOverlayHostProvider: (() -> View?)? = null private var inputMode: InputMode = InputMode.ACTION private var anchorView: View? = null @@ -111,29 +108,22 @@ class CrossFlickInputController( private var textMap: Map = emptyMap() private var longPressTextMap: Map = emptyMap() - private val actionPopupWindows = mutableMapOf() private val actionPopupViews = mutableMapOf() - private val directionalPopupMap = mutableMapOf() - private var currentVisibleDirectionalPopup: PopupWindow? = null + private val directionalPopupMap = mutableMapOf() + private var currentVisibleDirectionalPopup: DirectionalKeyPopupView? = null private var currentVisibleDirectional: FlickDirection? = null private var directionalPopupCacheDirty = true private var directionalPopupAnchorWidth = -1 private var directionalPopupAnchorHeight = -1 private var originalKeyText: CharSequence? = null - - private val gridPopup = PopupWindow( - CrossFlickPopupView(context), - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.WRAP_CONTENT, - false - ).apply { - setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) - isClippingEnabled = false + private val popupAnchorLocation = IntArray(2) + private val popupKeyLocationScratch = IntArray(2) + private val popupHostLocationScratch = IntArray(2) + private val popupPositionScratch = IntArray(2) + private val popupOverlay = KeyboardPopupOverlay(::positionVisiblePopupsBeforeDraw) + private val gridPopupView = CrossFlickPopupView(context).apply { elevation = 8f - animationStyle = 0 - enterTransition = null - exitTransition = null } private val controllerScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) @@ -142,6 +132,7 @@ class CrossFlickInputController( private var isLongPressTriggered = false private var popupColorTheme: FlickPopupColorTheme? = null + private var inputTextTransform: (String) -> String = { it } private var directionalPopupStyle = PopupViewStyle(100, 28f) private var crossPopupStyle = PopupViewStyle(100, 18f) private val displayActionsByClass by lazy { @@ -171,16 +162,24 @@ class CrossFlickInputController( textColor = cross.textColor ) actionPopupViews.values.forEach { it.applyPopupViewStyle(crossPopupStyle) } - (gridPopup.contentView as? CrossFlickPopupView)?.applyPopupViewStyle(crossPopupStyle) + gridPopupView.applyPopupViewStyle(crossPopupStyle) invalidateDirectionalPopupCache() } - fun setPopupWindowAnchorProvider(provider: (() -> View?)?) { - popupWindowAnchorProvider = provider + fun setPopupOverlayHostProvider(provider: (() -> View?)?) { + popupOverlayHostProvider = provider + } + + fun setInputTextTransform(transform: (String) -> String) { + inputTextTransform = transform + gridPopupView.setInputTextTransform(transform) + actionPopupViews.values.forEach { it.setInputTextTransform(transform) } + invalidateDirectionalPopupCache() } // コントローラを破棄する。ビューのデタッチやキーボードビューの再構築時に FlickKeyboardView から呼ばれる。 fun cancel() { + listener?.onCanceled() activeGestureConfig = null longPressJob?.cancel() controllerScope.cancel() @@ -279,6 +278,12 @@ class CrossFlickInputController( } else { updateNormalPopup(newDirection) } + if (inputMode == InputMode.TEXT) { + listener?.onTextSelectionChanged( + resolveText(currentDirection, preferLongPress = isLongPressMode), + currentDirection != FlickDirection.TAP + ) + } } return true } @@ -292,6 +297,7 @@ class CrossFlickInputController( commitAction() } else { notifyLongPressCanceledIfNeeded() + listener?.onCanceled() } restoreOriginalButtonText() @@ -320,6 +326,10 @@ class CrossFlickInputController( showGridPopup() highlightGrid(currentDirection) } + listener?.onTextSelectionChanged( + resolveText(currentDirection, preferLongPress = true), + currentDirection != FlickDirection.TAP + ) } } } @@ -399,7 +409,7 @@ class CrossFlickInputController( } InputMode.TEXT -> { - if (gridPopup.isShowing) { + if (popupOverlay.isShowing(gridPopupView)) { highlightGrid(direction) } else { showDirectionalPopup(direction) @@ -509,10 +519,10 @@ class CrossFlickInputController( val flickAction = resolveAction(direction) ?: return if (!isVisiblePopupAction(flickAction)) return val anchor = anchorView ?: return - val windowAnchor = resolveWindowAnchor(anchor) - if (!isAnchorReady(anchor, windowAnchor)) return + if (!anchor.isAttachedToWindow) return val popupView = CrossFlickPopupView(context).apply { + setInputTextTransform(inputTextTransform) applyPopupViewStyle(crossPopupStyle) val scale = crossPopupStyle.sizeScalePercent.coerceIn(50, 200) / 100f setCells( @@ -522,44 +532,24 @@ class CrossFlickInputController( ) popupColorTheme?.let { setColors(it) } if (highlighted) highlightDirection(direction) - } - - val popupWindow = PopupWindow( - popupView, - (anchor.width * (crossPopupStyle.sizeScalePercent.coerceIn(50, 200) / 100f)).toInt().coerceAtLeast(1), - (anchor.height * (crossPopupStyle.sizeScalePercent.coerceIn(50, 200) / 100f)).toInt().coerceAtLeast(1), - false - ).apply { - isClippingEnabled = false elevation = 8f - animationStyle = 0 - enterTransition = null - exitTransition = null } + val scale = crossPopupStyle.sizeScalePercent.coerceIn(50, 200) / 100f + val popupWidth = (anchor.width * scale).toInt().coerceAtLeast(1) + val popupHeight = (anchor.height * scale).toInt().coerceAtLeast(1) + + // Register first so the overlay's initial pre-draw placement can include this view. + actionPopupViews[direction] = popupView + val shown = popupOverlay.show( + anchor = anchor, + preferredHost = resolvePreferredOverlayHost(), + popupView = popupView, + width = popupWidth, + height = popupHeight + ) - val location = getLocationRelativeToWindowAnchor(anchor, windowAnchor) - val x = location[0] - val y = location[1] - - val popupX = when (direction) { - FlickDirection.UP_LEFT_FAR, FlickDirection.UP_LEFT -> x - anchor.width - FlickDirection.UP_RIGHT_FAR, FlickDirection.UP_RIGHT -> x + anchor.width - else -> x - } - val popupY = when (direction) { - FlickDirection.UP -> y - anchor.height - FlickDirection.DOWN -> y + anchor.height - else -> y - } - - val shown = runCatching { - popupWindow.showAtLocation(windowAnchor, Gravity.NO_GRAVITY, popupX, popupY) - true - }.getOrDefault(false) - - if (shown) { - actionPopupWindows[direction] = popupWindow - actionPopupViews[direction] = popupView + if (!shown) { + actionPopupViews.remove(direction) } } @@ -574,13 +564,14 @@ class CrossFlickInputController( FlickDirection.UP_LEFT_FAR, FlickDirection.UP_RIGHT_FAR ).forEach { direction -> - val existingPopup = actionPopupWindows[direction] val existingPopupView = actionPopupViews[direction] - if (existingPopup?.isShowing == true && existingPopupView != null) { + if ( + existingPopupView != null && + popupOverlay.isShowing(existingPopupView) + ) { existingPopupView.highlightDirection(null) } else { - existingPopup?.dismiss() - actionPopupWindows.remove(direction) + existingPopupView?.let(popupOverlay::dismiss) actionPopupViews.remove(direction) showActionPopup(direction, highlighted = false) } @@ -596,8 +587,7 @@ class CrossFlickInputController( // 表示中のアクションポップアップを全て閉じてマップをクリアする。 private fun dismissAllActionPopups() { - actionPopupWindows.values.forEach { if (it.isShowing) it.dismiss() } - actionPopupWindows.clear() + actionPopupViews.values.forEach(popupOverlay::dismiss) actionPopupViews.clear() } @@ -611,7 +601,7 @@ class CrossFlickInputController( return } - directionalPopupMap.values.forEach { if (it.isShowing) it.dismiss() } + directionalPopupMap.values.forEach(popupOverlay::dismiss) directionalPopupMap.clear() currentVisibleDirectionalPopup = null currentVisibleDirectional = null @@ -629,7 +619,7 @@ class CrossFlickInputController( if (text.isNullOrEmpty()) return@forEach val popupView = DirectionalKeyPopupView(context).apply { - this.text = text + this.text = inputTextTransform(text) applyPopupViewStyle(directionalPopupStyle) popupColorTheme?.let { setColors(it) } setFlickDirection(direction) @@ -655,18 +645,12 @@ class CrossFlickInputController( } }.let { (it * scale).toInt().coerceAtLeast(1) } - directionalPopupMap[direction] = PopupWindow( - popupView, - popupWidth, - popupHeight, - false - ).apply { - isClippingEnabled = false - elevation = 8f - animationStyle = 0 - enterTransition = null - exitTransition = null - } + popupView.elevation = 8f + popupView.measure( + View.MeasureSpec.makeMeasureSpec(popupWidth, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(popupHeight, View.MeasureSpec.EXACTLY) + ) + directionalPopupMap[direction] = popupView } directionalPopupAnchorWidth = currentAnchor.width @@ -681,81 +665,41 @@ class CrossFlickInputController( return } - currentVisibleDirectionalPopup?.dismiss() + currentVisibleDirectionalPopup?.let(popupOverlay::dismiss) val popupToShow = directionalPopupMap[direction] ?: return val currentAnchor = anchorView ?: return - val windowAnchor = resolveWindowAnchor(currentAnchor) - if (!isAnchorReady(currentAnchor, windowAnchor)) { + if (!currentAnchor.isAttachedToWindow) { currentVisibleDirectionalPopup = null currentVisibleDirectional = null return } + currentVisibleDirectionalPopup = popupToShow + currentVisibleDirectional = direction + val shown = popupOverlay.show( + anchor = currentAnchor, + preferredHost = resolvePreferredOverlayHost(), + popupView = popupToShow, + width = popupToShow.measuredWidth, + height = popupToShow.measuredHeight + ) - val location = getLocationRelativeToWindowAnchor(currentAnchor, windowAnchor) - val anchorX = location[0] - val anchorY = location[1] - val keyWidth = currentAnchor.width - val keyHeight = currentAnchor.height - val anchorCenterX = anchorX + keyWidth / 2 - val anchorCenterY = anchorY + keyHeight / 2 - - val popupWidth = popupToShow.width - val popupHeight = popupToShow.height - - val x: Int - val y: Int - - when (direction) { - FlickDirection.TAP -> { - x = anchorCenterX - popupWidth / 2 - y = anchorCenterY - popupHeight / 2 - } - - FlickDirection.UP -> { - x = anchorCenterX - popupWidth / 2 - y = anchorCenterY - popupHeight - } - - FlickDirection.DOWN -> { - x = anchorCenterX - popupWidth / 2 - y = anchorCenterY - } - - FlickDirection.UP_LEFT_FAR, FlickDirection.UP_LEFT -> { - x = anchorCenterX - popupWidth - y = anchorCenterY - popupHeight / 2 - } - - FlickDirection.UP_RIGHT_FAR, FlickDirection.UP_RIGHT -> { - x = anchorCenterX - y = anchorCenterY - popupHeight / 2 - } - } - - val shown = runCatching { - popupToShow.showAtLocation(windowAnchor, Gravity.NO_GRAVITY, x, y) - true - }.getOrDefault(false) - - if (shown) { - currentVisibleDirectionalPopup = popupToShow - currentVisibleDirectional = direction + if (!shown) { + currentVisibleDirectionalPopup = null + currentVisibleDirectional = null } } // TEXT モードの長押し発動時にグリッドポップアップを表示する。既に表示中なら位置を更新する。 private fun showGridPopup() { val currentAnchor = anchorView ?: return - val windowAnchor = resolveWindowAnchor(currentAnchor) - if (!isAnchorReady(currentAnchor, windowAnchor)) { - if (gridPopup.isShowing) { - gridPopup.dismiss() - } + if (!currentAnchor.isAttachedToWindow) { + popupOverlay.dismiss(gridPopupView) return } - val popupView = gridPopup.contentView as CrossFlickPopupView + val popupView = gridPopupView + popupView.setInputTextTransform(inputTextTransform) popupView.applyPopupViewStyle(crossPopupStyle) popupColorTheme?.let { popupView.setColors(it) } val scale = crossPopupStyle.sizeScalePercent.coerceIn(50, 200) / 100f @@ -770,29 +714,19 @@ class CrossFlickInputController( ) popupView.highlightDirection(currentDirection) - val location = getLocationRelativeToWindowAnchor(currentAnchor, windowAnchor) popupView.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) - - val x = location[0] + currentAnchor.width / 2 - popupView.measuredWidth / 2 - val y = location[1] + currentAnchor.height / 2 - popupView.measuredHeight / 2 - - gridPopup.width = WindowManager.LayoutParams.WRAP_CONTENT - gridPopup.height = WindowManager.LayoutParams.WRAP_CONTENT - - if (!gridPopup.isShowing) { - runCatching { - gridPopup.showAtLocation(windowAnchor, Gravity.NO_GRAVITY, x, y) - } - } else { - runCatching { - gridPopup.update(x, y, -1, -1) - } - } + popupOverlay.show( + anchor = currentAnchor, + preferredHost = resolvePreferredOverlayHost(), + popupView = popupView, + width = popupView.measuredWidth, + height = popupView.measuredHeight + ) } // グリッドポップアップ内の対応セルをハイライトする。長押し中の指移動で呼ばれる。 private fun highlightGrid(direction: FlickDirection) { - (gridPopup.contentView as? CrossFlickPopupView)?.highlightDirection(direction) + gridPopupView.highlightDirection(direction) } // グリッドポップアップに渡す表示文字マップを生成する。各方向で longPressTextMap を優先し、なければ textMap を使う。 @@ -822,40 +756,183 @@ class CrossFlickInputController( // TEXT モードのポップアップ(方向ポップアップとグリッドポップアップ)をすべて閉じる。 private fun dismissDirectionalPopups(clearCache: Boolean = false) { - currentVisibleDirectionalPopup?.dismiss() + currentVisibleDirectionalPopup?.let(popupOverlay::dismiss) currentVisibleDirectionalPopup = null currentVisibleDirectional = null - directionalPopupMap.values.forEach { if (it.isShowing) it.dismiss() } if (clearCache) { + directionalPopupMap.values.forEach(popupOverlay::dismiss) directionalPopupMap.clear() directionalPopupAnchorWidth = -1 directionalPopupAnchorHeight = -1 directionalPopupCacheDirty = true } - if (gridPopup.isShowing) { - gridPopup.dismiss() - } + popupOverlay.dismiss(gridPopupView) } // ACTION・TEXT 両モードのポップアップをすべて閉じる。 fun dismissAllPopups(clearDirectionalCache: Boolean = false) { dismissAllActionPopups() dismissDirectionalPopups(clearCache = clearDirectionalCache) + popupOverlay.dismissAll() } private fun invalidateDirectionalPopupCache() { dismissDirectionalPopups(clearCache = true) } - private fun resolveWindowAnchor(keyAnchor: View): View? { - return popupWindowAnchorProvider?.invoke() ?: keyAnchor + private fun resolvePreferredOverlayHost(): View? { + return popupOverlayHostProvider?.invoke() } - private fun isAnchorReady(keyAnchor: View, windowAnchor: View?): Boolean { - if (!keyAnchor.isAttachedToWindow) return false - if (windowAnchor == null) return false - if (!windowAnchor.isAttachedToWindow) return false - return windowAnchor.windowToken != null + private fun readPopupAnchorLocation(keyAnchor: View, overlayHost: View) { + getLocationRelativeToOverlayHost( + keyAnchor = keyAnchor, + overlayHost = overlayHost, + outLocation = popupAnchorLocation, + keyLocationScratch = popupKeyLocationScratch, + hostLocationScratch = popupHostLocationScratch + ) + } + + private fun resolveActionPopupPosition( + direction: FlickDirection, + anchor: View, + anchorX: Int, + anchorY: Int + ) { + popupPositionScratch[0] = when (direction) { + FlickDirection.UP_LEFT_FAR, FlickDirection.UP_LEFT -> anchorX - anchor.width + FlickDirection.UP_RIGHT_FAR, FlickDirection.UP_RIGHT -> anchorX + anchor.width + else -> anchorX + } + popupPositionScratch[1] = when (direction) { + FlickDirection.UP -> anchorY - anchor.height + FlickDirection.DOWN -> anchorY + anchor.height + else -> anchorY + } + } + + private fun resolveDirectionalPopupPosition( + direction: FlickDirection, + anchorX: Int, + anchorY: Int, + keyWidth: Int, + keyHeight: Int, + popupWidth: Int, + popupHeight: Int + ) { + val anchorCenterX = anchorX + keyWidth / 2 + val anchorCenterY = anchorY + keyHeight / 2 + when (direction) { + FlickDirection.TAP -> { + popupPositionScratch[0] = anchorCenterX - popupWidth / 2 + popupPositionScratch[1] = anchorCenterY - popupHeight / 2 + } + + FlickDirection.UP -> { + popupPositionScratch[0] = anchorCenterX - popupWidth / 2 + popupPositionScratch[1] = anchorCenterY - popupHeight + } + + FlickDirection.DOWN -> { + popupPositionScratch[0] = anchorCenterX - popupWidth / 2 + popupPositionScratch[1] = anchorCenterY + } + + FlickDirection.UP_LEFT_FAR, FlickDirection.UP_LEFT -> { + popupPositionScratch[0] = anchorCenterX - popupWidth + popupPositionScratch[1] = anchorCenterY - popupHeight / 2 + } + + FlickDirection.UP_RIGHT_FAR, FlickDirection.UP_RIGHT -> { + popupPositionScratch[0] = anchorCenterX + popupPositionScratch[1] = anchorCenterY - popupHeight / 2 + } + } + } + + private fun resolveCenteredPopupPosition( + anchorX: Int, + anchorY: Int, + keyWidth: Int, + keyHeight: Int, + popupWidth: Int, + popupHeight: Int + ) { + popupPositionScratch[0] = anchorX + keyWidth / 2 - popupWidth / 2 + popupPositionScratch[1] = anchorY + keyHeight / 2 - popupHeight / 2 + } + + private fun positionVisiblePopupsBeforeDraw() { + val anchor = anchorView ?: run { + popupOverlay.dismissAll() + return + } + val overlayHost = popupOverlay.currentHost ?: return + if (!anchor.isAttachedToWindow || !overlayHost.isAttachedToWindow) { + popupOverlay.dismissAll() + return + } + + readPopupAnchorLocation(anchor, overlayHost) + positionVisibleActionPopups(anchor) + positionVisibleDirectionalPopup(anchor) + positionGridPopup(anchor) + } + + private fun positionVisibleActionPopups(anchor: View) { + actionPopupViews.forEach { (direction, popupView) -> + if (!popupOverlay.isShowing(popupView)) return@forEach + resolveActionPopupPosition( + direction = direction, + anchor = anchor, + anchorX = popupAnchorLocation[0], + anchorY = popupAnchorLocation[1] + ) + popupOverlay.place( + popupView = popupView, + left = popupPositionScratch[0], + top = popupPositionScratch[1] + ) + } + } + + private fun positionVisibleDirectionalPopup(anchor: View) { + val popupView = currentVisibleDirectionalPopup ?: return + val direction = currentVisibleDirectional ?: return + if (!popupOverlay.isShowing(popupView)) return + + resolveDirectionalPopupPosition( + direction = direction, + anchorX = popupAnchorLocation[0], + anchorY = popupAnchorLocation[1], + keyWidth = anchor.width, + keyHeight = anchor.height, + popupWidth = popupView.measuredWidth, + popupHeight = popupView.measuredHeight + ) + popupOverlay.place( + popupView = popupView, + left = popupPositionScratch[0], + top = popupPositionScratch[1] + ) + } + + private fun positionGridPopup(anchor: View) { + if (!popupOverlay.isShowing(gridPopupView)) return + resolveCenteredPopupPosition( + anchorX = popupAnchorLocation[0], + anchorY = popupAnchorLocation[1], + keyWidth = anchor.width, + keyHeight = anchor.height, + popupWidth = gridPopupView.measuredWidth, + popupHeight = gridPopupView.measuredHeight + ) + popupOverlay.place( + popupView = gridPopupView, + left = popupPositionScratch[0], + top = popupPositionScratch[1] + ) } } diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CustomAngleFlickController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CustomAngleFlickController.kt index d4404d74c..96a8a22d3 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CustomAngleFlickController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CustomAngleFlickController.kt @@ -53,6 +53,8 @@ class CustomAngleFlickController( fun onFlick(direction: CircularFlickDirection, action: FlickAction) fun onStateChanged(view: View, newMap: Map) fun onFlickDirectionChanged(newDirection: CircularFlickDirection) + fun onSelectionChanged(action: FlickAction?, isFlick: Boolean) {} + fun onCanceled() {} } var listener: FlickListener? = null @@ -130,6 +132,10 @@ class CustomAngleFlickController( popupWindowAnchorProvider = provider } + fun setInputTextTransform(transform: (String) -> String) { + popupView.setInputTextTransform(transform) + } + private fun updateEffectiveRangesForMap( map: Map ) { @@ -144,6 +150,7 @@ class CustomAngleFlickController( } fun cancel() { + listener?.onCanceled() activeGestureConfig = null hidePopup() controllerScope.cancel() @@ -269,6 +276,10 @@ class CustomAngleFlickController( popupView.updateFlickDirection(currentDirection) previousDirection = currentDirection } + listener?.onSelectionChanged( + keyMaps.getOrNull(currentMapIndex)?.get(currentDirection), + currentDirection != CircularFlickDirection.TAP + ) return true } @@ -311,6 +322,7 @@ class CustomAngleFlickController( previousDirection = CircularFlickDirection.TAP hidePopup() activeGestureConfig = null + listener?.onCanceled() return true } } diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/DoubleTapActionDispatcher.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/DoubleTapActionDispatcher.kt new file mode 100644 index 000000000..45456eab1 --- /dev/null +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/DoubleTapActionDispatcher.kt @@ -0,0 +1,126 @@ +package com.kazumaproject.custom_keyboard.controller + +import com.kazumaproject.custom_keyboard.data.DoubleTapBinding +import com.kazumaproject.custom_keyboard.data.DoubleTapPolicy +import com.kazumaproject.custom_keyboard.data.KeyAction + +fun interface CancellableTask { + fun cancel() +} + +fun interface TapTaskScheduler { + fun schedule(delayMillis: Long, task: () -> Unit): CancellableTask +} + +/** + * Resolves committed taps into normal or double-tap actions. + * + * This class is intentionally independent from MotionEvent. FlickKeyboardView already owns + * gesture recognition for normal, flick and long-press keys; feeding only committed taps here + * prevents a long press or a cancelled gesture from being mistaken for a double tap. + */ +class DoubleTapActionDispatcher( + private val timeoutMillis: Long, + private val minimumIntervalMillis: Long, + private val clockMillis: () -> Long, + private val scheduler: TapTaskScheduler, + private val dispatch: (KeyAction) -> Unit +) { + private data class PendingTap( + val keyIdentity: String, + val normalAction: KeyAction, + val binding: DoubleTapBinding, + val startedAtMillis: Long, + val generation: Long, + val scheduledTask: CancellableTask + ) + + private var pending: PendingTap? = null + private var generation: Long = 0 + + fun onCommittedTap( + keyIdentity: String, + normalAction: KeyAction, + binding: DoubleTapBinding? + ) { + val now = clockMillis() + val previous = pending + val interval = previous?.let { now - it.startedAtMillis } + if ( + previous != null && + previous.keyIdentity == keyIdentity && + interval != null && + interval in 0 until minimumIntervalMillis + ) { + return + } + val isDoubleTap = previous != null && + previous.keyIdentity == keyIdentity && + previous.binding == binding && + interval != null && + interval in minimumIntervalMillis..timeoutMillis + + if (isDoubleTap) { + clearPending() + dispatch(requireNotNull(binding).action) + return + } + + flushPending() + if (binding == null) { + dispatch(normalAction) + return + } + + if (binding.policy == DoubleTapPolicy.PROMOTE) { + dispatch(normalAction) + } + + val thisGeneration = ++generation + val task = scheduler.schedule(timeoutMillis) { + val active = pending + if (active?.generation != thisGeneration) return@schedule + pending = null + if (active.binding.policy == DoubleTapPolicy.EXCLUSIVE) { + dispatch(active.normalAction) + } + } + pending = PendingTap( + keyIdentity = keyIdentity, + normalAction = normalAction, + binding = binding, + startedAtMillis = now, + generation = thisGeneration, + scheduledTask = task + ) + } + + /** + * Ends double-tap recognition because a different committed gesture is about to run. + * An exclusive normal tap is delivered before the interrupting action. + */ + fun interrupt() { + flushPending() + } + + /** + * Drops all pending work. Use when a keyboard is detached or replaced. + */ + fun cancel() { + clearPending() + } + + private fun flushPending() { + val active = pending ?: return + clearPending() + if (active.binding.policy == DoubleTapPolicy.EXCLUSIVE) { + dispatch(active.normalAction) + } + } + + private fun clearPending() { + pending?.scheduledTask?.cancel() + pending = null + generation++ + } +} diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/FlickLongPressInputController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/FlickLongPressInputController.kt index 1a6459b0c..255430603 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/FlickLongPressInputController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/FlickLongPressInputController.kt @@ -65,6 +65,7 @@ class FlickLongPressInputController( private var popupView: TfbiFlickPopupView? = null private var popupWindow: PopupWindow? = null private var popupWindowAnchorProvider: (() -> View?)? = null + private var inputTextTransform: (String) -> String = { it } private var popupBackgroundColor: Int? = null private var popupHighlightedColor: Int? = null @@ -101,6 +102,11 @@ class FlickLongPressInputController( popupWindowAnchorProvider = provider } + fun setInputTextTransform(transform: (String) -> String) { + inputTextTransform = transform + popupView?.setInputTextTransform(transform) + } + fun attach( view: View, normalMap: Map, @@ -261,6 +267,7 @@ class FlickLongPressInputController( private fun showPopup(anchorView: View) { popupWindow?.dismiss() popupView = TfbiFlickPopupView(context).apply { + setInputTextTransform(inputTextTransform) if (popupBackgroundColor != null && popupHighlightedColor != null && popupTextColor != null) { setColors(popupBackgroundColor!!, popupHighlightedColor!!, popupTextColor!!) } diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/KeyboardPopupOverlay.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/KeyboardPopupOverlay.kt new file mode 100644 index 000000000..c624c2798 --- /dev/null +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/KeyboardPopupOverlay.kt @@ -0,0 +1,165 @@ +package com.kazumaproject.custom_keyboard.controller + +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver + +/** + * Draws keyboard popups in the same window and draw traversal as their key anchor. + * + * PopupWindow creates a child window whose relayout is committed independently from the IME + * window. That can expose an intermediate frame when the IME height changes. ViewGroupOverlay + * keeps popup drawing in the anchor's existing window, and this pre-draw callback places every + * visible popup after the current traversal has laid out the keyboard. + */ +internal class KeyboardPopupOverlay( + private val positionVisiblePopups: () -> Unit +) { + private val visibleViews = LinkedHashSet() + private var host: ViewGroup? = null + private var observedViewTree: ViewTreeObserver? = null + + private val preDrawListener = ViewTreeObserver.OnPreDrawListener { + if (visibleViews.isEmpty()) { + stopObserving() + } else { + positionVisiblePopups() + } + true + } + + val currentHost: ViewGroup? + get() = host + + fun show( + anchor: View, + preferredHost: View?, + popupView: View, + width: Int, + height: Int + ): Boolean { + if (!anchor.isAttachedToWindow) return false + val resolvedHost = resolveHost(anchor, preferredHost) ?: return false + if (!resolvedHost.isAttachedToWindow) return false + + if (host !== resolvedHost) { + removeAllViews() + host = resolvedHost + } + + measureExactly(popupView, width, height) + if (visibleViews.add(popupView)) { + resolvedHost.overlay.add(popupView) + } + startObserving(resolvedHost) + + // Place before the first invalidation as well as immediately before each later draw. + positionVisiblePopups() + resolvedHost.invalidate() + return true + } + + fun isShowing(popupView: View): Boolean = popupView in visibleViews + + fun place( + popupView: View, + left: Int, + top: Int, + width: Int = popupView.measuredWidth, + height: Int = popupView.measuredHeight + ) { + if (popupView !in visibleViews) return + val resolvedWidth = width.coerceAtLeast(1) + val resolvedHeight = height.coerceAtLeast(1) + if ( + popupView.measuredWidth != resolvedWidth || + popupView.measuredHeight != resolvedHeight + ) { + measureExactly(popupView, resolvedWidth, resolvedHeight) + } + popupView.layout( + left, + top, + left + resolvedWidth, + top + resolvedHeight + ) + } + + fun dismiss(popupView: View) { + if (!visibleViews.remove(popupView)) return + host?.overlay?.remove(popupView) + if (visibleViews.isEmpty()) { + stopObserving() + host = null + } + } + + fun dismissAll() { + removeAllViews() + host = null + } + + private fun resolveHost(anchor: View, preferredHost: View?): ViewGroup? { + val anchorRoot = anchor.rootView + val preferredGroup = preferredHost as? ViewGroup + if (preferredGroup != null && preferredGroup.rootView === anchorRoot) { + return preferredGroup + } + return anchorRoot as? ViewGroup + ?: generateSequence(anchor.parent) { parent -> + (parent as? View)?.parent + }.filterIsInstance().lastOrNull() + } + + private fun startObserving(resolvedHost: ViewGroup) { + val observer = resolvedHost.viewTreeObserver + if (observedViewTree === observer && observer.isAlive) return + + stopObserving() + if (!observer.isAlive) return + observedViewTree = observer + observer.addOnPreDrawListener(preDrawListener) + } + + private fun stopObserving() { + observedViewTree?.let { observer -> + if (observer.isAlive) { + observer.removeOnPreDrawListener(preDrawListener) + } + } + observedViewTree = null + } + + private fun removeAllViews() { + val currentHost = host + if (currentHost != null) { + visibleViews.forEach(currentHost.overlay::remove) + } + visibleViews.clear() + stopObserving() + } + + private fun measureExactly(view: View, width: Int, height: Int) { + view.measure( + View.MeasureSpec.makeMeasureSpec(width.coerceAtLeast(1), View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(height.coerceAtLeast(1), View.MeasureSpec.EXACTLY) + ) + } +} + +internal fun getLocationRelativeToOverlayHost( + keyAnchor: View, + overlayHost: View, + outLocation: IntArray, + keyLocationScratch: IntArray, + hostLocationScratch: IntArray +) { + require(outLocation.size >= 2) + require(keyLocationScratch.size >= 2) + require(hostLocationScratch.size >= 2) + + keyAnchor.getLocationOnScreen(keyLocationScratch) + overlayHost.getLocationOnScreen(hostLocationScratch) + outLocation[0] = keyLocationScratch[0] - hostLocationScratch[0] + outLocation[1] = keyLocationScratch[1] - hostLocationScratch[1] +} diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/PopupWindowAnchorLocation.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/PopupWindowAnchorLocation.kt index cea60a0c0..527bdb728 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/PopupWindowAnchorLocation.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/PopupWindowAnchorLocation.kt @@ -23,3 +23,28 @@ internal fun getLocationRelativeToWindowAnchor( keyLocation[1] - windowLocation[1] ) } + +internal fun getLocationRelativeToWindowAnchor( + keyAnchor: View, + windowAnchor: View?, + outLocation: IntArray, + keyLocationScratch: IntArray, + windowLocationScratch: IntArray +) { + require(outLocation.size >= 2) + require(keyLocationScratch.size >= 2) + require(windowLocationScratch.size >= 2) + + val resolvedWindowAnchor = windowAnchor ?: keyAnchor + + if (keyAnchor === resolvedWindowAnchor) { + keyAnchor.getLocationInWindow(outLocation) + return + } + + keyAnchor.getLocationOnScreen(keyLocationScratch) + resolvedWindowAnchor.getLocationOnScreen(windowLocationScratch) + + outLocation[0] = keyLocationScratch[0] - windowLocationScratch[0] + outLocation[1] = keyLocationScratch[1] - windowLocationScratch[1] +} diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/StandardFlickInputController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/StandardFlickInputController.kt index f748557c6..14ab0fd2c 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/StandardFlickInputController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/StandardFlickInputController.kt @@ -41,6 +41,8 @@ class StandardFlickInputController( interface StandardFlickListener { fun onPress(character: String) fun onFlick(character: String) + fun onSelectionChanged(character: String?, isFlick: Boolean) {} + fun onCanceled() {} } var listener: StandardFlickListener? = null @@ -97,6 +99,10 @@ class StandardFlickInputController( popupWindowAnchorProvider = provider } + fun setInputTextTransform(transform: (String) -> String) { + popupView.setInputTextTransform(transform) + } + @SuppressLint("ClickableViewAccessibility") fun attach( button: View, @@ -139,6 +145,10 @@ class StandardFlickInputController( val direction = calculateDirection(dx, dy) segmentedDrawable?.highlightDirection = direction showPopup(direction) + listener?.onSelectionChanged( + characterMap[direction]?.takeIf(String::isNotEmpty), + direction != FlickDirection.TAP + ) return true } @@ -162,6 +172,7 @@ class StandardFlickInputController( dismissPopup() anchorView = null activeGestureConfig = null + listener?.onCanceled() return true } } @@ -240,6 +251,7 @@ class StandardFlickInputController( } fun cancel() { + listener?.onCanceled() activeGestureConfig = null dismissPopup() } diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt index 884949b11..786e218c2 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt @@ -55,6 +55,8 @@ class TfbiHierarchicalFlickController( interface TfbiListener { fun onPress(character: String) fun onFlick(character: String) + fun onSelectionChanged(character: String?, isFlick: Boolean) {} + fun onCanceled() {} /** * コントローラーの内部状態が変更されたことを通知します。 @@ -103,6 +105,7 @@ class TfbiHierarchicalFlickController( // ポップアップView関連 private var popupView: TfbiFlickPopupView? = null private var popupWindow: PopupWindow? = null + private var inputTextTransform: (String) -> String = { it } private var popupStyle = PopupViewStyle(100, 20f) private var popupWindowAnchorProvider: (() -> View?)? = null @@ -146,6 +149,11 @@ class TfbiHierarchicalFlickController( popupWindowAnchorProvider = provider } + fun setInputTextTransform(transform: (String) -> String) { + inputTextTransform = transform + popupView?.setInputTextTransform(transform) + } + /** * View と階層フリックのルートマップをアタッチします。 */ @@ -178,6 +186,7 @@ class TfbiHierarchicalFlickController( * コントローラーを View からデタッチし、リソースを解放します。 */ fun cancel() { + listener?.onCanceled() resetState() attachedView?.setOnTouchListener(null) attachedView = null @@ -191,7 +200,10 @@ class TfbiHierarchicalFlickController( MotionEvent.ACTION_DOWN -> handleTouchDown(event, view) MotionEvent.ACTION_MOVE -> handleTouchMove(event, view) MotionEvent.ACTION_UP -> handleTouchUp(event) - MotionEvent.ACTION_CANCEL -> resetState() + MotionEvent.ACTION_CANCEL -> { + listener?.onCanceled() + resetState() + } } return true } @@ -272,12 +284,14 @@ class TfbiHierarchicalFlickController( setupStageUI(currentMap!!) popupView?.highlightDirection(currentHighlight) + notifySelectionChanged() return // イベント処理終了 } } // --- 通常の "Sticky" 動作 --- popupView?.highlightDirection(currentHighlight) + notifySelectionChanged() return } @@ -345,6 +359,7 @@ class TfbiHierarchicalFlickController( Log.e(TAG, "Illegal state: StatefulKey found inside a flick map during Move.") } } + notifySelectionChanged() } private fun handleTouchUp(event: MotionEvent) { @@ -388,6 +403,23 @@ class TfbiHierarchicalFlickController( resetState() } + private fun notifySelectionChanged() { + listener?.onSelectionChanged( + resolveCurrentOutput(), + mapStack.size > 1 || currentHighlight != TfbiFlickDirection.TAP + ) + } + + private fun resolveCurrentOutput(): String? { + val map = currentMap ?: return null + return when (val node = map[currentHighlight]) { + is TfbiFlickNode.Input -> node.char + is TfbiFlickNode.SubMenu -> + (node.nextMap[TfbiFlickDirection.TAP] as? TfbiFlickNode.Input)?.char + else -> null + }?.takeIf(String::isNotEmpty) + } + private fun updateInternalState(newMode: KeyMode?, event: MotionEvent) { // 状態遷移のトリガーがなければ何もしない if (newMode == null) return @@ -573,6 +605,7 @@ class TfbiHierarchicalFlickController( } popupView = TfbiFlickPopupView(context).apply { + setInputTextTransform(inputTextTransform) // ▼▼▼ 修正: 色設定があれば適用 ▼▼▼ if (popupBackgroundColor != null && popupHighlightedColor != null && popupTextColor != null) { setColors(popupBackgroundColor!!, popupHighlightedColor!!, popupTextColor!!) diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt index 4a9ed693e..bdb8e0360 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt @@ -50,6 +50,12 @@ class TfbiInputController( fun onPress(first: TfbiFlickDirection, second: TfbiFlickDirection) fun onFlick(first: TfbiFlickDirection, second: TfbiFlickDirection) fun onLongPressFlick(first: TfbiFlickDirection, second: TfbiFlickDirection): Boolean = false + fun onSelectionChanged( + first: TfbiFlickDirection, + second: TfbiFlickDirection, + isLongPress: Boolean, + ) {} + fun onCanceled() {} } private enum class FlickState { NEUTRAL, FIRST_FLICK_DETERMINED } @@ -76,6 +82,7 @@ class TfbiInputController( private var popupView: TfbiFlickPopupView? = null private var popupWindow: PopupWindow? = null + private var inputTextTransform: (String) -> String = { it } private var popupWindowAnchorProvider: (() -> View?)? = null @@ -89,6 +96,11 @@ class TfbiInputController( isLongPressModeActive = true popupWindow?.dismiss() showPopup(view, TfbiFlickDirection.TAP, true) + listener?.onSelectionChanged( + TfbiFlickDirection.TAP, + TfbiFlickDirection.TAP, + true + ) } // ▼▼▼ 追加: 色設定保持用の変数 ▼▼▼ @@ -118,6 +130,11 @@ class TfbiInputController( popupWindowAnchorProvider = provider } + fun setInputTextTransform(transform: (String) -> String) { + inputTextTransform = transform + popupView?.setInputTextTransform(transform) + } + fun attach( view: View, provider: (TfbiFlickDirection, TfbiFlickDirection) -> String, @@ -131,6 +148,7 @@ class TfbiInputController( } fun cancel() { + listener?.onCanceled() activeGestureConfig = null clearLongPressCallback(attachedView) isTouchActive = false @@ -161,6 +179,7 @@ class TfbiInputController( clearLongPressCallback(attachedView) resetState() activeGestureConfig = null + listener?.onCanceled() } } return true @@ -214,6 +233,11 @@ class TfbiInputController( } resetState() showPopup(view, TfbiFlickDirection.TAP, false) + listener?.onSelectionChanged( + TfbiFlickDirection.TAP, + TfbiFlickDirection.TAP, + false + ) return } val dx = event.x - intermediateTouchX @@ -232,6 +256,11 @@ class TfbiInputController( currentSecondFlickDirection = highlightTargetDirection } } + listener?.onSelectionChanged( + firstFlickDirection, + currentSecondFlickDirection, + isLongPressModeActive + ) } private fun handleTouchUp(event: MotionEvent) { @@ -311,6 +340,7 @@ class TfbiInputController( } popupView = TfbiFlickPopupView(context).apply { + setInputTextTransform(inputTextTransform) // ▼▼▼ 修正: 色設定があれば適用 ▼▼▼ if (popupBackgroundColor != null && popupHighlightedColor != null && popupTextColor != null) { setColors(popupBackgroundColor!!, popupHighlightedColor!!, popupTextColor!!) diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt index c983aed5b..3eda5618b 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt @@ -71,6 +71,7 @@ class TfbiStickyFlickController( private var popupView: TfbiFlickPopupView? = null private var popupWindow: PopupWindow? = null private var popupStyle = PopupViewStyle(100, 20f) + private var inputTextTransform: (String) -> String = { it } private var popupWindowAnchorProvider: (() -> View?)? = null @@ -96,6 +97,11 @@ class TfbiStickyFlickController( popupWindowAnchorProvider = provider } + fun setInputTextTransform(transform: (String) -> String) { + inputTextTransform = transform + popupView?.setInputTextTransform(transform) + } + fun applyPopupViewStyle(style: PopupViewStyle) { popupStyle = PopupViewStyle( sizeScalePercent = style.sizeScalePercent.coerceIn(50, 200), @@ -275,6 +281,7 @@ class TfbiStickyFlickController( } popupView = TfbiFlickPopupView(context).apply { + setInputTextTransform(inputTextTransform) applyPopupViewStyle(popupStyle) setCharacters(tapCharacter, petalChars) highlightDirection(TfbiFlickDirection.TAP) diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KeyCharacterCase.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KeyCharacterCase.kt new file mode 100644 index 000000000..45edec52a --- /dev/null +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KeyCharacterCase.kt @@ -0,0 +1,25 @@ +package com.kazumaproject.custom_keyboard.data + +/** + * Presentation case for text-producing custom keyboard keys. + * + * [AS_DEFINED] preserves the canonical label and input maps exactly as stored. [UPPERCASE] + * changes only ASCII letters so the visual representation stays consistent with the IME's + * Shift/CapsLock input semantics without introducing locale-dependent transformations. + */ +enum class KeyCharacterCase { + AS_DEFINED, + UPPERCASE; + + fun transformAsciiLetters(text: String): String { + if (this == AS_DEFINED || text.none { it in 'a'..'z' }) return text + val characters = text.toCharArray() + characters.indices.forEach { index -> + val char = characters[index] + if (char in 'a'..'z') { + characters[index] = char.uppercaseChar() + } + } + return characters.concatToString() + } +} diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KeyGestureBinding.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KeyGestureBinding.kt new file mode 100644 index 000000000..6dfd7ac82 --- /dev/null +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KeyGestureBinding.kt @@ -0,0 +1,60 @@ +package com.kazumaproject.custom_keyboard.data + +/** + * Internal strategy for resolving a special key's first tap. + * + * This remains serialized for backup compatibility, but it is deliberately not user-configurable: + * Shift uses [PROMOTE], while every other supported shortcut uses [EXCLUSIVE]. + */ +enum class DoubleTapPolicy(val serializedName: String) { + PROMOTE("PROMOTE"), + EXCLUSIVE("EXCLUSIVE"); + + companion object { + fun fromSerializedName(value: String?): DoubleTapPolicy? = + entries.firstOrNull { it.serializedName == value } + } +} + +data class DoubleTapBinding( + val action: KeyAction, + val policy: DoubleTapPolicy = DoubleTapPolicy.EXCLUSIVE +) + +/** + * Normal character keys must dispatch repeated taps as repeated input, never as a hidden gesture. + * Special keys are the only keys that may own a double-tap shortcut. + */ +val KeyData.supportsDoubleTap: Boolean + get() = isSpecialKey + +fun automaticDoubleTapPolicy( + normalAction: KeyAction?, + doubleTapAction: KeyAction +): DoubleTapPolicy = + if ( + normalAction == KeyAction.ShiftKey && + doubleTapAction == KeyAction.CapLockKey + ) { + DoubleTapPolicy.PROMOTE + } else { + DoubleTapPolicy.EXCLUSIVE + } + +/** + * Resolves persisted bindings through the current capability and policy rules. + * + * Runtime callers use the action that was actually committed so old or imported policy values + * cannot re-enable immediate execution for non-Shift shortcuts. + */ +fun KeyData.effectiveDoubleTapBinding(normalAction: KeyAction?): DoubleTapBinding? = + doubleTapBinding + ?.takeIf { supportsDoubleTap } + ?.let { binding -> + binding.copy( + policy = automaticDoubleTapPolicy( + normalAction = normalAction, + doubleTapAction = binding.action + ) + ) + } diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KeyModels.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KeyModels.kt index 698408b9a..3f0b8666f 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KeyModels.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KeyModels.kt @@ -135,7 +135,8 @@ data class KeyData( val isHiLighted: Boolean = false, val keyId: String? = null, val keyType: KeyType = if (isFlickable) KeyType.CIRCULAR_FLICK else KeyType.NORMAL, - val specialKeyColorStyle: SpecialKeyColorStyle = SpecialKeyColorStyle.SPECIAL + val specialKeyColorStyle: SpecialKeyColorStyle = SpecialKeyColorStyle.SPECIAL, + val doubleTapBinding: DoubleTapBinding? = null ) data class GridPlacement( diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/layout/KeyboardDefaultLayouts.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/layout/KeyboardDefaultLayouts.kt index 4f7924e85..73fe992c5 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/layout/KeyboardDefaultLayouts.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/layout/KeyboardDefaultLayouts.kt @@ -2,6 +2,8 @@ package com.kazumaproject.custom_keyboard.layout import com.kazumaproject.custom_keyboard.data.FlickAction import com.kazumaproject.custom_keyboard.data.FlickDirection +import com.kazumaproject.custom_keyboard.data.DoubleTapBinding +import com.kazumaproject.custom_keyboard.data.DoubleTapPolicy import com.kazumaproject.custom_keyboard.data.GridPlacement import com.kazumaproject.custom_keyboard.data.KeyAction import com.kazumaproject.custom_keyboard.data.KeyData @@ -4712,6 +4714,7 @@ object KeyboardDefaultLayouts { val label: String = "", val action: KeyAction, val drawableResId: Int? = null, + val doubleTapBinding: DoubleTapBinding? = null, override val columnSpanUnits: Int = 2 ) : TemplateItemSpec @@ -4785,7 +4788,8 @@ object KeyboardDefaultLayouts { keyType = KeyType.NORMAL, isSpecialKey = true, drawableResId = itemSpec.drawableResId, - keyId = keyId + keyId = keyId, + doubleTapBinding = itemSpec.doubleTapBinding ) items += KeyItem(id = keyId, keyData = keyData, placement = placement) keys += keyData @@ -4823,6 +4827,10 @@ object KeyboardDefaultLayouts { idSuffix = "shift", action = KeyAction.ShiftKey, drawableResId = com.kazumaproject.core.R.drawable.shift_24px, + doubleTapBinding = DoubleTapBinding( + action = KeyAction.CapLockKey, + policy = DoubleTapPolicy.PROMOTE + ), columnSpanUnits = 2 ) diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/CrossFlickPopupView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/CrossFlickPopupView.kt index 26a90e572..854d6cafb 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/CrossFlickPopupView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/CrossFlickPopupView.kt @@ -23,14 +23,16 @@ import com.google.android.material.R as materialR private data class PopupCellContent( val text: String?, val label: String?, - val drawableResId: Int? + val drawableResId: Int?, + val isInputText: Boolean ) private fun FlickAction.toPopupCellContent(): PopupCellContent = when (this) { is FlickAction.Input -> PopupCellContent( text = char.takeUnless { it.isEmpty() }, label = label?.takeUnless { it.isEmpty() }, - drawableResId = drawableResId + drawableResId = drawableResId, + isInputText = true ) is FlickAction.Action -> { @@ -43,7 +45,8 @@ private fun FlickAction.toPopupCellContent(): PopupCellContent = when (this) { PopupCellContent( text = text, label = label?.takeUnless { it.isEmpty() }, - drawableResId = drawableResId + drawableResId = drawableResId, + isInputText = action is KeyAction.Text ) } } @@ -71,7 +74,7 @@ class CrossFlickPopupView(context: Context) : FrameLayout(context) { background = backgroundShape } - fun setContent(action: FlickAction) { + fun setContent(action: FlickAction, inputTextTransform: (String) -> String) { val content = action.toPopupCellContent() if (content.drawableResId != null) { imageView.setImageResource(content.drawableResId) @@ -80,7 +83,12 @@ class CrossFlickPopupView(context: Context) : FrameLayout(context) { return } - val text = content.label ?: content.text + val resolvedText = content.label ?: content.text + val text = if (content.isInputText && resolvedText != null) { + inputTextTransform(resolvedText) + } else { + resolvedText + } if (text.isNullOrEmpty()) { textView.visibility = View.GONE imageView.visibility = View.GONE @@ -151,6 +159,7 @@ class CrossFlickPopupView(context: Context) : FrameLayout(context) { private var popupTextSizeSp: Float = 18f private var popupBackgroundColor: Int? = null private var popupTextColor: Int? = null + private var inputTextTransform: (String) -> String = { it } init { addView(gridLayout, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) @@ -170,6 +179,10 @@ class CrossFlickPopupView(context: Context) : FrameLayout(context) { invalidate() } + fun setInputTextTransform(transform: (String) -> String) { + inputTextTransform = transform + } + fun setCells(map: Map, keyWidth: Int, keyHeight: Int) { gridLayout.removeAllViews() cells.clear() @@ -188,7 +201,7 @@ class CrossFlickPopupView(context: Context) : FrameLayout(context) { } val cell = CellView(context).apply { - setContent(action) + setContent(action, inputTextTransform) applyTextSize(popupTextSizeSp) val theme = colorTheme if (theme != null) { @@ -239,7 +252,7 @@ class CrossFlickPopupView(context: Context) : FrameLayout(context) { if (action != null) { val cell = CellView(context).apply { - setContent(action) + setContent(action, inputTextTransform) applyTextSize(popupTextSizeSp) val theme = colorTheme if (theme != null) { diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/CustomAngleFlickPopupView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/CustomAngleFlickPopupView.kt index cb8868446..4eebc9fbb 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/CustomAngleFlickPopupView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/CustomAngleFlickPopupView.kt @@ -70,6 +70,7 @@ class CustomAngleFlickPopupView @JvmOverloads constructor( private var mapSwitchDirection: CircularFlickDirection? = null private var showMapSwitchLabel = false private var mapSwitchLabel: String? = null + private var inputTextTransform: (String) -> String = { it } // --- Size Properties --- // デフォルト値を設定(後で setUiSize で上書き可能) @@ -154,6 +155,11 @@ class CustomAngleFlickPopupView @JvmOverloads constructor( invalidate() } + fun setInputTextTransform(transform: (String) -> String) { + inputTextTransform = transform + invalidate() + } + // --- Drawing Logic --- // (onMeasure, onSizeChanged, onDraw, updateShaders などは既存のままでOK) // ※ createPathForDirection 内で centerCircleRadius が使われますが、 @@ -213,14 +219,17 @@ class CustomAngleFlickPopupView @JvmOverloads constructor( private fun FlickAction?.toDisplayLabel(): String { return when (this) { - is FlickAction.Input -> label ?: char - is FlickAction.Action -> label ?: when (action) { - KeyAction.ShowEmojiKeyboard -> "絵" - KeyAction.SwitchToNextIme -> "IME" - KeyAction.SwitchToKanaLayout -> "かな" - KeyAction.SwitchToEnglishLayout -> "英" - KeyAction.SwitchToNumberLayout -> "数" - else -> "" + is FlickAction.Input -> inputTextTransform(label ?: char) + is FlickAction.Action -> when (val keyAction = action) { + is KeyAction.Text -> inputTextTransform(label ?: keyAction.text) + else -> label ?: when (keyAction) { + KeyAction.ShowEmojiKeyboard -> "絵" + KeyAction.SwitchToNextIme -> "IME" + KeyAction.SwitchToKanaLayout -> "かな" + KeyAction.SwitchToEnglishLayout -> "英" + KeyAction.SwitchToNumberLayout -> "数" + else -> "" + } } null -> "" diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/DirectionalKeyPopupView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/DirectionalKeyPopupView.kt index c17e48c23..1cf3151d6 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/DirectionalKeyPopupView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/DirectionalKeyPopupView.kt @@ -10,10 +10,10 @@ import android.graphics.RectF import android.util.TypedValue import androidx.appcompat.widget.AppCompatTextView import androidx.core.graphics.toColorInt -import androidx.core.graphics.withRotation import com.kazumaproject.core.data.popup.PopupViewStyle import com.kazumaproject.custom_keyboard.data.FlickDirection import com.kazumaproject.custom_keyboard.data.FlickPopupColorTheme +import kotlin.math.min /** * 通常フリック時に表示される、方向を示す矢印型のポップアップ @@ -29,11 +29,11 @@ class DirectionalKeyPopupView(context: Context) : AppCompatTextView(context) { // ▼▼▼ 追加: 枠線描画用のPaintオブジェクト ▼▼▼ private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE + strokeJoin = Paint.Join.ROUND } private val backgroundPath = Path() private val textBounds = Rect() - private var viewRotation = 0f private var currentDirection: FlickDirection = FlickDirection.TAP // カラーテーマから受け取る色のプレースホルダー @@ -66,13 +66,6 @@ class DirectionalKeyPopupView(context: Context) : AppCompatTextView(context) { fun setFlickDirection(direction: FlickDirection) { this.currentDirection = direction - this.viewRotation = when (direction) { - FlickDirection.UP -> 90f - FlickDirection.DOWN -> -90f - FlickDirection.UP_LEFT_FAR -> 0f - FlickDirection.UP_RIGHT_FAR -> 180f - else -> 0f - } invalidate() } @@ -100,15 +93,16 @@ class DirectionalKeyPopupView(context: Context) : AppCompatTextView(context) { // 枠線用のPaintに色を設定 strokePaint.color = this.separatorColor - updatePath(w, h) + val strokeInset = strokePaint.strokeWidth / 2f + val pathWidth = (w - strokePaint.strokeWidth).coerceAtLeast(0f) + val pathHeight = (h - strokePaint.strokeWidth).coerceAtLeast(0f) + updatePath(pathWidth, pathHeight) - // 背景と枠線の両方が回転されるように、withRotationブロック内で描画 - canvas.withRotation(viewRotation, w / 2f, h / 2f) { - // 1. 背景(塗りつぶし)を描画 - drawPath(backgroundPath, backgroundPaint) - // 2. 枠線を描画 - drawPath(backgroundPath, strokePaint) - } + val saveCount = canvas.save() + canvas.translate(strokeInset, strokeInset) + canvas.drawPath(backgroundPath, backgroundPaint) + canvas.drawPath(backgroundPath, strokePaint) + canvas.restoreToCount(saveCount) val textToDraw = this.text.toString() val textPaint = this.paint @@ -129,34 +123,124 @@ class DirectionalKeyPopupView(context: Context) : AppCompatTextView(context) { private fun updatePath(w: Float, h: Float) { backgroundPath.reset() - val cornerRadius = 24f - val pointerHeight = 35f - val showPointer = currentDirection != FlickDirection.TAP - - if (showPointer) { - val rectWidth = w - pointerHeight + val cornerRadius = min(24f, min(w, h) / 2f) + val pointerLength = min(35f, min(w, h)) - backgroundPath.moveTo(cornerRadius, 0f) - backgroundPath.lineTo(rectWidth, 0f) - backgroundPath.lineTo(w, h / 2f) - backgroundPath.lineTo(rectWidth, h) - backgroundPath.lineTo(cornerRadius, h) - backgroundPath.quadTo(0f, h, 0f, h - cornerRadius) - backgroundPath.lineTo(0f, cornerRadius) - backgroundPath.quadTo(0f, 0f, cornerRadius, 0f) - - } else { - backgroundPath.addRoundRect( + when (currentDirection) { + FlickDirection.TAP -> backgroundPath.addRoundRect( RectF(0f, 0f, w, h), cornerRadius, cornerRadius, Path.Direction.CW ) + + FlickDirection.UP -> updateDownPointingPath( + w = w, + h = h, + cornerRadius = cornerRadius, + pointerLength = pointerLength + ) + + FlickDirection.DOWN -> updateUpPointingPath( + w = w, + h = h, + cornerRadius = cornerRadius, + pointerLength = pointerLength + ) + + FlickDirection.UP_LEFT_FAR, + FlickDirection.UP_LEFT -> updateRightPointingPath( + w = w, + h = h, + cornerRadius = cornerRadius, + pointerLength = pointerLength + ) + + FlickDirection.UP_RIGHT_FAR, + FlickDirection.UP_RIGHT -> updateLeftPointingPath( + w = w, + h = h, + cornerRadius = cornerRadius, + pointerLength = pointerLength + ) } backgroundPath.close() } + private fun updateRightPointingPath( + w: Float, + h: Float, + cornerRadius: Float, + pointerLength: Float + ) { + val bodyRight = w - pointerLength + val radius = min(cornerRadius, min(bodyRight, h) / 2f) + backgroundPath.moveTo(radius, 0f) + backgroundPath.lineTo(bodyRight, 0f) + backgroundPath.lineTo(w, h / 2f) + backgroundPath.lineTo(bodyRight, h) + backgroundPath.lineTo(radius, h) + backgroundPath.quadTo(0f, h, 0f, h - radius) + backgroundPath.lineTo(0f, radius) + backgroundPath.quadTo(0f, 0f, radius, 0f) + } + + private fun updateLeftPointingPath( + w: Float, + h: Float, + cornerRadius: Float, + pointerLength: Float + ) { + val bodyLeft = pointerLength + val bodyWidth = w - bodyLeft + val radius = min(cornerRadius, min(bodyWidth, h) / 2f) + backgroundPath.moveTo(w - radius, 0f) + backgroundPath.lineTo(bodyLeft, 0f) + backgroundPath.lineTo(0f, h / 2f) + backgroundPath.lineTo(bodyLeft, h) + backgroundPath.lineTo(w - radius, h) + backgroundPath.quadTo(w, h, w, h - radius) + backgroundPath.lineTo(w, radius) + backgroundPath.quadTo(w, 0f, w - radius, 0f) + } + + private fun updateDownPointingPath( + w: Float, + h: Float, + cornerRadius: Float, + pointerLength: Float + ) { + val bodyBottom = h - pointerLength + val radius = min(cornerRadius, min(w, bodyBottom) / 2f) + backgroundPath.moveTo(radius, 0f) + backgroundPath.lineTo(w - radius, 0f) + backgroundPath.quadTo(w, 0f, w, radius) + backgroundPath.lineTo(w, bodyBottom) + backgroundPath.lineTo(w / 2f, h) + backgroundPath.lineTo(0f, bodyBottom) + backgroundPath.lineTo(0f, radius) + backgroundPath.quadTo(0f, 0f, radius, 0f) + } + + private fun updateUpPointingPath( + w: Float, + h: Float, + cornerRadius: Float, + pointerLength: Float + ) { + val bodyTop = pointerLength + val bodyHeight = h - bodyTop + val radius = min(cornerRadius, min(w, bodyHeight) / 2f) + backgroundPath.moveTo(w / 2f, 0f) + backgroundPath.lineTo(w, bodyTop) + backgroundPath.lineTo(w, h - radius) + backgroundPath.quadTo(w, h, w - radius, h) + backgroundPath.lineTo(radius, h) + backgroundPath.quadTo(0f, h, 0f, h - radius) + backgroundPath.lineTo(0f, bodyTop) + } + // ▼▼▼ 追加: dpをピクセルに変換するヘルパー関数 ▼▼▼ private fun dpToPx(dp: Float): Float { return TypedValue.applyDimension( 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 822518b96..094ceb49a 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 @@ -46,8 +46,11 @@ import com.kazumaproject.core.domain.flick.RuntimeGestureSettings import com.kazumaproject.core.domain.flick.RuntimeGestureSettingsSource import com.kazumaproject.custom_keyboard.controller.CrossFlickInputController import com.kazumaproject.custom_keyboard.controller.CustomAngleFlickController +import com.kazumaproject.custom_keyboard.controller.CancellableTask +import com.kazumaproject.custom_keyboard.controller.DoubleTapActionDispatcher import com.kazumaproject.custom_keyboard.controller.FlickLongPressInputController import com.kazumaproject.custom_keyboard.controller.StandardFlickInputController +import com.kazumaproject.custom_keyboard.controller.TapTaskScheduler import com.kazumaproject.custom_keyboard.controller.TapLongPressInputController import com.kazumaproject.custom_keyboard.controller.TfbiHierarchicalFlickController import com.kazumaproject.custom_keyboard.controller.TfbiStickyFlickController @@ -57,6 +60,7 @@ import com.kazumaproject.custom_keyboard.data.FlickDirection import com.kazumaproject.custom_keyboard.data.FlickPopupColorTheme import com.kazumaproject.custom_keyboard.data.GridPlacement import com.kazumaproject.custom_keyboard.data.KeyAction +import com.kazumaproject.custom_keyboard.data.KeyCharacterCase import com.kazumaproject.custom_keyboard.data.KeyIconResolver import com.kazumaproject.custom_keyboard.data.KeyActionMapper import com.kazumaproject.custom_keyboard.data.KeyData @@ -73,11 +77,16 @@ import com.kazumaproject.custom_keyboard.data.buildSumireSpecialKeyDisplayAction import com.kazumaproject.custom_keyboard.data.buildEvenCircularRanges import com.kazumaproject.custom_keyboard.data.dispatchResolvedSumireSpecialKeyAction import com.kazumaproject.custom_keyboard.data.dispatchSumireSpecialKeyRuntimeAction +import com.kazumaproject.custom_keyboard.data.effectiveDoubleTapBinding import com.kazumaproject.custom_keyboard.data.refreshSumireSpecialKeyTap import com.kazumaproject.custom_keyboard.data.toCircularFlickKeyMaps import com.kazumaproject.custom_keyboard.data.toLegacyFlickDirection import com.kazumaproject.custom_keyboard.data.toSumireSpecialKeyDirectionOrNull import com.kazumaproject.custom_keyboard.layout.SegmentedBackgroundDrawable +import com.kazumaproject.core.domain.flick.FlickTextPreviewEmitter +import com.kazumaproject.core.domain.flick.FlickTextPreviewListener +import com.kazumaproject.core.domain.flick.FlickTextSelection +import java.util.IdentityHashMap import kotlin.math.abs import kotlin.math.roundToInt @@ -100,9 +109,12 @@ class FlickKeyboardView @JvmOverloads constructor( private const val SPECIAL_KEY_BASE_TEXT_SIZE_SP = 16f private const val SPECIAL_ICON_TO_TEXT_RATIO = 1.6f private const val INPUT_MODE_SWITCH_ICON_SIZE_MULTIPLIER = 1.65f + private const val DOUBLE_TAP_MIN_INTERVAL_MILLIS = 40L } private var listener: OnKeyboardActionListener? = null + private val flickTextPreviewEmitter = FlickTextPreviewEmitter() + private var previewKeyData: KeyData? = null private val flickControllers = mutableListOf() private val crossFlickControllers = mutableListOf() private val standardFlickControllers = mutableListOf() @@ -111,6 +123,17 @@ class FlickKeyboardView @JvmOverloads constructor( private val stickyTfbiControllers = mutableListOf() private val hierarchicalTfbiControllers = mutableListOf() private val tapLongPressControllers = mutableListOf() + private val doubleTapActionDispatcher = DoubleTapActionDispatcher( + timeoutMillis = ViewConfiguration.getDoubleTapTimeout().toLong(), + minimumIntervalMillis = DOUBLE_TAP_MIN_INTERVAL_MILLIS, + clockMillis = SystemClock::uptimeMillis, + scheduler = TapTaskScheduler { delayMillis, task -> + val runnable = Runnable(task) + postDelayed(runnable, delayMillis) + CancellableTask { removeCallbacks(runnable) } + }, + dispatch = { action -> listener?.onAction(action, false) } + ) private var popupWindowAnchorProvider: (() -> View?)? = null @@ -153,8 +176,14 @@ class FlickKeyboardView @JvmOverloads constructor( private var liquidGlassEnable: Boolean = false + private val keyInfos = mutableListOf() private val dynamicKeyMap = mutableMapOf() + private val canonicalGuideLabels = + IdentityHashMap() private var currentLayout: KeyboardLayout? = null + private var keyboardRenderRevision: Int = 0 + private var renderedKeyboardRenderRevision: Int = -1 + private var keyCharacterCase: KeyCharacterCase = KeyCharacterCase.AS_DEFINED private var sumireSpecialKeyActionResolver: ((String, String, KeyData, SumireSpecialKeyDirection) -> ResolvedSumireSpecialKeyAction)? = null @@ -214,6 +243,21 @@ class FlickKeyboardView @JvmOverloads constructor( this.listener = listener } + fun setOnFlickTextPreviewListener(listener: FlickTextPreviewListener?) { + flickTextPreviewEmitter.listener = listener + } + + /** + * Updates text-producing key labels without rebuilding the keyboard or changing canonical + * [KeyData]. This preserves active gesture recognizers, including the second Shift tap. + */ + fun setKeyCharacterCase(characterCase: KeyCharacterCase) { + if (keyCharacterCase == characterCase) return + keyCharacterCase = characterCase + keyInfos.forEach(::refreshKeyTextPresentation) + refreshPopupTextTransformers() + } + fun setSumireSpecialKeyActionResolver( resolver: ((String, String, KeyData, SumireSpecialKeyDirection) -> ResolvedSumireSpecialKeyAction)?, layoutType: String?, @@ -233,7 +277,7 @@ class FlickKeyboardView @JvmOverloads constructor( fun setPopupWindowAnchorProvider(provider: (() -> View?)?) { popupWindowAnchorProvider = provider flickControllers.forEach { it.setPopupWindowAnchorProvider(provider) } - crossFlickControllers.forEach { it.setPopupWindowAnchorProvider(provider) } + crossFlickControllers.forEach { it.setPopupOverlayHostProvider(provider) } standardFlickControllers.forEach { it.setPopupWindowAnchorProvider(provider) } tfbiControllers.forEach { it.setPopupWindowAnchorProvider(provider) } flickLongPressControllers.forEach { it.setPopupWindowAnchorProvider(provider) } @@ -323,21 +367,21 @@ class FlickKeyboardView @JvmOverloads constructor( } flickGuideEnabled = enabled flickGuideAllowsMultiCharacterLabels = allowMultiCharacterLabels - currentLayout?.let { setKeyboard(it) } + rebuildCurrentKeyboard() } fun setFlickGuideTextSizeSp(sizeSp: Float) { val coerced = sizeSp.coerceIn(6f, 16f) if (flickGuideTextSizeSp == coerced) return flickGuideTextSizeSp = coerced - currentLayout?.let { setKeyboard(it) } + rebuildCurrentKeyboard() } fun setFlickGuideMaxCodePoints(maxCodePoints: Int) { val coerced = maxCodePoints.coerceIn(1, 4) if (flickGuideMaxCodePoints == coerced) return flickGuideMaxCodePoints = coerced - currentLayout?.let { setKeyboard(it) } + rebuildCurrentKeyboard() } fun applyKeySizing( @@ -347,13 +391,27 @@ class FlickKeyboardView @JvmOverloads constructor( textSizeSp: Float, specialKeyTextSizeSp: Float ) { - this.keyWidthScalePercent = keyWidthScalePercent.coerceIn(0, 200) - this.keyHeightScalePercent = keyHeightScalePercent.coerceIn(0, 200) - this.iconScalePercent = iconScalePercent.coerceIn(40, 200) - this.defaultTextSize = textSizeSp.coerceIn(8f, 32f) - this.specialKeyTextSizeSp = specialKeyTextSizeSp.coerceIn(8f, 32f) + val normalizedWidthScale = keyWidthScalePercent.coerceIn(0, 200) + val normalizedHeightScale = keyHeightScalePercent.coerceIn(0, 200) + val normalizedIconScale = iconScalePercent.coerceIn(40, 200) + val normalizedTextSize = textSizeSp.coerceIn(8f, 32f) + val normalizedSpecialTextSize = specialKeyTextSizeSp.coerceIn(8f, 32f) + if ( + this.keyWidthScalePercent == normalizedWidthScale && + this.keyHeightScalePercent == normalizedHeightScale && + this.iconScalePercent == normalizedIconScale && + this.defaultTextSize == normalizedTextSize && + this.specialKeyTextSizeSp == normalizedSpecialTextSize + ) { + return + } + this.keyWidthScalePercent = normalizedWidthScale + this.keyHeightScalePercent = normalizedHeightScale + this.iconScalePercent = normalizedIconScale + this.defaultTextSize = normalizedTextSize + this.specialKeyTextSizeSp = normalizedSpecialTextSize - currentLayout?.let { setKeyboard(it) } + rebuildCurrentKeyboard() } fun setCursorMode(enabled: Boolean) { @@ -401,6 +459,21 @@ class FlickKeyboardView @JvmOverloads constructor( liquidGlassKeyAlphaEnable: Int, borderWidth: Int ) { + val renderConfigurationChanged = + this.themeMode != themeMode || + this.isNightMode != + (currentNightMode == Configuration.UI_MODE_NIGHT_YES) || + this.isDynamicColorEnabled != isDynamicColorEnabled || + this.customBgColor != customBgColor || + this.customKeyColor != customKeyColor || + this.customSpecialKeyColor != customSpecialKeyColor || + this.customKeyTextColor != customKeyTextColor || + this.customSpecialKeyTextColor != customSpecialKeyTextColor || + this.liquidGlassEnable != liquidGlassEnable || + this.customBorderEnable != customBorderEnable || + this.customBorderColor != customBorderColor || + this.liquidGlassKeyAlphaEnable != liquidGlassKeyAlphaEnable || + this.borderWidth != borderWidth this.themeMode = themeMode this.isNightMode = (currentNightMode == Configuration.UI_MODE_NIGHT_YES) this.isDynamicColorEnabled = isDynamicColorEnabled @@ -414,6 +487,9 @@ class FlickKeyboardView @JvmOverloads constructor( this.customBorderColor = customBorderColor this.liquidGlassKeyAlphaEnable = liquidGlassKeyAlphaEnable this.borderWidth = borderWidth + if (renderConfigurationChanged) { + keyboardRenderRevision += 1 + } if (liquidGlassEnable) { this.setBackgroundColor(ColorUtils.setAlphaComponent(customBgColor, 0)) @@ -472,8 +548,32 @@ class FlickKeyboardView @JvmOverloads constructor( @SuppressLint("ClickableViewAccessibility") fun setKeyboard(layout: KeyboardLayout) { + setKeyboard(layout, forceRebuild = false) + } + + private fun rebuildCurrentKeyboard() { + currentLayout?.let { setKeyboard(it, forceRebuild = true) } + } + + @SuppressLint("ClickableViewAccessibility") + private fun setKeyboard(layout: KeyboardLayout, forceRebuild: Boolean) { + val expectedChildCount = if (layout.items.isNotEmpty()) { + layout.items.size + } else { + layout.keys.size + } + if ( + !forceRebuild && + currentLayout == layout && + renderedKeyboardRenderRevision == keyboardRenderRevision && + childCount == expectedChildCount + ) { + Log.d("FlickKeyboardView", "setKeyboard (Reuse Existing Views)") + return + } Log.d("FlickKeyboardView", "setKeyboard (Full Rebuild)") + doubleTapActionDispatcher.cancel() cancelTrackedTouchState() listener?.onLongPressActionCanceled(KeyAction.Cancel) @@ -503,7 +603,9 @@ class FlickKeyboardView @JvmOverloads constructor( tapLongPressControllers.forEach { it.cancel() } tapLongPressControllers.clear() + keyInfos.clear() dynamicKeyMap.clear() + canonicalGuideLabels.clear() currentLayout = layout columnCount = if (layout.items.isNotEmpty()) layout.columnUnitCount else layout.columnCount @@ -534,6 +636,7 @@ class FlickKeyboardView @JvmOverloads constructor( ) } } + renderedKeyboardRenderRevision = keyboardRenderRevision } private fun addKeyItem(item: KeyItem) { @@ -544,8 +647,10 @@ class FlickKeyboardView @JvmOverloads constructor( val controller = attachKeyBehavior(keyView, keyData) applyVisibleKeyFilter(keyView, keyData) + val info = KeyInfo(keyView, keyData, controller, index) + keyInfos += info keyData.keyId?.let { id -> - dynamicKeyMap[id] = KeyInfo(keyView, keyData, controller, index) + dynamicKeyMap[id] = info } addView(keyView) @@ -587,6 +692,7 @@ class FlickKeyboardView @JvmOverloads constructor( displayActions = KeyActionMapper.getDisplayActions(context), resolve = ::resolveSumireSpecialKeyOverride ) + if (info.keyData == newKeyData) return val oldView = info.view val newViewIsIcon = KeyIconResolver.hasIcon(newKeyData) @@ -598,6 +704,7 @@ class FlickKeyboardView @JvmOverloads constructor( val needsNewView = (oldViewIsIcon && newViewIsText) || (oldViewIsText && newViewIsIcon) detachKeyBehavior(info.controller) + (oldView as? AutoSizeButton)?.let(canonicalGuideLabels::remove) val newView: View if (needsNewView) { @@ -632,6 +739,57 @@ class FlickKeyboardView @JvmOverloads constructor( } } + private fun refreshKeyTextPresentation(info: KeyInfo) { + val button = info.view as? AutoSizeButton ?: return + val guideLabels = canonicalGuideLabels[button] + updateKeyVisuals(button, info.keyData) + if (guideLabels != null) { + applyDisplayedGuideLabels(button, info.keyData, guideLabels) + } + } + + private fun refreshPopupTextTransformers() { + val transform = ::transformInputTextForDisplay + flickControllers.forEach { it.setInputTextTransform(transform) } + crossFlickControllers.forEach { it.setInputTextTransform(transform) } + standardFlickControllers.forEach { it.setInputTextTransform(transform) } + tfbiControllers.forEach { it.setInputTextTransform(transform) } + flickLongPressControllers.forEach { it.setInputTextTransform(transform) } + stickyTfbiControllers.forEach { it.setInputTextTransform(transform) } + hierarchicalTfbiControllers.forEach { it.setInputTextTransform(transform) } + } + + private fun transformInputTextForDisplay(text: String): String = + keyCharacterCase.transformAsciiLetters(text) + + private fun shouldTransformMainLabel(keyData: KeyData): Boolean { + if (keyData.action is KeyAction.Text) return true + return !keyData.isSpecialKey && keyData.keyType != KeyType.NORMAL + } + + private fun keyLabelForDisplay(keyData: KeyData): String { + val canonicalLabel = KeyIconResolver.resolvedLabelForRendering(keyData) + return if (shouldTransformMainLabel(keyData)) { + transformInputTextForDisplay(canonicalLabel) + } else { + canonicalLabel + } + } + + private fun transformGuideLabels( + labels: AutoSizeButton.FlickGuideLabels + ): AutoSizeButton.FlickGuideLabels = labels.copy( + tap = transformInputTextForDisplay(labels.tap), + up = transformInputTextForDisplay(labels.up), + upRight = transformInputTextForDisplay(labels.upRight), + right = transformInputTextForDisplay(labels.right), + downRight = transformInputTextForDisplay(labels.downRight), + down = transformInputTextForDisplay(labels.down), + downLeft = transformInputTextForDisplay(labels.downLeft), + left = transformInputTextForDisplay(labels.left), + upLeft = transformInputTextForDisplay(labels.upLeft) + ) + /** * 100 = デフォルト margin * 200 = margin 0 @@ -824,11 +982,12 @@ class FlickKeyboardView @JvmOverloads constructor( private fun applyButtonText(button: AutoSizeButton, keyData: KeyData) { val targetTextSizeSp = getKeyTextSizeSp(keyData) - val label = KeyIconResolver.resolvedLabelForRendering(keyData) + val label = keyLabelForDisplay(keyData) button.setDefaultTextSize(targetTextSizeSp) button.setFlickGuideTextSizeSp(flickGuideTextSizeSp) button.setFlickGuideLabels(null) + button.contentDescription = label if (label.contains("\n")) { button.maxLines = 2 @@ -1015,10 +1174,24 @@ class FlickKeyboardView @JvmOverloads constructor( (!singleCharacterLabel && !eligibleMultiCharacterLabel) || !labels.hasVisibleGuides() ) { + canonicalGuideLabels.remove(button) button.setFlickGuideLabels(null) return } + canonicalGuideLabels[button] = labels + applyDisplayedGuideLabels(button, keyData, labels) + } + + private fun applyDisplayedGuideLabels( + button: AutoSizeButton, + keyData: KeyData, + canonicalLabels: AutoSizeButton.FlickGuideLabels + ) { + val labels = transformGuideLabels(canonicalLabels) + val singleCharacterLabel = isSingleGuideCharacter(keyData.label) + val eligibleMultiCharacterLabel = + flickGuideAllowsMultiCharacterLabels && !keyData.isSpecialKey if (!singleCharacterLabel && eligibleMultiCharacterLabel && labels.tap.isNotEmpty()) { button.maxLines = 1 button.setLineSpacing(0f, 1f) @@ -1256,6 +1429,7 @@ class FlickKeyboardView @JvmOverloads constructor( gestureConfigSource = gestureSessionConfigSource ).apply { setPopupWindowAnchorProvider(popupWindowAnchorProvider) + setInputTextTransform(::transformInputTextForDisplay) val secondaryColor = context.getColorFromAttr(R.attr.colorSecondaryContainer) val surfaceContainerLow = @@ -1320,7 +1494,7 @@ class FlickKeyboardView @JvmOverloads constructor( this.listener = object : CustomAngleFlickController.FlickListener { override fun onPress(action: FlickAction?) { when (action) { - is FlickAction.Input -> notifyTextPress(action.char) + is FlickAction.Input -> notifyTextPress(keyData, action.char) is FlickAction.Action -> this@FlickKeyboardView.listener?.onPress( action.action ) @@ -1336,7 +1510,8 @@ class FlickKeyboardView @JvmOverloads constructor( when (action) { is FlickAction.Input -> { if (action.char.isNotEmpty()) { - this@FlickKeyboardView.listener?.onAction( + dispatchCommittedKeyAction( + keyData, KeyAction.Text(action.char), isFlick = direction != CircularFlickDirection.TAP ) @@ -1344,7 +1519,8 @@ class FlickKeyboardView @JvmOverloads constructor( } is FlickAction.Action -> { - this@FlickKeyboardView.listener?.onAction( + dispatchCommittedKeyAction( + keyData, action.action, isFlick = direction != CircularFlickDirection.TAP ) @@ -1372,6 +1548,15 @@ class FlickKeyboardView @JvmOverloads constructor( newDirection.toLegacyFlickDirection() ) } + + override fun onSelectionChanged( + action: FlickAction?, + isFlick: Boolean + ) { + updateTextPreview(textOutputFromFlickAction(action), isFlick) + } + + override fun onCanceled() = cancelTextPreview() } val mapSwitchLabels = List(circularKeyMapsList.size) { null } @@ -1429,7 +1614,8 @@ class FlickKeyboardView @JvmOverloads constructor( context = context, gestureConfigSource = gestureSessionConfigSource ).apply { - setPopupWindowAnchorProvider(popupWindowAnchorProvider) + setPopupOverlayHostProvider(popupWindowAnchorProvider) + setInputTextTransform(::transformInputTextForDisplay) applyPopupViewStyleSet( popupViewStyleSet.directional, popupViewStyleSet.cross @@ -1489,7 +1675,8 @@ class FlickKeyboardView @JvmOverloads constructor( isFlick = isFlick, resolve = ::resolveSumireSpecialKeyOverride ) { dispatchedAction, actionIsFlick -> - this@FlickKeyboardView.listener?.onAction( + dispatchCommittedKeyAction( + keyData, dispatchedAction, actionIsFlick ) @@ -1498,6 +1685,7 @@ class FlickKeyboardView @JvmOverloads constructor( override fun onFlickLongPress(action: KeyAction) { if (action !is KeyAction.Text) { + doubleTapActionDispatcher.interrupt() this@FlickKeyboardView.listener?.onFlickActionLongPress(action) } } @@ -1507,6 +1695,7 @@ class FlickKeyboardView @JvmOverloads constructor( isFlick: Boolean ) { if (action !is KeyAction.Text) { + doubleTapActionDispatcher.interrupt() this@FlickKeyboardView.listener?.onFlickActionUpAfterLongPress( action, isFlick = isFlick ) @@ -1662,19 +1851,28 @@ class FlickKeyboardView @JvmOverloads constructor( gestureConfigSource = gestureSessionConfigSource ).apply { setPopupWindowAnchorProvider(popupWindowAnchorProvider) + setInputTextTransform(::transformInputTextForDisplay) applyPopupViewStyle(popupViewStyleSet.standard) this.listener = object : StandardFlickInputController.StandardFlickListener { override fun onPress(character: String) { - notifyTextPress(character) + notifyTextPress(keyData, character) } override fun onFlick(character: String) { - this@FlickKeyboardView.listener?.onAction( + dispatchCommittedKeyAction( + keyData, KeyAction.Text(character), isFlick = true ) } + + override fun onSelectionChanged( + character: String?, + isFlick: Boolean + ) = updateTextPreview(character, isFlick) + + override fun onCanceled() = cancelTextPreview() } val stringMap = extractInputMap(flickActionMap) @@ -1764,7 +1962,8 @@ class FlickKeyboardView @JvmOverloads constructor( context = context, gestureConfigSource = gestureSessionConfigSource ).apply { - setPopupWindowAnchorProvider(popupWindowAnchorProvider) + setPopupOverlayHostProvider(popupWindowAnchorProvider) + setInputTextTransform(::transformInputTextForDisplay) applyPopupViewStyleSet( popupViewStyleSet.directional, popupViewStyleSet.cross @@ -1837,17 +2036,25 @@ class FlickKeyboardView @JvmOverloads constructor( this.listener = object : CrossFlickInputController.CrossFlickListener { override fun onPress(action: KeyAction) { when (action) { - is KeyAction.Text -> notifyTextPress(action.text) + is KeyAction.Text -> notifyTextPress(keyData, action.text) else -> this@FlickKeyboardView.listener?.onPress(action) } } override fun onFlick(action: KeyAction, isFlick: Boolean) { - this@FlickKeyboardView.listener?.onAction(action, isFlick) + dispatchCommittedKeyAction(keyData, action, isFlick) } + override fun onTextSelectionChanged( + text: String?, + isFlick: Boolean + ) = updateTextPreview(text, isFlick) + + override fun onCanceled() = cancelTextPreview() + override fun onFlickLongPress(action: KeyAction) { if (action !is KeyAction.Text) { + doubleTapActionDispatcher.interrupt() this@FlickKeyboardView.listener?.onFlickActionLongPress(action) } } @@ -1857,6 +2064,7 @@ class FlickKeyboardView @JvmOverloads constructor( isFlick: Boolean ) { if (action !is KeyAction.Text) { + doubleTapActionDispatcher.interrupt() this@FlickKeyboardView.listener?.onFlickActionUpAfterLongPress( action, isFlick @@ -1936,6 +2144,7 @@ class FlickKeyboardView @JvmOverloads constructor( override fun onTap() { if ( dispatchResolvedSumireSpecialKeyAction( + keyData, resolveSumireSpecialKeyOverride( keyData, SumireSpecialKeyDirection.TAP @@ -1950,19 +2159,22 @@ class FlickKeyboardView @JvmOverloads constructor( "FlickKeyboardView KeyType.NORMAL", "currentAction: $currentAction" ) - this@FlickKeyboardView.listener?.onAction( + dispatchCommittedKeyAction( + keyData, currentAction, isFlick = false ) } override fun onLongPress() { + doubleTapActionDispatcher.interrupt() this@FlickKeyboardView.listener?.onActionLongPress( currentAction() ) } override fun onUpAfterLongPress() { + doubleTapActionDispatcher.interrupt() this@FlickKeyboardView.listener?.onActionUpAfterLongPress( currentAction() ) @@ -1998,13 +2210,14 @@ class FlickKeyboardView @JvmOverloads constructor( gestureConfigSource = gestureSessionConfigSource ).apply { setPopupWindowAnchorProvider(popupWindowAnchorProvider) + setInputTextTransform(::transformInputTextForDisplay) applyPopupViewStyle(popupViewStyleSet.tfbi) this.listener = object : TfbiInputController.TfbiListener { override fun onPress( first: TfbiFlickDirection, second: TfbiFlickDirection ) { - notifyTextPress(twoStepMap[first]?.get(second) ?: "") + notifyTextPress(keyData, twoStepMap[first]?.get(second) ?: "") } override fun onFlick( @@ -2017,7 +2230,8 @@ class FlickKeyboardView @JvmOverloads constructor( "$character $first $second" ) if (character.isNotEmpty()) { - this@FlickKeyboardView.listener?.onAction( + dispatchCommittedKeyAction( + keyData, KeyAction.Text(character), isFlick = !(first == TfbiFlickDirection.TAP && second == TfbiFlickDirection.TAP) ) @@ -2031,13 +2245,34 @@ class FlickKeyboardView @JvmOverloads constructor( val output = twoStepLongPressMap?.get(first)?.get(second).orEmpty() if (output.isEmpty()) return false - this@FlickKeyboardView.listener?.onAction( - KeyAction.Text(output), + dispatchCommittedKeyAction( + keyData = keyData, + action = KeyAction.Text(output), isFlick = !(first == TfbiFlickDirection.TAP && - second == TfbiFlickDirection.TAP) + second == TfbiFlickDirection.TAP) ) return true } + + override fun onSelectionChanged( + first: TfbiFlickDirection, + second: TfbiFlickDirection, + isLongPress: Boolean + ) { + val longPressText = if (isLongPress) { + twoStepLongPressMap?.get(first)?.get(second) + } else { + null + } + updateTextPreview( + longPressText?.takeIf(String::isNotEmpty) + ?: twoStepMap[first]?.get(second), + first != TfbiFlickDirection.TAP || + second != TfbiFlickDirection.TAP + ) + } + + override fun onCanceled() = cancelTextPreview() } attach( @@ -2092,14 +2327,16 @@ class FlickKeyboardView @JvmOverloads constructor( gestureConfigSource = gestureSessionConfigSource ).apply { setPopupWindowAnchorProvider(popupWindowAnchorProvider) + setInputTextTransform(::transformInputTextForDisplay) applyPopupViewStyle(popupViewStyleSet.tfbi) this.listener = object : FlickLongPressInputController.Listener { override fun onPress(character: String) { - notifyTextPress(character) + notifyTextPress(keyData, character) } override fun onCommit(character: String, isFlick: Boolean) { - this@FlickKeyboardView.listener?.onAction( + dispatchCommittedKeyAction( + keyData, KeyAction.Text(character), isFlick = isFlick ) @@ -2141,13 +2378,14 @@ class FlickKeyboardView @JvmOverloads constructor( gestureConfigSource = gestureSessionConfigSource ).apply { setPopupWindowAnchorProvider(popupWindowAnchorProvider) + setInputTextTransform(::transformInputTextForDisplay) applyPopupViewStyle(popupViewStyleSet.tfbi) this.listener = object : TfbiStickyFlickController.TfbiListener { override fun onPress( first: TfbiFlickDirection, second: TfbiFlickDirection ) { - notifyTextPress(twoStepMap[first]?.get(second) ?: "") + notifyTextPress(keyData, twoStepMap[first]?.get(second) ?: "") } override fun onFlick( @@ -2160,7 +2398,8 @@ class FlickKeyboardView @JvmOverloads constructor( "$character $first $second" ) if (character.isNotEmpty()) { - this@FlickKeyboardView.listener?.onAction( + dispatchCommittedKeyAction( + keyData, KeyAction.Text(character), isFlick = !(first == TfbiFlickDirection.TAP && second == TfbiFlickDirection.TAP) ) @@ -2199,11 +2438,12 @@ class FlickKeyboardView @JvmOverloads constructor( gestureConfigSource = gestureSessionConfigSource ).apply { setPopupWindowAnchorProvider(popupWindowAnchorProvider) + setInputTextTransform(::transformInputTextForDisplay) setModeSwitchAngleMargin(hierarchicalFlickModeSwitchAngleMargin) applyPopupViewStyle(popupViewStyleSet.tfbi) this.listener = object : TfbiHierarchicalFlickController.TfbiListener { override fun onPress(character: String) { - notifyTextPress(character) + notifyTextPress(keyData, character) } override fun onFlick(character: String) { @@ -2212,13 +2452,21 @@ class FlickKeyboardView @JvmOverloads constructor( "Char: $character" ) if (character.isNotEmpty()) { - this@FlickKeyboardView.listener?.onAction( + dispatchCommittedKeyAction( + keyData, KeyAction.Text(character), isFlick = true ) } } + override fun onSelectionChanged( + character: String?, + isFlick: Boolean + ) = updateTextPreview(character, isFlick) + + override fun onCanceled() = cancelTextPreview() + override fun onModeChanged( newLabel: String, activeRootMap: Map @@ -2290,21 +2538,89 @@ class FlickKeyboardView @JvmOverloads constructor( return resolver(layoutType, inputMode, keyData, direction) } + private fun dispatchCommittedKeyAction( + keyData: KeyData, + action: KeyAction, + isFlick: Boolean + ) { + val dispatch = { + if (isFlick) { + dispatchNonTapActionWithoutPreviewCancel(action, isFlick = true) + } else { + doubleTapActionDispatcher.onCommittedTap( + keyIdentity = keyData.keyId + ?: "legacy:${keyData.row}:${keyData.column}:${keyData.keyType}", + normalAction = action, + binding = keyData.effectiveDoubleTapBinding(action) + ) + } + } + val textAction = action as? KeyAction.Text + if (textAction != null && canPreviewText(keyData, textAction)) { + flickTextPreviewEmitter.commit( + FlickTextSelection(textAction.text, isFlick), + dispatch + ) + previewKeyData = null + } else { + cancelTextPreview() + dispatch() + } + } + + private fun dispatchNonTapAction(action: KeyAction, isFlick: Boolean) { + cancelTextPreview() + dispatchNonTapActionWithoutPreviewCancel(action, isFlick) + } + + private fun dispatchNonTapActionWithoutPreviewCancel(action: KeyAction, isFlick: Boolean) { + doubleTapActionDispatcher.interrupt() + listener?.onAction(action, isFlick) + } + private fun dispatchResolvedSumireSpecialKeyAction( + keyData: KeyData, resolved: ResolvedSumireSpecialKeyAction, isFlick: Boolean ): Boolean { return dispatchResolvedSumireSpecialKeyAction(resolved, isFlick) { action, actionIsFlick -> - listener?.onAction(action, actionIsFlick) + dispatchCommittedKeyAction(keyData, action, actionIsFlick) } } - private fun notifyTextPress(character: String) { - if (character.isNotEmpty()) { - listener?.onPress(KeyAction.Text(character)) + private fun notifyTextPress(keyData: KeyData, character: String) { + val action = KeyAction.Text(character) + if (character.isNotEmpty()) listener?.onPress(action) + if (character.isNotEmpty() && canPreviewText(keyData, action)) { + previewKeyData = keyData + flickTextPreviewEmitter.begin(FlickTextSelection(character, false)) + } else { + cancelTextPreview() } } + private fun updateTextPreview(character: String?, isFlick: Boolean) { + if (previewKeyData == null) return + flickTextPreviewEmitter.update(FlickTextSelection(character, isFlick)) + } + + private fun cancelTextPreview() { + flickTextPreviewEmitter.cancel() + previewKeyData = null + } + + private fun canPreviewText(keyData: KeyData, action: KeyAction.Text): Boolean { + return !keyData.isSpecialKey && keyData.effectiveDoubleTapBinding(action) == null + } + + private fun textOutputFromFlickAction(action: FlickAction?): String? { + return when (action) { + is FlickAction.Input -> action.char + is FlickAction.Action -> (action.action as? KeyAction.Text)?.text + null -> null + }?.takeIf(String::isNotEmpty) + } + private fun detachKeyBehavior(controller: Any?) { when (controller) { is CustomAngleFlickController -> { @@ -2384,6 +2700,7 @@ class FlickKeyboardView @JvmOverloads constructor( private val TAG = "FlickKeyboardViewTouch" private fun cancelTrackedTouchState() { + cancelTextPreview() if (motionTargets.isEmpty() && pointerDownTime.isEmpty()) return val eventTime = SystemClock.uptimeMillis() @@ -2527,13 +2844,13 @@ class FlickKeyboardView @JvmOverloads constructor( if (abs(dx) > abs(dy) && abs(dx) > threshold) { val action2 = if (dx < 0f) KeyAction.MoveCursorLeft else KeyAction.MoveCursorRight - listener?.onAction(action2, false) + dispatchNonTapAction(action2, false) cursorInitialX = currentX cursorInitialY = currentY } else if (abs(dy) > abs(dx) && abs(dy) > threshold) { val action2 = if (dy < 0f) KeyAction.MoveCursorUp else KeyAction.MoveCursorDown - listener?.onAction(action2, false) + dispatchNonTapAction(action2, false) cursorInitialX = currentX cursorInitialY = currentY } @@ -2746,6 +3063,8 @@ class FlickKeyboardView @JvmOverloads constructor( } override fun onDetachedFromWindow() { + doubleTapActionDispatcher.cancel() + cancelTextPreview() cancelTrackedTouchState() super.onDetachedFromWindow() listener?.onLongPressActionCanceled(KeyAction.Cancel) @@ -2762,6 +3081,8 @@ class FlickKeyboardView @JvmOverloads constructor( override fun onVisibilityChanged(changedView: View, visibility: Int) { super.onVisibilityChanged(changedView, visibility) if (changedView == this && visibility != View.VISIBLE) { + doubleTapActionDispatcher.cancel() + cancelTextPreview() cancelTrackedTouchState() listener?.onLongPressActionCanceled(KeyAction.Cancel) } diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/StandardFlickPopupView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/StandardFlickPopupView.kt index a39bb5317..c1c4eae7e 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/StandardFlickPopupView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/StandardFlickPopupView.kt @@ -46,6 +46,7 @@ class StandardFlickPopupView(context: Context) : AppCompatTextView(context) { private var popupTextSizeSp: Float = 19f private var popupBackgroundColor: Int? = null private var popupTextColor: Int? = null + private var inputTextTransform: (String) -> String = { it } private class YOffsetSpan(private val yOffset: Int) : ReplacementSpan() { override fun getSize( @@ -118,20 +119,24 @@ class StandardFlickPopupView(context: Context) : AppCompatTextView(context) { colorTheme?.let { applyTheme(it, direction) } } + fun setInputTextTransform(transform: (String) -> String) { + inputTextTransform = transform + } + fun updateText(text: String?) { if (text.isNullOrEmpty()) { this.text = "" return } - this.text = createSpannableText(text) + this.text = createSpannableText(inputTextTransform(text)) } fun updateMultiCharText(characters: Map) { - val up = characters[FlickDirection.UP] ?: "" - val left = characters[FlickDirection.UP_LEFT_FAR] ?: "" - val tap = characters[FlickDirection.TAP] ?: "" - val right = characters[FlickDirection.UP_RIGHT_FAR] ?: "" - val down = characters[FlickDirection.DOWN] ?: "" + val up = inputTextTransform(characters[FlickDirection.UP] ?: "") + val left = inputTextTransform(characters[FlickDirection.UP_LEFT_FAR] ?: "") + val tap = inputTextTransform(characters[FlickDirection.TAP] ?: "") + val right = inputTextTransform(characters[FlickDirection.UP_RIGHT_FAR] ?: "") + val down = inputTextTransform(characters[FlickDirection.DOWN] ?: "") val tapSize = popupTextSizeSp.coerceIn(8f, 48f) val sideSize = tapSize * 0.58f diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiFlickPopupView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiFlickPopupView.kt index ac66b4720..c53c86eea 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiFlickPopupView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiFlickPopupView.kt @@ -43,6 +43,7 @@ class TfbiFlickPopupView(context: Context) : View(context) { } private var popupBackgroundColor: Int? = null private var popupTextColor: Int? = null + private var inputTextTransform: (String) -> String = { it } private val rects = mutableMapOf() private var cornerRadius = 20f @@ -80,6 +81,11 @@ class TfbiFlickPopupView(context: Context) : View(context) { invalidate() } + fun setInputTextTransform(transform: (String) -> String) { + inputTextTransform = transform + invalidate() + } + fun highlightDirection(direction: TfbiFlickDirection) { if (this.highlightedDirection != direction) { this.highlightedDirection = direction @@ -118,7 +124,7 @@ class TfbiFlickPopupView(context: Context) : View(context) { if (highlightedDirection == TfbiFlickDirection.TAP) highlightBgPaint else bgPaint canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) canvas.drawRoundRect(rect, cornerRadius, cornerRadius, strokePaint) - drawTextCentered(canvas, tapCharacter, rect) + drawTextCentered(canvas, inputTextTransform(tapCharacter), rect) } for ((direction, char) in petalCharacters) { @@ -126,7 +132,7 @@ class TfbiFlickPopupView(context: Context) : View(context) { val paint = if (highlightedDirection == direction) highlightBgPaint else bgPaint canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) canvas.drawRoundRect(rect, cornerRadius, cornerRadius, strokePaint) - drawTextCentered(canvas, char, rect) + drawTextCentered(canvas, inputTextTransform(char), rect) } } } diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/CrossFlickInputControllerPopupCacheTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/CrossFlickInputControllerPopupCacheTest.kt index 539a6ab05..c6305ceec 100644 --- a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/CrossFlickInputControllerPopupCacheTest.kt +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/CrossFlickInputControllerPopupCacheTest.kt @@ -3,8 +3,8 @@ package com.kazumaproject.custom_keyboard.controller import android.content.Context import android.view.ContextThemeWrapper import android.view.MotionEvent +import android.view.View import android.widget.Button -import android.widget.PopupWindow import androidx.test.core.app.ApplicationProvider import com.kazumaproject.custom_keyboard.data.FlickDirection import com.kazumaproject.custom_keyboard.data.KeyAction @@ -81,11 +81,11 @@ class CrossFlickInputControllerPopupCacheTest { @Suppress("UNCHECKED_CAST") private fun CrossFlickInputController.directionalPopups(): - Map { + Map { val field = CrossFlickInputController::class.java .getDeclaredField("directionalPopupMap") .apply { isAccessible = true } - return field.get(this) as Map + return field.get(this) as Map } } diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/CrossFlickInputControllerPopupPositionTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/CrossFlickInputControllerPopupPositionTest.kt new file mode 100644 index 000000000..1de844e1a --- /dev/null +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/CrossFlickInputControllerPopupPositionTest.kt @@ -0,0 +1,126 @@ +package com.kazumaproject.custom_keyboard.controller + +import android.content.Context +import android.view.View +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertArrayEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class CrossFlickInputControllerPopupPositionTest { + + @Test + fun popupCoordinatesAreRelativeToOverlayHost() { + val context = ApplicationProvider.getApplicationContext() + val key = LocatedView( + context = context, + screenLocation = intArrayOf(560, 599), + windowLocation = intArrayOf(434, 599) + ) + val overlayHost = LocatedView( + context = context, + screenLocation = intArrayOf(126, 95), + windowLocation = intArrayOf(0, 0) + ) + + assertArrayEquals( + intArrayOf(434, 504), + readPopupAnchorLocation(keyAnchor = key, overlayHost = overlayHost) + ) + } + + @Test + fun popupCoordinatesRemainStableWhenAnchorAndHostWindowMoveTogether() { + val context = ApplicationProvider.getApplicationContext() + val beforeMoveKey = LocatedView( + context = context, + screenLocation = intArrayOf(760, 640), + windowLocation = intArrayOf(760, 640) + ) + val beforeMoveHost = LocatedView( + context = context, + screenLocation = intArrayOf(120, 80), + windowLocation = intArrayOf(120, 80) + ) + val afterMoveKey = LocatedView( + context = context, + screenLocation = intArrayOf(760, 735), + windowLocation = intArrayOf(760, 735) + ) + val afterMoveHost = LocatedView( + context = context, + screenLocation = intArrayOf(120, 175), + windowLocation = intArrayOf(120, 175) + ) + + assertArrayEquals( + readPopupAnchorLocation(beforeMoveKey, beforeMoveHost), + readPopupAnchorLocation(afterMoveKey, afterMoveHost) + ) + } + + @Test + @Config(qualifiers = "land") + fun overlayCoordinateRuleDoesNotChangeWithOrientation() { + val context = ApplicationProvider.getApplicationContext() + val key = LocatedView( + context = context, + screenLocation = intArrayOf(760, 640), + windowLocation = intArrayOf(760, 640) + ) + val windowAnchor = LocatedView( + context = context, + screenLocation = intArrayOf(120, 80), + windowLocation = intArrayOf(120, 80) + ) + + assertArrayEquals( + intArrayOf(640, 560), + readPopupAnchorLocation(keyAnchor = key, overlayHost = windowAnchor) + ) + } + + private fun readPopupAnchorLocation( + keyAnchor: View, + overlayHost: View + ): IntArray { + val controller = CrossFlickInputController( + context = ApplicationProvider.getApplicationContext(), + flickSensitivity = 80 + ) + val method = CrossFlickInputController::class.java.getDeclaredMethod( + "readPopupAnchorLocation", + View::class.java, + View::class.java + ).apply { + isAccessible = true + } + method.invoke(controller, keyAnchor, overlayHost) + + return CrossFlickInputController::class.java + .getDeclaredField("popupAnchorLocation") + .apply { isAccessible = true } + .get(controller) as IntArray + } + + private class LocatedView( + context: Context, + private val screenLocation: IntArray, + private val windowLocation: IntArray + ) : View(context) { + + override fun getLocationOnScreen(outLocation: IntArray) { + outLocation[0] = screenLocation[0] + outLocation[1] = screenLocation[1] + } + + override fun getLocationInWindow(outLocation: IntArray) { + outLocation[0] = windowLocation[0] + outLocation[1] = windowLocation[1] + } + } +} diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/DoubleTapActionDispatcherTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/DoubleTapActionDispatcherTest.kt new file mode 100644 index 000000000..fab6cc128 --- /dev/null +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/DoubleTapActionDispatcherTest.kt @@ -0,0 +1,117 @@ +package com.kazumaproject.custom_keyboard.controller + +import com.kazumaproject.custom_keyboard.data.DoubleTapBinding +import com.kazumaproject.custom_keyboard.data.DoubleTapPolicy +import com.kazumaproject.custom_keyboard.data.KeyAction +import org.junit.Assert.assertEquals +import org.junit.Test + +class DoubleTapActionDispatcherTest { + private var now = 0L + private val scheduler = FakeScheduler { now } + private val dispatched = mutableListOf() + private val dispatcher = DoubleTapActionDispatcher( + timeoutMillis = 300, + minimumIntervalMillis = 40, + clockMillis = { now }, + scheduler = scheduler, + dispatch = dispatched::add + ) + + @Test + fun promote_dispatchesFirstTapImmediately_thenOnlyDoubleTapAction() { + val binding = DoubleTapBinding(KeyAction.CapLockKey, DoubleTapPolicy.PROMOTE) + + dispatcher.onCommittedTap("shift", KeyAction.ShiftKey, binding) + now = 120 + dispatcher.onCommittedTap("shift", KeyAction.ShiftKey, binding) + + assertEquals(listOf(KeyAction.ShiftKey, KeyAction.CapLockKey), dispatched) + scheduler.runDueAt(500) + assertEquals(listOf(KeyAction.ShiftKey, KeyAction.CapLockKey), dispatched) + } + + @Test + fun exclusive_doubleTap_suppressesNormalAction() { + val binding = DoubleTapBinding(KeyAction.Copy, DoubleTapPolicy.EXCLUSIVE) + + dispatcher.onCommittedTap("select", KeyAction.SelectAll, binding) + now = 160 + dispatcher.onCommittedTap("select", KeyAction.SelectAll, binding) + + assertEquals(listOf(KeyAction.Copy), dispatched) + } + + @Test + fun exclusive_singleTap_dispatchesAfterTimeout() { + val binding = DoubleTapBinding(KeyAction.Copy, DoubleTapPolicy.EXCLUSIVE) + + dispatcher.onCommittedTap("select", KeyAction.SelectAll, binding) + scheduler.runDueAt(299) + assertEquals(emptyList(), dispatched) + scheduler.runDueAt(300) + + assertEquals(listOf(KeyAction.SelectAll), dispatched) + } + + @Test + fun differentKey_flushesExclusiveTapBeforeNewAction() { + val binding = DoubleTapBinding(KeyAction.Copy, DoubleTapPolicy.EXCLUSIVE) + + dispatcher.onCommittedTap("select", KeyAction.SelectAll, binding) + now = 100 + dispatcher.onCommittedTap("space", KeyAction.Space, null) + + assertEquals(listOf(KeyAction.SelectAll, KeyAction.Space), dispatched) + } + + @Test + fun tooFastSecondTap_isIgnoredAsTouchBounce() { + val binding = DoubleTapBinding(KeyAction.CapLockKey, DoubleTapPolicy.PROMOTE) + + dispatcher.onCommittedTap("shift", KeyAction.ShiftKey, binding) + now = 20 + dispatcher.onCommittedTap("shift", KeyAction.ShiftKey, binding) + + assertEquals(listOf(KeyAction.ShiftKey), dispatched) + } + + @Test + fun cancel_dropsPendingExclusiveAction() { + val binding = DoubleTapBinding(KeyAction.Copy, DoubleTapPolicy.EXCLUSIVE) + + dispatcher.onCommittedTap("select", KeyAction.SelectAll, binding) + dispatcher.cancel() + scheduler.runDueAt(500) + + assertEquals(emptyList(), dispatched) + } + + private class FakeScheduler( + private val clock: () -> Long + ) : TapTaskScheduler { + private data class Entry( + val dueAt: Long, + var canceled: Boolean, + val task: () -> Unit + ) + + private val entries = mutableListOf() + + override fun schedule(delayMillis: Long, task: () -> Unit): CancellableTask { + val entry = Entry(clock() + delayMillis, false, task) + entries += entry + return CancellableTask { entry.canceled = true } + } + + fun runDueAt(now: Long) { + entries + .filter { !it.canceled && it.dueAt <= now } + .sortedBy { it.dueAt } + .forEach { + it.canceled = true + it.task() + } + } + } +} diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/KeyboardPopupOverlayTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/KeyboardPopupOverlayTest.kt new file mode 100644 index 000000000..b6b59fcf9 --- /dev/null +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/KeyboardPopupOverlayTest.kt @@ -0,0 +1,74 @@ +package com.kazumaproject.custom_keyboard.controller + +import android.app.Activity +import android.view.View +import android.widget.FrameLayout +import android.widget.PopupWindow +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class KeyboardPopupOverlayTest { + + @Test + fun crossFlickControllerDoesNotRetainPopupWindowState() { + val hasPopupWindowField = CrossFlickInputController::class.java.declaredFields.any { + PopupWindow::class.java.isAssignableFrom(it.type) + } + + assertFalse(hasPopupWindowField) + } + + @Test + fun visiblePopupIsPlacedAgainInTheSamePreDrawAsAnchorMovement() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val host = FrameLayout(activity) + val anchor = View(activity) + val popup = View(activity) + activity.setContentView(host) + host.addView(anchor, FrameLayout.LayoutParams(100, 60)) + host.measure(exactly(500), exactly(500)) + host.layout(0, 0, 500, 500) + anchor.layout(40, 300, 140, 360) + + lateinit var overlay: KeyboardPopupOverlay + overlay = KeyboardPopupOverlay { + overlay.place( + popupView = popup, + left = anchor.left, + top = anchor.top - 60, + width = 100, + height = 60 + ) + } + + assertTrue( + overlay.show( + anchor = anchor, + preferredHost = host, + popupView = popup, + width = 100, + height = 60 + ) + ) + assertEquals(240, popup.top) + + anchor.layout(40, 205, 140, 265) + host.viewTreeObserver.dispatchOnPreDraw() + + assertEquals(145, popup.top) + overlay.dismiss(popup) + assertFalse(overlay.isShowing(popup)) + } + + private fun exactly(size: Int): Int = + View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY) +} diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/data/KeyCharacterCaseTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/data/KeyCharacterCaseTest.kt new file mode 100644 index 000000000..0b0ae9da7 --- /dev/null +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/data/KeyCharacterCaseTest.kt @@ -0,0 +1,29 @@ +package com.kazumaproject.custom_keyboard.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class KeyCharacterCaseTest { + @Test + fun uppercase_transformsOnlyLowercaseAsciiLetters() { + assertEquals( + "ABC XYZ-123 あİß", + KeyCharacterCase.UPPERCASE.transformAsciiLetters("aBc xyz-123 あİß") + ) + } + + @Test + fun asDefined_returnsCanonicalInstance() { + val canonical = "qWERTY あいう" + + assertSame(canonical, KeyCharacterCase.AS_DEFINED.transformAsciiLetters(canonical)) + } + + @Test + fun uppercase_withoutLowercaseAscii_returnsCanonicalInstance() { + val canonical = "ABC 123 あいう" + + assertSame(canonical, KeyCharacterCase.UPPERCASE.transformAsciiLetters(canonical)) + } +} diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/data/KeyGestureBindingTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/data/KeyGestureBindingTest.kt new file mode 100644 index 000000000..38d29c287 --- /dev/null +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/data/KeyGestureBindingTest.kt @@ -0,0 +1,62 @@ +package com.kazumaproject.custom_keyboard.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class KeyGestureBindingTest { + @Test + fun normalCharacterKey_neverExposesPersistedDoubleTapBinding() { + val key = key( + normalAction = KeyAction.Text("a"), + isSpecialKey = false, + binding = DoubleTapBinding(KeyAction.Copy, DoubleTapPolicy.PROMOTE) + ) + + assertNull(key.effectiveDoubleTapBinding(KeyAction.Text("a"))) + } + + @Test + fun shiftCapsLock_usesPromoteRegardlessOfPersistedPolicy() { + val key = key( + normalAction = KeyAction.ShiftKey, + isSpecialKey = true, + binding = DoubleTapBinding(KeyAction.CapLockKey, DoubleTapPolicy.EXCLUSIVE) + ) + + assertEquals( + DoubleTapPolicy.PROMOTE, + key.effectiveDoubleTapBinding(KeyAction.ShiftKey)?.policy + ) + } + + @Test + fun otherSpecialShortcut_usesExclusiveRegardlessOfPersistedPolicy() { + val key = key( + normalAction = KeyAction.SelectAll, + isSpecialKey = true, + binding = DoubleTapBinding(KeyAction.Copy, DoubleTapPolicy.PROMOTE) + ) + + assertEquals( + DoubleTapPolicy.EXCLUSIVE, + key.effectiveDoubleTapBinding(KeyAction.SelectAll)?.policy + ) + } + + private fun key( + normalAction: KeyAction, + isSpecialKey: Boolean, + binding: DoubleTapBinding + ) = KeyData( + label = "test", + row = 0, + column = 0, + isFlickable = false, + action = normalAction, + isSpecialKey = isSpecialKey, + keyId = "test", + keyType = KeyType.NORMAL, + doubleTapBinding = binding + ) +} diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/layout/AlphabetTemplateLayoutsTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/layout/AlphabetTemplateLayoutsTest.kt index 402063a92..1e8fd566b 100644 --- a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/layout/AlphabetTemplateLayoutsTest.kt +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/layout/AlphabetTemplateLayoutsTest.kt @@ -1,6 +1,7 @@ package com.kazumaproject.custom_keyboard.layout import com.kazumaproject.custom_keyboard.data.GridPlacement +import com.kazumaproject.custom_keyboard.data.DoubleTapPolicy import com.kazumaproject.custom_keyboard.data.KeyAction import com.kazumaproject.custom_keyboard.data.KeyData import com.kazumaproject.custom_keyboard.data.KeyItem @@ -80,6 +81,16 @@ class AlphabetTemplateLayoutsTest { assertEquals("$prefix SwitchToNextIme rowUnits", 6, switchIme!!.placement.rowUnits) assertEquals("$prefix Space rowUnits", 6, space!!.placement.rowUnits) assertEquals("$prefix Enter rowUnits", 6, enter!!.placement.rowUnits) + assertEquals( + "$prefix Shift double tap action", + KeyAction.CapLockKey, + shift.keyData.doubleTapBinding?.action + ) + assertEquals( + "$prefix Shift double tap policy", + DoubleTapPolicy.PROMOTE, + shift.keyData.doubleTapBinding?.policy + ) } private fun assertAllCharacterKeysAreNormalText(layout: KeyboardLayout) { diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/DirectionalKeyPopupViewTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/DirectionalKeyPopupViewTest.kt new file mode 100644 index 000000000..a18d55b6b --- /dev/null +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/DirectionalKeyPopupViewTest.kt @@ -0,0 +1,118 @@ +package com.kazumaproject.custom_keyboard.view + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.view.ContextThemeWrapper +import androidx.test.core.app.ApplicationProvider +import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.custom_keyboard.data.FlickDirection +import com.kazumaproject.custom_keyboard.data.FlickPopupColorTheme +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], qualifiers = "xxxhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class DirectionalKeyPopupViewTest { + + @Test + fun verticalDirectionsFillLandscapePopupWidthWithoutRotationClipping() { + val upBitmap = render(FlickDirection.UP) + val downBitmap = render(FlickDirection.DOWN) + + listOf(40, POPUP_WIDTH - 40).forEach { x -> + assertEquals(Color.RED, upBitmap.getPixel(x, 40)) + assertEquals(Color.RED, downBitmap.getPixel(x, POPUP_HEIGHT - 40)) + } + } + + @Test + fun horizontalDirectionsStillFillLandscapePopupHeight() { + val leftBitmap = render(FlickDirection.UP_LEFT_FAR) + val rightBitmap = render(FlickDirection.UP_RIGHT_FAR) + + listOf(30, POPUP_HEIGHT - 30).forEach { y -> + assertEquals(Color.RED, leftBitmap.getPixel(40, y)) + assertEquals(Color.RED, rightBitmap.getPixel(POPUP_WIDTH - 40, y)) + } + } + + @Test + fun outlineIsFullyDrawnInsidePopupBoundsForEveryDirection() { + val tapBitmap = render(FlickDirection.TAP) + val upBitmap = render(FlickDirection.UP) + val downBitmap = render(FlickDirection.DOWN) + val leftBitmap = render(FlickDirection.UP_LEFT_FAR) + val rightBitmap = render(FlickDirection.UP_RIGHT_FAR) + + assertEquals(Color.GREEN, tapBitmap.getPixel(POPUP_WIDTH / 2, 2)) + assertEquals(Color.GREEN, upBitmap.getPixel(POPUP_WIDTH / 2, 2)) + assertEquals(Color.GREEN, downBitmap.getPixel(POPUP_WIDTH / 2, POPUP_HEIGHT - 3)) + assertEquals(Color.GREEN, leftBitmap.getPixel(2, POPUP_HEIGHT / 2)) + assertEquals(Color.GREEN, rightBitmap.getPixel(POPUP_WIDTH - 3, POPUP_HEIGHT / 2)) + assertEquals(Color.RED, tapBitmap.getPixel(POPUP_WIDTH / 2, 5)) + } + + private fun render(direction: FlickDirection): Bitmap { + val context = ContextThemeWrapper( + ApplicationProvider.getApplicationContext(), + com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar + ) + val view = DirectionalKeyPopupView(context).apply { + text = "" + applyPopupViewStyle( + PopupViewStyle( + sizeScalePercent = 100, + textSizeSp = 28f, + backgroundColor = Color.RED, + textColor = Color.WHITE + ) + ) + setColors( + FlickPopupColorTheme( + segmentColor = Color.RED, + segmentHighlightGradientStartColor = Color.RED, + segmentHighlightGradientEndColor = Color.RED, + centerGradientStartColor = Color.RED, + centerGradientEndColor = Color.RED, + centerHighlightGradientStartColor = Color.RED, + centerHighlightGradientEndColor = Color.RED, + separatorColor = Color.GREEN, + textColor = Color.WHITE + ) + ) + setFlickDirection(direction) + measure( + ViewMeasureSpec.exactly(POPUP_WIDTH), + ViewMeasureSpec.exactly(POPUP_HEIGHT) + ) + layout(0, 0, POPUP_WIDTH, POPUP_HEIGHT) + } + return Bitmap.createBitmap( + POPUP_WIDTH, + POPUP_HEIGHT, + Bitmap.Config.ARGB_8888 + ).also { bitmap -> + view.draw(Canvas(bitmap)) + } + } + + private object ViewMeasureSpec { + fun exactly(size: Int): Int = + android.view.View.MeasureSpec.makeMeasureSpec( + size, + android.view.View.MeasureSpec.EXACTLY + ) + } + + private companion object { + const val POPUP_WIDTH = 401 + const val POPUP_HEIGHT = 120 + } +} diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewCharacterCaseTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewCharacterCaseTest.kt new file mode 100644 index 000000000..5e9776616 --- /dev/null +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewCharacterCaseTest.kt @@ -0,0 +1,147 @@ +package com.kazumaproject.custom_keyboard.view + +import android.content.Context +import android.view.ContextThemeWrapper +import androidx.test.core.app.ApplicationProvider +import com.kazumaproject.custom_keyboard.data.KeyAction +import com.kazumaproject.custom_keyboard.data.KeyCharacterCase +import com.kazumaproject.custom_keyboard.data.KeyData +import com.kazumaproject.custom_keyboard.data.KeyType +import com.kazumaproject.custom_keyboard.data.KeyboardInputMode +import com.kazumaproject.custom_keyboard.data.KeyboardLayout +import com.kazumaproject.custom_keyboard.layout.KeyboardDefaultLayouts +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class FlickKeyboardViewCharacterCaseTest { + @Test + fun qwertyLabels_followCharacterCase_withoutMutatingCanonicalLayout() { + val keyboard = createView() + val layout = KeyboardDefaultLayouts.createQwertyTemplateLayout() + keyboard.setKeyboard(layout) + + assertNotNull(keyboard.findButtonWithText("q")) + keyboard.setKeyCharacterCase(KeyCharacterCase.UPPERCASE) + assertNotNull(keyboard.findButtonWithText("Q")) + assertNull(keyboard.findButtonWithText("q")) + assertEquals("q", layout.keys.first { it.keyId == "qwerty_key_q" }.label) + + keyboard.setKeyCharacterCase(KeyCharacterCase.AS_DEFINED) + assertNotNull(keyboard.findButtonWithText("q")) + assertNull(keyboard.findButtonWithText("Q")) + } + + @Test + fun characterCaseSetBeforeLayout_isAppliedWhenKeysAreCreated() { + val keyboard = createView() + + keyboard.setKeyCharacterCase(KeyCharacterCase.UPPERCASE) + keyboard.setKeyboard(KeyboardDefaultLayouts.createQwertyTemplateLayout()) + + assertNotNull(keyboard.findButtonWithText("Q")) + } + + @Test + fun nonInputShortcutLabel_isNotChanged() { + val keyboard = createView() + keyboard.setKeyboard( + KeyboardLayout( + keys = listOf( + KeyData( + label = "paste", + row = 0, + column = 0, + isFlickable = false, + action = KeyAction.Paste, + isSpecialKey = false, + keyId = "paste", + keyType = KeyType.NORMAL + ) + ), + flickKeyMaps = emptyMap(), + columnCount = 1, + rowCount = 1 + ) + ) + + keyboard.setKeyCharacterCase(KeyCharacterCase.UPPERCASE) + + assertNotNull(keyboard.findButtonWithText("paste")) + assertNull(keyboard.findButtonWithText("PASTE")) + } + + @Test + fun textActionLabel_isChangedEvenWhenItUsesSpecialKeyStyling() { + val keyboard = createView() + keyboard.setKeyboard( + KeyboardLayout( + keys = listOf( + KeyData( + label = "a", + row = 0, + column = 0, + isFlickable = false, + action = KeyAction.Text("a"), + isSpecialKey = true, + keyId = "styled_text", + keyType = KeyType.NORMAL + ) + ), + flickKeyMaps = emptyMap(), + columnCount = 1, + rowCount = 1 + ) + ) + + keyboard.setKeyCharacterCase(KeyCharacterCase.UPPERCASE) + + assertNotNull(keyboard.findButtonWithText("A")) + } + + @Test + fun englishFlickGuideTapLabel_followsCharacterCase() { + val keyboard = createView() + keyboard.setFlickGuideEnabled(enabled = true, allowMultiCharacterLabels = true) + keyboard.setKeyboard( + KeyboardDefaultLayouts.createFinalLayout( + mode = KeyboardInputMode.ENGLISH, + dynamicKeyStates = emptyMap(), + inputLayoutType = "flick", + inputStyle = "default" + ) + ) + assertNotNull(keyboard.findButtonWithText("a")) + + keyboard.setKeyCharacterCase(KeyCharacterCase.UPPERCASE) + assertNotNull(keyboard.findButtonWithText("A")) + assertNull(keyboard.findButtonWithText("a")) + + keyboard.setKeyCharacterCase(KeyCharacterCase.AS_DEFINED) + assertNotNull(keyboard.findButtonWithText("a")) + } + + private fun createView(): FlickKeyboardView { + val context = ApplicationProvider.getApplicationContext() + return FlickKeyboardView( + ContextThemeWrapper( + context, + com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar + ) + ) + } + + private fun FlickKeyboardView.findButtonWithText(text: String): AutoSizeButton? { + for (index in 0 until childCount) { + val button = getChildAt(index) as? AutoSizeButton ?: continue + if (button.text.toString() == text) return button + } + return null + } +} diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewReuseTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewReuseTest.kt new file mode 100644 index 000000000..4e7a860b6 --- /dev/null +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewReuseTest.kt @@ -0,0 +1,55 @@ +package com.kazumaproject.custom_keyboard.view + +import android.view.ContextThemeWrapper +import androidx.test.core.app.ApplicationProvider +import com.kazumaproject.custom_keyboard.data.KeyboardInputMode +import com.kazumaproject.custom_keyboard.layout.KeyboardDefaultLayouts +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class FlickKeyboardViewReuseTest { + @Test + fun equivalentLayoutReusesExistingKeyViews() { + val view = createView() + view.setKeyboard(createLayout()) + val firstKey = view.getChildAt(0) + + view.setKeyboard(createLayout()) + + assertSame(firstKey, view.getChildAt(0)) + } + + @Test + fun renderingPreferenceChangeStillRebuildsKeyViews() { + val view = createView() + view.setKeyboard(createLayout()) + val firstKey = view.getChildAt(0) + + view.setFlickGuideEnabled(enabled = true, allowMultiCharacterLabels = true) + + assertNotSame(firstKey, view.getChildAt(0)) + } + + private fun createView(): FlickKeyboardView { + val context = ApplicationProvider.getApplicationContext() + return FlickKeyboardView( + ContextThemeWrapper( + context, + com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar, + ), + ) + } + + private fun createLayout() = KeyboardDefaultLayouts.createFinalLayout( + mode = KeyboardInputMode.HIRAGANA, + dynamicKeyStates = emptyMap(), + inputLayoutType = "toggle", + inputStyle = "default", + ) +} diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTouchDispatchTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTouchDispatchTest.kt index cc0eaa778..3d8c5db7e 100644 --- a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTouchDispatchTest.kt +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTouchDispatchTest.kt @@ -2,13 +2,17 @@ package com.kazumaproject.custom_keyboard.view import android.content.Context import android.graphics.Rect +import android.os.Looper import android.view.ContextThemeWrapper import android.view.MotionEvent import android.view.View import androidx.test.core.app.ApplicationProvider import com.kazumaproject.custom_keyboard.data.FlickAction import com.kazumaproject.custom_keyboard.data.FlickDirection +import com.kazumaproject.custom_keyboard.data.DoubleTapBinding +import com.kazumaproject.custom_keyboard.data.DoubleTapPolicy import com.kazumaproject.custom_keyboard.data.KeyAction +import com.kazumaproject.custom_keyboard.data.KeyCharacterCase import com.kazumaproject.custom_keyboard.data.KeyData import com.kazumaproject.custom_keyboard.data.KeyType import com.kazumaproject.custom_keyboard.data.KeyboardLayout @@ -17,7 +21,9 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config +import java.util.concurrent.TimeUnit @RunWith(RobolectricTestRunner::class) @Config(sdk = [35]) @@ -62,6 +68,72 @@ class FlickKeyboardViewTouchDispatchTest { assertFalse(listener.actions.contains(KeyAction.Paste)) } + @Test + fun normalShiftKey_doubleTap_dispatchesShiftThenCapsLock() { + val listener = RecordingKeyboardActionListener() + val keyboardView = keyboardView(listener) + keyboardView.setKeyboard(shiftKeyLayout()) + layoutKeyboard(keyboardView) + val shiftKey = keyboardView.getChildAt(0) + val x = shiftKey.centerX() + val y = shiftKey.centerY() + + keyboardView.dispatchTouch(MotionEvent.ACTION_DOWN, x, y, 100L, 100L) + keyboardView.dispatchTouch(MotionEvent.ACTION_UP, x, y, 100L, 120L) + shadowOf(Looper.getMainLooper()).idleFor(100, TimeUnit.MILLISECONDS) + keyboardView.dispatchTouch(MotionEvent.ACTION_DOWN, x, y, 220L, 220L) + keyboardView.dispatchTouch(MotionEvent.ACTION_UP, x, y, 220L, 240L) + + assertTrue(listener.actions == listOf(KeyAction.ShiftKey, KeyAction.CapLockKey)) + } + + @Test + fun characterCaseRefreshAfterFirstShiftTap_preservesSecondTapRecognition() { + lateinit var keyboardView: FlickKeyboardView + val listener = RecordingKeyboardActionListener { action -> + if (action == KeyAction.ShiftKey) { + keyboardView.setKeyCharacterCase(KeyCharacterCase.UPPERCASE) + } + } + keyboardView = keyboardView(listener) + keyboardView.setKeyboard(shiftKeyLayout()) + layoutKeyboard(keyboardView) + val shiftKey = keyboardView.getChildAt(0) + val x = shiftKey.centerX() + val y = shiftKey.centerY() + + keyboardView.dispatchTouch(MotionEvent.ACTION_DOWN, x, y, 300L, 300L) + keyboardView.dispatchTouch(MotionEvent.ACTION_UP, x, y, 300L, 320L) + shadowOf(Looper.getMainLooper()).idleFor(100, TimeUnit.MILLISECONDS) + keyboardView.dispatchTouch(MotionEvent.ACTION_DOWN, x, y, 420L, 420L) + keyboardView.dispatchTouch(MotionEvent.ACTION_UP, x, y, 420L, 440L) + + assertTrue(listener.actions == listOf(KeyAction.ShiftKey, KeyAction.CapLockKey)) + } + + @Test + fun normalCharacterKey_ignoresDoubleTapBindingAndDispatchesBothTaps() { + val listener = RecordingKeyboardActionListener() + val keyboardView = keyboardView(listener) + keyboardView.setKeyboard(normalCharacterKeyWithInvalidBindingLayout()) + layoutKeyboard(keyboardView) + val characterKey = keyboardView.getChildAt(0) + val x = characterKey.centerX() + val y = characterKey.centerY() + + keyboardView.dispatchTouch(MotionEvent.ACTION_DOWN, x, y, 500L, 500L) + keyboardView.dispatchTouch(MotionEvent.ACTION_UP, x, y, 500L, 520L) + keyboardView.dispatchTouch(MotionEvent.ACTION_DOWN, x, y, 620L, 620L) + keyboardView.dispatchTouch(MotionEvent.ACTION_UP, x, y, 620L, 640L) + + assertTrue( + listener.actions == listOf( + KeyAction.Text("a"), + KeyAction.Text("a") + ) + ) + } + private fun keyboardView( listener: FlickKeyboardView.OnKeyboardActionListener ): FlickKeyboardView { @@ -97,6 +169,52 @@ class FlickKeyboardViewTouchDispatchTest { ) } + private fun shiftKeyLayout(): KeyboardLayout { + val shiftKey = KeyData( + label = "", + row = 0, + column = 0, + isFlickable = false, + action = KeyAction.ShiftKey, + isSpecialKey = true, + keyId = "shift", + keyType = KeyType.NORMAL, + doubleTapBinding = DoubleTapBinding( + KeyAction.CapLockKey, + DoubleTapPolicy.PROMOTE + ) + ) + return KeyboardLayout( + keys = listOf(shiftKey), + flickKeyMaps = emptyMap(), + columnCount = 1, + rowCount = 1 + ) + } + + private fun normalCharacterKeyWithInvalidBindingLayout(): KeyboardLayout { + val characterKey = KeyData( + label = "a", + row = 0, + column = 0, + isFlickable = false, + action = KeyAction.Text("a"), + isSpecialKey = false, + keyId = "a", + keyType = KeyType.NORMAL, + doubleTapBinding = DoubleTapBinding( + KeyAction.Copy, + DoubleTapPolicy.PROMOTE + ) + ) + return KeyboardLayout( + keys = listOf(characterKey), + flickKeyMaps = emptyMap(), + columnCount = 1, + rowCount = 1 + ) + } + private fun layoutKeyboard(keyboardView: FlickKeyboardView) { val widthSpec = View.MeasureSpec.makeMeasureSpec(240, View.MeasureSpec.EXACTLY) val heightSpec = View.MeasureSpec.makeMeasureSpec(120, View.MeasureSpec.EXACTLY) @@ -120,13 +238,16 @@ class FlickKeyboardViewTouchDispatchTest { private fun View.centerY(): Float = top + height / 2f - private class RecordingKeyboardActionListener : FlickKeyboardView.OnKeyboardActionListener { + private class RecordingKeyboardActionListener( + private val afterAction: (KeyAction) -> Unit = {} + ) : FlickKeyboardView.OnKeyboardActionListener { val actions = mutableListOf() override fun onPress(action: KeyAction) = Unit override fun onAction(action: KeyAction, isFlick: Boolean) { actions += action + afterAction(action) } override fun onActionLongPress(action: KeyAction) = Unit diff --git a/docs/flick-editor-preview-design.md b/docs/flick-editor-preview-design.md new file mode 100644 index 000000000..919e0efef --- /dev/null +++ b/docs/flick-editor-preview-design.md @@ -0,0 +1,699 @@ +# フリック選択文字の入力欄プレビュー設計 + +## 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 new file mode 100644 index 000000000..1ad647766 --- /dev/null +++ b/docs/flick-editor-preview-implementation-report-2026-08-02.md @@ -0,0 +1,93 @@ +# フリック文字プレビュー 実装・実機試験報告 + +試験日: 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/symbol_keyboard/src/main/java/com/kazumaproject/data/emoji/EmojiCategory.kt b/symbol_keyboard/src/main/java/com/kazumaproject/data/emoji/EmojiCategory.kt index c3cb62d30..e115052bb 100644 --- a/symbol_keyboard/src/main/java/com/kazumaproject/data/emoji/EmojiCategory.kt +++ b/symbol_keyboard/src/main/java/com/kazumaproject/data/emoji/EmojiCategory.kt @@ -1,20 +1,15 @@ package com.kazumaproject.data.emoji -/** - * Unicode の絵文字群を大まかに以下のように分類します。 - * 実際の Unicode 仕様ではさらに細かく分かれていますが、 - * アプリでよく使われる大分類として参考にしてください。 - */ +/** Unicode Emoji の CLDR 順序に含まれるトップレベルグループ。 */ enum class EmojiCategory { - EMOTICONS, // 😀〜🙏 のような「顔文字」系 (U+1F600–1F64F) - GESTURES, // 👍👎✊✌️など「手や指・ジェスチャー」系 (U+1F590–1F5FF、U+1F90C–1F91F、U+2700–27BF など) - PEOPLE_BODY, // 👦👧👮🧕 など「人・体のパーツ」系 (U+1F466–1F487、U+1F9D0–1F9FF など) - ANIMALS_NATURE, // 🐶🐱🌲🌻 など「動物・自然」系 (U+1F300–1F5FF の一部、U+1F900–1F9FF の動植物範囲) - FOOD_DRINK, // 🍎🍔☕️ など「食べ物・飲み物」系 (U+1F34F–1F37F、U+1F950–1F96F など) - TRAVEL_PLACES, // 🚗✈️🏝 など「乗り物・場所」系 (U+1F680–1F6FF、U+1F3E0–1F3FF など) - ACTIVITIES, // ⚽️🎮🎉 など「アクティビティ・イベント」系 (U+26F0–26FF、U+1F3A0–1F3FF など) - OBJECTS, // 💡📱🔑 など「モノ一般」系 (U+1F4A0–1F4FF、U+1F500–1F5FF の一部) - SYMBOLS, // ♻️✅🔣 など「記号・シンボル」系 (U+2600–26FF、U+1F300–1F5FF の一部、U+1F7E0–1F7EB など) - FLAGS, // 🇯🇵🇺🇸 など「旗」系 (U+1F1E6–1F1FF) - UNKNOWN // どの範囲にも当てはまらない場合 + SMILEYS_EMOTION, + PEOPLE_BODY, + ANIMALS_NATURE, + FOOD_DRINK, + TRAVEL_PLACES, + ACTIVITIES, + OBJECTS, + SYMBOLS, + FLAGS, + UNKNOWN, } diff --git a/symbol_keyboard/src/main/java/com/kazumaproject/domain/Emoji.kt b/symbol_keyboard/src/main/java/com/kazumaproject/domain/Emoji.kt index ea416b8b7..a036adb9f 100644 --- a/symbol_keyboard/src/main/java/com/kazumaproject/domain/Emoji.kt +++ b/symbol_keyboard/src/main/java/com/kazumaproject/domain/Emoji.kt @@ -5,219 +5,18 @@ import com.kazumaproject.data.emoji.EmojiCategory import com.kazumaproject.data.emoticon.EmoticonCategory import com.kazumaproject.data.symbol.SymbolCategory -private val hardMap: Map = buildMap { - putAll( - codePoints( - 0x263A, 0x2639, - 0x1F600..0x1F64F, 0x1F910..0x1F93A, 0x1F97A..0x1F97A, 0x1F9D0..0x1F9D0, - 0x1F971, 0x1F974, 0x1F972, 0x1F973, 0x1F975, 0x1F976, 0x1F978, 0x1F979, - 0x1FAE0..0x1FAE5, 0x1FAE8 - ), - EmojiCategory.EMOTICONS - ) - - putAll( - codePoints( - 0x270A..0x270D, - 0x1F44A..0x1F4AA, - 0x1F590..0x1F590, - 0x1F91A..0x1F91F, - 0x1FAF0..0x1FAF8, - 0x1F64C, - 0x1F64F, - 0x1F919, - 0x1F918, - 0x1F933, - 0x1F596, - 0x1F595, - 0x1F440, - 0x1F441, - 0x1F442, - 0x1F443, - 0x1F444, - 0x1F445, - 0x1F446, - 0x1F447, - 0x1F448, - 0x1F449, - 0x261D, - 0x1F90C, - 0x1F90F - ), - EmojiCategory.GESTURES - ) - - putAll( - codePoints( - 0x1F319..0x1F31F, - 0x1F466..0x1F487, - 0x1F9B0..0x1F9B3, - 0x1FAC0..0x1FAC5, - 0x1F385, - 0x1F977, - 0x1F9B5, - 0x1F9B6, - 0x1F9B8, - 0x1F9B9, - 0x1F9BF, - 0x1F9BE, - 0x1F9CD, - 0x1F9CE, - 0x1F9CF, - 0x1FAE6, - 0x1F9E0 - ), - EmojiCategory.PEOPLE_BODY - ) - - putAll( - codePoints( - 0x1F43F, - 0x1F9E3, - 0x1F9E5, - 0x1F9A6, - 0x1F9A4, - 0x1F9A7, - 0x1F9A8, - 0x1F9A9, - 0x1F9AB, - 0x1F9AA, - 0x1FAB0, - 0x1FAB1, - 0x1FAB2, - 0x1FAB3, - 0x1FAB4, - 0x1FAB5, - 0x1FAB6, - 0x1FAB7, - 0x1FAB8, - 0x1FABB, - 0x1FABC, - 0x1FABD, - 0x1FABF, - 0x1FACE, - 0x1FACF, - 0x1FAD0, - 0x1FAD1, - 0x1FAD2, - 0x1FAD3, - 0x1FADA, - 0x1FADB, - 0x1FAD8 - ), - EmojiCategory.ANIMALS_NATURE - ) - - putAll( - codePoints(0x1F34F..0x1F37F, 0x1F950..0x1F96F), - EmojiCategory.FOOD_DRINK - ) +fun categorizeEmoji(emoji: String): EmojiCategory = + EmojiCategoryMetadata.emojiCategoryOf(emoji) - putAll( - codePoints(0x1F680..0x1F6FF, 0x1F3E0..0x1F3FF), - EmojiCategory.TRAVEL_PLACES - ) - - putAll( - codePoints(0x26F0..0x26FF, 0x1F3A0..0x1F3CA, 0x1F3C5..0x1F3FA), - EmojiCategory.ACTIVITIES - ) - - putAll( - codePoints(0x1F4A0..0x1F4FF, 0x1F5A4..0x1F5FF), - EmojiCategory.OBJECTS - ) - - putAll( - codePoints(0x2600..0x26FF, 0x1F300..0x1F318, 0x1F320..0x1F335, 0x1F7E0..0x1F7EB), - EmojiCategory.SYMBOLS - ) -} - -private fun MutableMap.putAll(points: Iterable, cat: EmojiCategory) = - points.forEach { this[it] = cat } - -fun categorizeEmoji(emoji: String): EmojiCategory { - if (emoji.isEmpty()) return EmojiCategory.UNKNOWN - val cp = emoji.codePointAt(0) - hardMap[cp]?.let { return it } - return when { - (cp in 0x1F600..0x1F64F) - || (cp in 0x1F300..0x1F323) - || (cp in 0x1F900..0x1F9FF && cp in listOf( - 0x1F916, 0x1F917, 0x1F918, 0x1F919, 0x1F91A, 0x1F91B, - 0x1F91C, 0x1F91E, 0x1F91F, 0x1F920..0x1F927, - 0x1F970..0x1F97F, 0x1F9D0..0x1F9E3 - )) - -> EmojiCategory.EMOTICONS - - (cp in 0x1F44A..0x1F44F) - || (cp in 0x270A..0x270D) - || (cp in 0x1F91A..0x1F91F) - || (cp in 0x1FAF0..0x1FAF8) - -> EmojiCategory.GESTURES - - (cp in 0x1F466..0x1F487) - || (cp in 0x1F9B0..0x1F9B3) - || (cp in 0x1F9D1..0x1F9DF) - -> EmojiCategory.PEOPLE_BODY - - (cp in 0x1F400..0x1F43E) - || (cp in 0x1F980..0x1F997) - || (cp in 0x1F995..0x1F9A2) - || (cp in 0x1F9AC..0x1F9AE) - || (cp in 0x1F331..0x1F37C) - -> EmojiCategory.ANIMALS_NATURE - - (cp in 0x1F347..0x1F37F) - || (cp in 0x1F950..0x1F96F) - || (cp in 0x1F9C0..0x1F9C2) - -> EmojiCategory.FOOD_DRINK - - (cp in 0x1F680..0x1F6FF) - || (cp in 0x1F3E0..0x1F3FF) - || (cp in 0x1F5FA..0x1F5FF) - || (cp in 0x2600..0x26FF) - -> EmojiCategory.TRAVEL_PLACES - - (cp in 0x26F0..0x26FF) - || (cp in 0x1F3A0..0x1F3CA) - || (cp in 0x1F3C5..0x1F3FA) - || (cp in 0x1FA80..0x1FA9F) - -> EmojiCategory.ACTIVITIES - - (cp in 0x1F4A0..0x1F4FF) - || (cp in 0x1F500..0x1F5FF) - || (cp in 0x1FA9A..0x1FA9F) - || (cp in 0x1F52E..0x1F54A) - -> EmojiCategory.OBJECTS - - (cp in 0x2600..0x26FF) - || (cp in 0x1F300..0x1F5FF) - || (cp in 0x1F7E0..0x1F7EB) - || (cp in 0x2B50..0x2B55) - || (cp in 0x1F191..0x1F19A) - || (cp in 0x2753..0x2757) - -> EmojiCategory.SYMBOLS - - (cp in 0x1F1E6..0x1F1FF) - -> EmojiCategory.FLAGS - - else -> EmojiCategory.UNKNOWN - } -} - -private fun codePoints(vararg items: Any): List = buildList { - items.forEach { elem -> - when (elem) { - is Int -> add(elem) - is IntRange -> addAll(elem) - } - } -} +internal fun emojiSortOrder(emoji: String): Int = + EmojiCategoryMetadata.emojiOrderOf(emoji) fun List.sortByEmojiCategory(): List = - sortedWith(compareBy { it.category.ordinal }.thenBy { it.symbol }) + sortedWith( + compareBy { it.category.ordinal } + .thenBy { emojiSortOrder(it.symbol) } + .thenBy { it.symbol } + ) private val emoticonCategoryMap = mapOf( "(๑•̀ㅁ•́๑)✧" to EmoticonCategory.SMILE, diff --git a/symbol_keyboard/src/main/java/com/kazumaproject/domain/EmojiCategoryMetadata.kt b/symbol_keyboard/src/main/java/com/kazumaproject/domain/EmojiCategoryMetadata.kt new file mode 100644 index 000000000..d6134e80b --- /dev/null +++ b/symbol_keyboard/src/main/java/com/kazumaproject/domain/EmojiCategoryMetadata.kt @@ -0,0 +1,5313 @@ +// Generated by tools/emoji_metadata/generate_category_metadata.py. Do not edit manually. +// Unicode Emoji 17.0: https://www.unicode.org/Public/17.0.0/emoji/emoji-test.txt +package com.kazumaproject.domain + +import com.kazumaproject.data.emoji.EmojiCategory + +internal object EmojiCategoryMetadata { + const val UNICODE_VERSION: String = "17.0" + + private data class EmojiMetadata(val category: EmojiCategory, val order: Int) + + private val emojiIndex: Map by lazy(LazyThreadSafetyMode.PUBLICATION) { + val result = HashMap(10432) + var order = 0 + emojiDataChunks.forEach { chunk -> + chunk.lineSequence().filter(String::isNotBlank).forEach { line -> + val separator = line.indexOf(':') + val category = emojiCategory(line.substring(0, separator)) + val symbol = decodeCodePoints(line, separator + 1) + result.putIfAbsent(symbol, EmojiMetadata(category, order)) + order++ + } + } + result + } + + // U+FE0E/U+FE0F select text/emoji presentation; they do not change the + // Unicode/CLDR group. Some bundled entries add U+FE0F to characters whose + // fully-qualified emoji-test sequence does not contain a selector. + private val presentationIndependentEmojiIndex: Map by + lazy(LazyThreadSafetyMode.PUBLICATION) { + val result = HashMap(emojiIndex.size * 2) + emojiIndex.forEach { (symbol, metadata) -> + val key = symbol.withoutPresentationSelectors() + val existing = result[key] + check(existing == null || existing.category == metadata.category) { + "Presentation-independent emoji category collision for $key" + } + if (existing == null || metadata.order < existing.order) { + result[key] = metadata + } + } + result + } + + fun emojiCategoryOf(symbol: String): EmojiCategory = + emojiMetadataOf(symbol)?.category ?: EmojiCategory.UNKNOWN + + fun emojiOrderOf(symbol: String): Int = emojiMetadataOf(symbol)?.order ?: Int.MAX_VALUE + + internal fun emojiEntryCount(): Int = emojiIndex.size + + private fun emojiMetadataOf(symbol: String): EmojiMetadata? = + emojiIndex[symbol] + ?: presentationIndependentEmojiIndex[symbol.withoutPresentationSelectors()] + + private fun String.withoutPresentationSelectors(): String = + replace("\uFE0E", "").replace("\uFE0F", "") + + private fun emojiCategory(code: String): EmojiCategory = when (code) { + "S" -> EmojiCategory.SMILEYS_EMOTION + "P" -> EmojiCategory.PEOPLE_BODY + "N" -> EmojiCategory.ANIMALS_NATURE + "F" -> EmojiCategory.FOOD_DRINK + "T" -> EmojiCategory.TRAVEL_PLACES + "A" -> EmojiCategory.ACTIVITIES + "O" -> EmojiCategory.OBJECTS + "Y" -> EmojiCategory.SYMBOLS + "L" -> EmojiCategory.FLAGS + else -> error("Unknown emoji category code: $code") + } + + private fun decodeCodePoints(line: String, startIndex: Int): String { + val result = StringBuilder() + var tokenStart = startIndex + var index = startIndex + while (index <= line.length) { + if (index == line.length || line[index] == ' ') { + if (index > tokenStart) { + result.appendCodePoint(line.substring(tokenStart, index).toInt(16)) + } + tokenStart = index + 1 + } + index++ + } + return result.toString() + } + + private val emojiDataChunks = arrayOf( + """ +S:1F600 +S:1F603 +S:1F604 +S:1F601 +S:1F606 +S:1F605 +S:1F923 +S:1F602 +S:1F642 +S:1F643 +S:1FAE0 +S:1F609 +S:1F60A +S:1F607 +S:1F970 +S:1F60D +S:1F929 +S:1F618 +S:1F617 +S:263A FE0F +S:263A +S:1F61A +S:1F619 +S:1F972 +S:1F60B +S:1F61B +S:1F61C +S:1F92A +S:1F61D +S:1F911 +S:1F917 +S:1F92D +S:1FAE2 +S:1FAE3 +S:1F92B +S:1F914 +S:1FAE1 +S:1F910 +S:1F928 +S:1F610 +S:1F611 +S:1F636 +S:1FAE5 +S:1F636 200D 1F32B FE0F +S:1F636 200D 1F32B +S:1F60F +S:1F612 +S:1F644 +S:1F62C +S:1F62E 200D 1F4A8 +S:1F925 +S:1FAE8 +S:1F642 200D 2194 FE0F +S:1F642 200D 2194 +S:1F642 200D 2195 FE0F +S:1F642 200D 2195 +S:1F60C +S:1F614 +S:1F62A +S:1F924 +S:1F634 +S:1FAE9 +S:1F637 +S:1F912 +S:1F915 +S:1F922 +S:1F92E +S:1F927 +S:1F975 +S:1F976 +S:1F974 +S:1F635 +S:1F635 200D 1F4AB +S:1F92F +S:1F920 +S:1F973 +S:1F978 +S:1F60E +S:1F913 +S:1F9D0 +S:1F615 +S:1FAE4 +S:1F61F +S:1F641 +S:2639 FE0F +S:2639 +S:1F62E +S:1F62F +S:1F632 +S:1F633 +S:1FAEA +S:1F97A +S:1F979 +S:1F626 +S:1F627 +S:1F628 +S:1F630 +S:1F625 +S:1F622 +S:1F62D +S:1F631 +S:1F616 +S:1F623 +S:1F61E +S:1F613 +S:1F629 +S:1F62B +S:1F971 +S:1F624 +S:1F621 +S:1F620 +S:1F92C +S:1F608 +S:1F47F +S:1F480 +S:2620 FE0F +S:2620 +S:1F4A9 +S:1F921 +S:1F479 +S:1F47A +S:1F47B +S:1F47D +S:1F47E +S:1F916 +S:1F63A +S:1F638 +S:1F639 +S:1F63B +S:1F63C +S:1F63D +S:1F640 +S:1F63F +S:1F63E +S:1F648 +S:1F649 +S:1F64A +S:1F48C +S:1F498 +S:1F49D +S:1F496 +S:1F497 +S:1F493 +S:1F49E +S:1F495 +S:1F49F +S:2763 FE0F +S:2763 +S:1F494 +S:2764 FE0F 200D 1F525 +S:2764 200D 1F525 +S:2764 FE0F 200D 1FA79 +S:2764 200D 1FA79 +S:2764 FE0F +S:2764 +S:1FA77 +S:1F9E1 +S:1F49B +S:1F49A +S:1F499 +S:1FA75 +S:1F49C +S:1F90E +S:1F5A4 +S:1FA76 +S:1F90D +S:1F48B +S:1F4AF +S:1F4A2 +S:1FAEF +S:1F4A5 +S:1F4AB +S:1F4A6 +S:1F4A8 +S:1F573 FE0F +S:1F573 +S:1F4AC +S:1F441 FE0F 200D 1F5E8 FE0F +S:1F441 200D 1F5E8 FE0F +S:1F441 FE0F 200D 1F5E8 +S:1F441 200D 1F5E8 +S:1F5E8 FE0F +S:1F5E8 +S:1F5EF FE0F +S:1F5EF +S:1F4AD +S:1F4A4 +P:1F44B +P:1F44B 1F3FB +P:1F44B 1F3FC +P:1F44B 1F3FD +P:1F44B 1F3FE +P:1F44B 1F3FF +P:1F91A +P:1F91A 1F3FB +P:1F91A 1F3FC +P:1F91A 1F3FD +P:1F91A 1F3FE +P:1F91A 1F3FF +P:1F590 FE0F +P:1F590 +P:1F590 1F3FB +P:1F590 1F3FC +P:1F590 1F3FD +P:1F590 1F3FE +P:1F590 1F3FF +P:270B +P:270B 1F3FB +P:270B 1F3FC +P:270B 1F3FD +P:270B 1F3FE +P:270B 1F3FF +P:1F596 +P:1F596 1F3FB +P:1F596 1F3FC +P:1F596 1F3FD +P:1F596 1F3FE +P:1F596 1F3FF +P:1FAF1 +P:1FAF1 1F3FB +P:1FAF1 1F3FC +P:1FAF1 1F3FD +P:1FAF1 1F3FE +P:1FAF1 1F3FF +P:1FAF2 +P:1FAF2 1F3FB +P:1FAF2 1F3FC +P:1FAF2 1F3FD +P:1FAF2 1F3FE +P:1FAF2 1F3FF +P:1FAF3 +P:1FAF3 1F3FB +P:1FAF3 1F3FC +P:1FAF3 1F3FD +P:1FAF3 1F3FE +P:1FAF3 1F3FF +P:1FAF4 +P:1FAF4 1F3FB +P:1FAF4 1F3FC +P:1FAF4 1F3FD +P:1FAF4 1F3FE +P:1FAF4 1F3FF +P:1FAF7 +P:1FAF7 1F3FB +P:1FAF7 1F3FC +P:1FAF7 1F3FD +P:1FAF7 1F3FE +P:1FAF7 1F3FF +P:1FAF8 +P:1FAF8 1F3FB +P:1FAF8 1F3FC +P:1FAF8 1F3FD +P:1FAF8 1F3FE +P:1FAF8 1F3FF +P:1F44C +P:1F44C 1F3FB +P:1F44C 1F3FC +P:1F44C 1F3FD +P:1F44C 1F3FE +P:1F44C 1F3FF +P:1F90C +P:1F90C 1F3FB +P:1F90C 1F3FC +P:1F90C 1F3FD +P:1F90C 1F3FE +P:1F90C 1F3FF +P:1F90F +P:1F90F 1F3FB +P:1F90F 1F3FC +P:1F90F 1F3FD +P:1F90F 1F3FE +P:1F90F 1F3FF +P:270C FE0F +P:270C +P:270C 1F3FB +P:270C 1F3FC +P:270C 1F3FD +P:270C 1F3FE +P:270C 1F3FF +P:1F91E +P:1F91E 1F3FB +P:1F91E 1F3FC +P:1F91E 1F3FD +P:1F91E 1F3FE +P:1F91E 1F3FF +P:1FAF0 +P:1FAF0 1F3FB +P:1FAF0 1F3FC +P:1FAF0 1F3FD +P:1FAF0 1F3FE +P:1FAF0 1F3FF +P:1F91F +P:1F91F 1F3FB +P:1F91F 1F3FC +P:1F91F 1F3FD +P:1F91F 1F3FE +P:1F91F 1F3FF +P:1F918 +P:1F918 1F3FB +P:1F918 1F3FC +P:1F918 1F3FD +P:1F918 1F3FE +P:1F918 1F3FF +P:1F919 +P:1F919 1F3FB +P:1F919 1F3FC +P:1F919 1F3FD +P:1F919 1F3FE +P:1F919 1F3FF +P:1F448 +P:1F448 1F3FB +P:1F448 1F3FC +P:1F448 1F3FD +P:1F448 1F3FE +P:1F448 1F3FF +P:1F449 +P:1F449 1F3FB +P:1F449 1F3FC +P:1F449 1F3FD +P:1F449 1F3FE +P:1F449 1F3FF +P:1F446 +P:1F446 1F3FB +P:1F446 1F3FC +P:1F446 1F3FD +P:1F446 1F3FE +P:1F446 1F3FF +P:1F595 +P:1F595 1F3FB +P:1F595 1F3FC +P:1F595 1F3FD +P:1F595 1F3FE +P:1F595 1F3FF +P:1F447 +P:1F447 1F3FB +P:1F447 1F3FC +P:1F447 1F3FD +P:1F447 1F3FE +P:1F447 1F3FF +P:261D FE0F +P:261D +P:261D 1F3FB +P:261D 1F3FC +P:261D 1F3FD +P:261D 1F3FE +P:261D 1F3FF +P:1FAF5 +P:1FAF5 1F3FB +P:1FAF5 1F3FC +P:1FAF5 1F3FD +P:1FAF5 1F3FE +P:1FAF5 1F3FF +P:1F44D +P:1F44D 1F3FB +P:1F44D 1F3FC +P:1F44D 1F3FD +P:1F44D 1F3FE +P:1F44D 1F3FF +P:1F44E +P:1F44E 1F3FB +P:1F44E 1F3FC +P:1F44E 1F3FD +P:1F44E 1F3FE +P:1F44E 1F3FF +P:270A +P:270A 1F3FB +P:270A 1F3FC +P:270A 1F3FD +P:270A 1F3FE +P:270A 1F3FF +P:1F44A +P:1F44A 1F3FB +P:1F44A 1F3FC +P:1F44A 1F3FD +P:1F44A 1F3FE +P:1F44A 1F3FF +P:1F91B +P:1F91B 1F3FB +P:1F91B 1F3FC +P:1F91B 1F3FD +P:1F91B 1F3FE +P:1F91B 1F3FF +P:1F91C +P:1F91C 1F3FB +P:1F91C 1F3FC +P:1F91C 1F3FD +P:1F91C 1F3FE +P:1F91C 1F3FF +P:1F44F +P:1F44F 1F3FB +P:1F44F 1F3FC +P:1F44F 1F3FD +P:1F44F 1F3FE +P:1F44F 1F3FF +P:1F64C +P:1F64C 1F3FB +P:1F64C 1F3FC +P:1F64C 1F3FD +P:1F64C 1F3FE +P:1F64C 1F3FF +P:1FAF6 +P:1FAF6 1F3FB +P:1FAF6 1F3FC +P:1FAF6 1F3FD +P:1FAF6 1F3FE +P:1FAF6 1F3FF +P:1F450 +P:1F450 1F3FB +P:1F450 1F3FC +P:1F450 1F3FD +P:1F450 1F3FE +P:1F450 1F3FF +P:1F932 +P:1F932 1F3FB +P:1F932 1F3FC +P:1F932 1F3FD +P:1F932 1F3FE +P:1F932 1F3FF +P:1F91D +P:1F91D 1F3FB +P:1F91D 1F3FC +P:1F91D 1F3FD +P:1F91D 1F3FE +P:1F91D 1F3FF +P:1FAF1 1F3FB 200D 1FAF2 1F3FC +P:1FAF1 1F3FB 200D 1FAF2 1F3FD +P:1FAF1 1F3FB 200D 1FAF2 1F3FE +P:1FAF1 1F3FB 200D 1FAF2 1F3FF +P:1FAF1 1F3FC 200D 1FAF2 1F3FB +P:1FAF1 1F3FC 200D 1FAF2 1F3FD +P:1FAF1 1F3FC 200D 1FAF2 1F3FE +P:1FAF1 1F3FC 200D 1FAF2 1F3FF +P:1FAF1 1F3FD 200D 1FAF2 1F3FB +P:1FAF1 1F3FD 200D 1FAF2 1F3FC +P:1FAF1 1F3FD 200D 1FAF2 1F3FE +P:1FAF1 1F3FD 200D 1FAF2 1F3FF +P:1FAF1 1F3FE 200D 1FAF2 1F3FB +P:1FAF1 1F3FE 200D 1FAF2 1F3FC +P:1FAF1 1F3FE 200D 1FAF2 1F3FD +P:1FAF1 1F3FE 200D 1FAF2 1F3FF +P:1FAF1 1F3FF 200D 1FAF2 1F3FB +P:1FAF1 1F3FF 200D 1FAF2 1F3FC +P:1FAF1 1F3FF 200D 1FAF2 1F3FD +P:1FAF1 1F3FF 200D 1FAF2 1F3FE +P:1F64F +P:1F64F 1F3FB +P:1F64F 1F3FC +P:1F64F 1F3FD +P:1F64F 1F3FE +P:1F64F 1F3FF +P:270D FE0F +P:270D +P:270D 1F3FB +P:270D 1F3FC +P:270D 1F3FD +P:270D 1F3FE +P:270D 1F3FF +P:1F485 +P:1F485 1F3FB +P:1F485 1F3FC +P:1F485 1F3FD +P:1F485 1F3FE +P:1F485 1F3FF +P:1F933 +P:1F933 1F3FB +P:1F933 1F3FC +P:1F933 1F3FD +P:1F933 1F3FE +P:1F933 1F3FF +P:1F4AA +P:1F4AA 1F3FB +P:1F4AA 1F3FC +P:1F4AA 1F3FD +P:1F4AA 1F3FE +P:1F4AA 1F3FF +P:1F9BE +P:1F9BF +P:1F9B5 +P:1F9B5 1F3FB +P:1F9B5 1F3FC +P:1F9B5 1F3FD +P:1F9B5 1F3FE +P:1F9B5 1F3FF +P:1F9B6 +P:1F9B6 1F3FB +P:1F9B6 1F3FC +P:1F9B6 1F3FD +P:1F9B6 1F3FE +P:1F9B6 1F3FF +P:1F442 +P:1F442 1F3FB +P:1F442 1F3FC +P:1F442 1F3FD +P:1F442 1F3FE +P:1F442 1F3FF +P:1F9BB +P:1F9BB 1F3FB +P:1F9BB 1F3FC +P:1F9BB 1F3FD +P:1F9BB 1F3FE +P:1F9BB 1F3FF +P:1F443 +P:1F443 1F3FB +P:1F443 1F3FC +P:1F443 1F3FD +P:1F443 1F3FE +P:1F443 1F3FF +P:1F9E0 +P:1FAC0 +P:1FAC1 +P:1F9B7 +P:1F9B4 +P:1F440 +P:1F441 FE0F +P:1F441 +P:1F445 +P:1F444 +P:1FAE6 +P:1F476 +P:1F476 1F3FB +P:1F476 1F3FC +P:1F476 1F3FD +P:1F476 1F3FE +P:1F476 1F3FF +P:1F9D2 +P:1F9D2 1F3FB +P:1F9D2 1F3FC +P:1F9D2 1F3FD +P:1F9D2 1F3FE +P:1F9D2 1F3FF +P:1F466 +P:1F466 1F3FB +P:1F466 1F3FC +P:1F466 1F3FD +P:1F466 1F3FE +P:1F466 1F3FF +P:1F467 +P:1F467 1F3FB +P:1F467 1F3FC +P:1F467 1F3FD +P:1F467 1F3FE +P:1F467 1F3FF +P:1F9D1 +P:1F9D1 1F3FB +P:1F9D1 1F3FC +P:1F9D1 1F3FD +P:1F9D1 1F3FE +P:1F9D1 1F3FF +P:1F471 +P:1F471 1F3FB +P:1F471 1F3FC +P:1F471 1F3FD +P:1F471 1F3FE +P:1F471 1F3FF +P:1F468 +P:1F468 1F3FB +P:1F468 1F3FC +P:1F468 1F3FD +P:1F468 1F3FE +P:1F468 1F3FF +P:1F9D4 +P:1F9D4 1F3FB +P:1F9D4 1F3FC +P:1F9D4 1F3FD +P:1F9D4 1F3FE +P:1F9D4 1F3FF +P:1F9D4 200D 2642 FE0F +P:1F9D4 200D 2642 +P:1F9D4 1F3FB 200D 2642 FE0F +P:1F9D4 1F3FB 200D 2642 +P:1F9D4 1F3FC 200D 2642 FE0F +P:1F9D4 1F3FC 200D 2642 +P:1F9D4 1F3FD 200D 2642 FE0F +P:1F9D4 1F3FD 200D 2642 +P:1F9D4 1F3FE 200D 2642 FE0F +P:1F9D4 1F3FE 200D 2642 +P:1F9D4 1F3FF 200D 2642 FE0F +P:1F9D4 1F3FF 200D 2642 +P:1F9D4 200D 2640 FE0F +P:1F9D4 200D 2640 +P:1F9D4 1F3FB 200D 2640 FE0F +P:1F9D4 1F3FB 200D 2640 +P:1F9D4 1F3FC 200D 2640 FE0F +P:1F9D4 1F3FC 200D 2640 +P:1F9D4 1F3FD 200D 2640 FE0F +P:1F9D4 1F3FD 200D 2640 +P:1F9D4 1F3FE 200D 2640 FE0F +P:1F9D4 1F3FE 200D 2640 +P:1F9D4 1F3FF 200D 2640 FE0F +P:1F9D4 1F3FF 200D 2640 +P:1F468 200D 1F9B0 +P:1F468 1F3FB 200D 1F9B0 +P:1F468 1F3FC 200D 1F9B0 +P:1F468 1F3FD 200D 1F9B0 +P:1F468 1F3FE 200D 1F9B0 +P:1F468 1F3FF 200D 1F9B0 +P:1F468 200D 1F9B1 +P:1F468 1F3FB 200D 1F9B1 +P:1F468 1F3FC 200D 1F9B1 +P:1F468 1F3FD 200D 1F9B1 +P:1F468 1F3FE 200D 1F9B1 +P:1F468 1F3FF 200D 1F9B1 +P:1F468 200D 1F9B3 +P:1F468 1F3FB 200D 1F9B3 +P:1F468 1F3FC 200D 1F9B3 +P:1F468 1F3FD 200D 1F9B3 +P:1F468 1F3FE 200D 1F9B3 +P:1F468 1F3FF 200D 1F9B3 +P:1F468 200D 1F9B2 +P:1F468 1F3FB 200D 1F9B2 +P:1F468 1F3FC 200D 1F9B2 +P:1F468 1F3FD 200D 1F9B2 +P:1F468 1F3FE 200D 1F9B2 +P:1F468 1F3FF 200D 1F9B2 +P:1F469 +P:1F469 1F3FB +P:1F469 1F3FC +P:1F469 1F3FD +P:1F469 1F3FE +P:1F469 1F3FF +P:1F469 200D 1F9B0 +P:1F469 1F3FB 200D 1F9B0 +P:1F469 1F3FC 200D 1F9B0 +P:1F469 1F3FD 200D 1F9B0 +P:1F469 1F3FE 200D 1F9B0 +P:1F469 1F3FF 200D 1F9B0 +P:1F9D1 200D 1F9B0 +P:1F9D1 1F3FB 200D 1F9B0 +P:1F9D1 1F3FC 200D 1F9B0 +P:1F9D1 1F3FD 200D 1F9B0 +P:1F9D1 1F3FE 200D 1F9B0 +P:1F9D1 1F3FF 200D 1F9B0 +P:1F469 200D 1F9B1 +P:1F469 1F3FB 200D 1F9B1 +P:1F469 1F3FC 200D 1F9B1 +P:1F469 1F3FD 200D 1F9B1 +P:1F469 1F3FE 200D 1F9B1 +P:1F469 1F3FF 200D 1F9B1 +P:1F9D1 200D 1F9B1 +P:1F9D1 1F3FB 200D 1F9B1 +P:1F9D1 1F3FC 200D 1F9B1 +P:1F9D1 1F3FD 200D 1F9B1 +P:1F9D1 1F3FE 200D 1F9B1 +P:1F9D1 1F3FF 200D 1F9B1 +P:1F469 200D 1F9B3 +P:1F469 1F3FB 200D 1F9B3 +P:1F469 1F3FC 200D 1F9B3 +P:1F469 1F3FD 200D 1F9B3 +P:1F469 1F3FE 200D 1F9B3 +P:1F469 1F3FF 200D 1F9B3 +P:1F9D1 200D 1F9B3 +P:1F9D1 1F3FB 200D 1F9B3 +P:1F9D1 1F3FC 200D 1F9B3 +P:1F9D1 1F3FD 200D 1F9B3 +P:1F9D1 1F3FE 200D 1F9B3 +P:1F9D1 1F3FF 200D 1F9B3 +P:1F469 200D 1F9B2 +P:1F469 1F3FB 200D 1F9B2 +P:1F469 1F3FC 200D 1F9B2 +P:1F469 1F3FD 200D 1F9B2 +P:1F469 1F3FE 200D 1F9B2 +P:1F469 1F3FF 200D 1F9B2 +P:1F9D1 200D 1F9B2 +P:1F9D1 1F3FB 200D 1F9B2 +P:1F9D1 1F3FC 200D 1F9B2 +P:1F9D1 1F3FD 200D 1F9B2 +P:1F9D1 1F3FE 200D 1F9B2 +P:1F9D1 1F3FF 200D 1F9B2 +P:1F471 200D 2640 FE0F +P:1F471 200D 2640 +P:1F471 1F3FB 200D 2640 FE0F +P:1F471 1F3FB 200D 2640 +P:1F471 1F3FC 200D 2640 FE0F +P:1F471 1F3FC 200D 2640 +P:1F471 1F3FD 200D 2640 FE0F +P:1F471 1F3FD 200D 2640 +P:1F471 1F3FE 200D 2640 FE0F +P:1F471 1F3FE 200D 2640 +P:1F471 1F3FF 200D 2640 FE0F +P:1F471 1F3FF 200D 2640 +P:1F471 200D 2642 FE0F +P:1F471 200D 2642 +P:1F471 1F3FB 200D 2642 FE0F +P:1F471 1F3FB 200D 2642 +P:1F471 1F3FC 200D 2642 FE0F +P:1F471 1F3FC 200D 2642 +P:1F471 1F3FD 200D 2642 FE0F +P:1F471 1F3FD 200D 2642 +P:1F471 1F3FE 200D 2642 FE0F +P:1F471 1F3FE 200D 2642 +P:1F471 1F3FF 200D 2642 FE0F +P:1F471 1F3FF 200D 2642 +P:1F9D3 +P:1F9D3 1F3FB +P:1F9D3 1F3FC +P:1F9D3 1F3FD +P:1F9D3 1F3FE +P:1F9D3 1F3FF +P:1F474 +P:1F474 1F3FB +P:1F474 1F3FC +P:1F474 1F3FD +P:1F474 1F3FE +P:1F474 1F3FF +P:1F475 +P:1F475 1F3FB +P:1F475 1F3FC +P:1F475 1F3FD +P:1F475 1F3FE +P:1F475 1F3FF +P:1F64D +P:1F64D 1F3FB +P:1F64D 1F3FC +P:1F64D 1F3FD +P:1F64D 1F3FE +P:1F64D 1F3FF +P:1F64D 200D 2642 FE0F +P:1F64D 200D 2642 +P:1F64D 1F3FB 200D 2642 FE0F +P:1F64D 1F3FB 200D 2642 +P:1F64D 1F3FC 200D 2642 FE0F +P:1F64D 1F3FC 200D 2642 +P:1F64D 1F3FD 200D 2642 FE0F +P:1F64D 1F3FD 200D 2642 +P:1F64D 1F3FE 200D 2642 FE0F +P:1F64D 1F3FE 200D 2642 +P:1F64D 1F3FF 200D 2642 FE0F +P:1F64D 1F3FF 200D 2642 +P:1F64D 200D 2640 FE0F +P:1F64D 200D 2640 +P:1F64D 1F3FB 200D 2640 FE0F +P:1F64D 1F3FB 200D 2640 +P:1F64D 1F3FC 200D 2640 FE0F +P:1F64D 1F3FC 200D 2640 +P:1F64D 1F3FD 200D 2640 FE0F +P:1F64D 1F3FD 200D 2640 +P:1F64D 1F3FE 200D 2640 FE0F +P:1F64D 1F3FE 200D 2640 +P:1F64D 1F3FF 200D 2640 FE0F +P:1F64D 1F3FF 200D 2640 +P:1F64E +P:1F64E 1F3FB +P:1F64E 1F3FC +P:1F64E 1F3FD +P:1F64E 1F3FE +P:1F64E 1F3FF +P:1F64E 200D 2642 FE0F +P:1F64E 200D 2642 +P:1F64E 1F3FB 200D 2642 FE0F +P:1F64E 1F3FB 200D 2642 +P:1F64E 1F3FC 200D 2642 FE0F +P:1F64E 1F3FC 200D 2642 +P:1F64E 1F3FD 200D 2642 FE0F +P:1F64E 1F3FD 200D 2642 +P:1F64E 1F3FE 200D 2642 FE0F +P:1F64E 1F3FE 200D 2642 +P:1F64E 1F3FF 200D 2642 FE0F +P:1F64E 1F3FF 200D 2642 +P:1F64E 200D 2640 FE0F +P:1F64E 200D 2640 +P:1F64E 1F3FB 200D 2640 FE0F +P:1F64E 1F3FB 200D 2640 +P:1F64E 1F3FC 200D 2640 FE0F +P:1F64E 1F3FC 200D 2640 +P:1F64E 1F3FD 200D 2640 FE0F +P:1F64E 1F3FD 200D 2640 +P:1F64E 1F3FE 200D 2640 FE0F +P:1F64E 1F3FE 200D 2640 +P:1F64E 1F3FF 200D 2640 FE0F +P:1F64E 1F3FF 200D 2640 +P:1F645 +P:1F645 1F3FB +P:1F645 1F3FC +P:1F645 1F3FD +P:1F645 1F3FE +P:1F645 1F3FF +P:1F645 200D 2642 FE0F +P:1F645 200D 2642 +P:1F645 1F3FB 200D 2642 FE0F +P:1F645 1F3FB 200D 2642 +P:1F645 1F3FC 200D 2642 FE0F +P:1F645 1F3FC 200D 2642 +P:1F645 1F3FD 200D 2642 FE0F +P:1F645 1F3FD 200D 2642 +P:1F645 1F3FE 200D 2642 FE0F +P:1F645 1F3FE 200D 2642 +P:1F645 1F3FF 200D 2642 FE0F +P:1F645 1F3FF 200D 2642 +P:1F645 200D 2640 FE0F +P:1F645 200D 2640 +P:1F645 1F3FB 200D 2640 FE0F +P:1F645 1F3FB 200D 2640 +P:1F645 1F3FC 200D 2640 FE0F +P:1F645 1F3FC 200D 2640 +P:1F645 1F3FD 200D 2640 FE0F +P:1F645 1F3FD 200D 2640 +P:1F645 1F3FE 200D 2640 FE0F +P:1F645 1F3FE 200D 2640 +P:1F645 1F3FF 200D 2640 FE0F +P:1F645 1F3FF 200D 2640 +P:1F646 +P:1F646 1F3FB +P:1F646 1F3FC +P:1F646 1F3FD +P:1F646 1F3FE +P:1F646 1F3FF +P:1F646 200D 2642 FE0F +P:1F646 200D 2642 +P:1F646 1F3FB 200D 2642 FE0F +P:1F646 1F3FB 200D 2642 +P:1F646 1F3FC 200D 2642 FE0F +P:1F646 1F3FC 200D 2642 +P:1F646 1F3FD 200D 2642 FE0F +P:1F646 1F3FD 200D 2642 +P:1F646 1F3FE 200D 2642 FE0F +P:1F646 1F3FE 200D 2642 +P:1F646 1F3FF 200D 2642 FE0F +P:1F646 1F3FF 200D 2642 +P:1F646 200D 2640 FE0F +P:1F646 200D 2640 +P:1F646 1F3FB 200D 2640 FE0F +P:1F646 1F3FB 200D 2640 +P:1F646 1F3FC 200D 2640 FE0F +P:1F646 1F3FC 200D 2640 +P:1F646 1F3FD 200D 2640 FE0F +P:1F646 1F3FD 200D 2640 +P:1F646 1F3FE 200D 2640 FE0F +P:1F646 1F3FE 200D 2640 +P:1F646 1F3FF 200D 2640 FE0F +P:1F646 1F3FF 200D 2640 +P:1F481 +P:1F481 1F3FB +P:1F481 1F3FC +P:1F481 1F3FD +P:1F481 1F3FE +P:1F481 1F3FF +P:1F481 200D 2642 FE0F +P:1F481 200D 2642 +P:1F481 1F3FB 200D 2642 FE0F +P:1F481 1F3FB 200D 2642 +P:1F481 1F3FC 200D 2642 FE0F +P:1F481 1F3FC 200D 2642 +P:1F481 1F3FD 200D 2642 FE0F +P:1F481 1F3FD 200D 2642 +P:1F481 1F3FE 200D 2642 FE0F +P:1F481 1F3FE 200D 2642 +P:1F481 1F3FF 200D 2642 FE0F +P:1F481 1F3FF 200D 2642 +P:1F481 200D 2640 FE0F +P:1F481 200D 2640 +P:1F481 1F3FB 200D 2640 FE0F +P:1F481 1F3FB 200D 2640 +P:1F481 1F3FC 200D 2640 FE0F +P:1F481 1F3FC 200D 2640 +P:1F481 1F3FD 200D 2640 FE0F +P:1F481 1F3FD 200D 2640 +P:1F481 1F3FE 200D 2640 FE0F +P:1F481 1F3FE 200D 2640 +P:1F481 1F3FF 200D 2640 FE0F +P:1F481 1F3FF 200D 2640 +P:1F64B +P:1F64B 1F3FB +P:1F64B 1F3FC +P:1F64B 1F3FD +P:1F64B 1F3FE +P:1F64B 1F3FF +P:1F64B 200D 2642 FE0F +P:1F64B 200D 2642 +P:1F64B 1F3FB 200D 2642 FE0F +P:1F64B 1F3FB 200D 2642 +P:1F64B 1F3FC 200D 2642 FE0F +P:1F64B 1F3FC 200D 2642 +P:1F64B 1F3FD 200D 2642 FE0F +P:1F64B 1F3FD 200D 2642 +P:1F64B 1F3FE 200D 2642 FE0F +P:1F64B 1F3FE 200D 2642 +P:1F64B 1F3FF 200D 2642 FE0F +P:1F64B 1F3FF 200D 2642 +P:1F64B 200D 2640 FE0F +P:1F64B 200D 2640 +P:1F64B 1F3FB 200D 2640 FE0F +P:1F64B 1F3FB 200D 2640 +P:1F64B 1F3FC 200D 2640 FE0F +P:1F64B 1F3FC 200D 2640 +P:1F64B 1F3FD 200D 2640 FE0F +P:1F64B 1F3FD 200D 2640 +P:1F64B 1F3FE 200D 2640 FE0F +P:1F64B 1F3FE 200D 2640 +P:1F64B 1F3FF 200D 2640 FE0F +P:1F64B 1F3FF 200D 2640 +P:1F9CF +P:1F9CF 1F3FB +P:1F9CF 1F3FC +P:1F9CF 1F3FD +P:1F9CF 1F3FE +P:1F9CF 1F3FF +P:1F9CF 200D 2642 FE0F +P:1F9CF 200D 2642 +P:1F9CF 1F3FB 200D 2642 FE0F +P:1F9CF 1F3FB 200D 2642 +P:1F9CF 1F3FC 200D 2642 FE0F +P:1F9CF 1F3FC 200D 2642 +P:1F9CF 1F3FD 200D 2642 FE0F +P:1F9CF 1F3FD 200D 2642 +P:1F9CF 1F3FE 200D 2642 FE0F +P:1F9CF 1F3FE 200D 2642 +P:1F9CF 1F3FF 200D 2642 FE0F +P:1F9CF 1F3FF 200D 2642 +P:1F9CF 200D 2640 FE0F +P:1F9CF 200D 2640 +P:1F9CF 1F3FB 200D 2640 FE0F +P:1F9CF 1F3FB 200D 2640 +P:1F9CF 1F3FC 200D 2640 FE0F +P:1F9CF 1F3FC 200D 2640 +P:1F9CF 1F3FD 200D 2640 FE0F +P:1F9CF 1F3FD 200D 2640 +P:1F9CF 1F3FE 200D 2640 FE0F +P:1F9CF 1F3FE 200D 2640 +P:1F9CF 1F3FF 200D 2640 FE0F +P:1F9CF 1F3FF 200D 2640 +P:1F647 +P:1F647 1F3FB +P:1F647 1F3FC +P:1F647 1F3FD +P:1F647 1F3FE +P:1F647 1F3FF +P:1F647 200D 2642 FE0F +P:1F647 200D 2642 +P:1F647 1F3FB 200D 2642 FE0F +P:1F647 1F3FB 200D 2642 +P:1F647 1F3FC 200D 2642 FE0F +P:1F647 1F3FC 200D 2642 +P:1F647 1F3FD 200D 2642 FE0F +P:1F647 1F3FD 200D 2642 +P:1F647 1F3FE 200D 2642 FE0F +P:1F647 1F3FE 200D 2642 +P:1F647 1F3FF 200D 2642 FE0F +P:1F647 1F3FF 200D 2642 +P:1F647 200D 2640 FE0F +P:1F647 200D 2640 +P:1F647 1F3FB 200D 2640 FE0F +P:1F647 1F3FB 200D 2640 +P:1F647 1F3FC 200D 2640 FE0F +P:1F647 1F3FC 200D 2640 +P:1F647 1F3FD 200D 2640 FE0F +P:1F647 1F3FD 200D 2640 +P:1F647 1F3FE 200D 2640 FE0F +P:1F647 1F3FE 200D 2640 +P:1F647 1F3FF 200D 2640 FE0F +P:1F647 1F3FF 200D 2640 +P:1F926 +P:1F926 1F3FB +P:1F926 1F3FC +P:1F926 1F3FD +P:1F926 1F3FE +P:1F926 1F3FF +P:1F926 200D 2642 FE0F +P:1F926 200D 2642 +P:1F926 1F3FB 200D 2642 FE0F +P:1F926 1F3FB 200D 2642 +P:1F926 1F3FC 200D 2642 FE0F +P:1F926 1F3FC 200D 2642 +P:1F926 1F3FD 200D 2642 FE0F +P:1F926 1F3FD 200D 2642 +P:1F926 1F3FE 200D 2642 FE0F +P:1F926 1F3FE 200D 2642 +P:1F926 1F3FF 200D 2642 FE0F +P:1F926 1F3FF 200D 2642 +P:1F926 200D 2640 FE0F +P:1F926 200D 2640 +P:1F926 1F3FB 200D 2640 FE0F +P:1F926 1F3FB 200D 2640 +P:1F926 1F3FC 200D 2640 FE0F +P:1F926 1F3FC 200D 2640 +P:1F926 1F3FD 200D 2640 FE0F +P:1F926 1F3FD 200D 2640 +P:1F926 1F3FE 200D 2640 FE0F +P:1F926 1F3FE 200D 2640 +P:1F926 1F3FF 200D 2640 FE0F +P:1F926 1F3FF 200D 2640 +P:1F937 +P:1F937 1F3FB +P:1F937 1F3FC +P:1F937 1F3FD +P:1F937 1F3FE +P:1F937 1F3FF +P:1F937 200D 2642 FE0F +P:1F937 200D 2642 +P:1F937 1F3FB 200D 2642 FE0F +P:1F937 1F3FB 200D 2642 +P:1F937 1F3FC 200D 2642 FE0F +P:1F937 1F3FC 200D 2642 +P:1F937 1F3FD 200D 2642 FE0F +P:1F937 1F3FD 200D 2642 +P:1F937 1F3FE 200D 2642 FE0F +P:1F937 1F3FE 200D 2642 +P:1F937 1F3FF 200D 2642 FE0F +P:1F937 1F3FF 200D 2642 +P:1F937 200D 2640 FE0F +P:1F937 200D 2640 +P:1F937 1F3FB 200D 2640 FE0F +P:1F937 1F3FB 200D 2640 +P:1F937 1F3FC 200D 2640 FE0F +P:1F937 1F3FC 200D 2640 +P:1F937 1F3FD 200D 2640 FE0F +P:1F937 1F3FD 200D 2640 +P:1F937 1F3FE 200D 2640 FE0F +P:1F937 1F3FE 200D 2640 +P:1F937 1F3FF 200D 2640 FE0F +P:1F937 1F3FF 200D 2640 +P:1F9D1 200D 2695 FE0F +P:1F9D1 200D 2695 +P:1F9D1 1F3FB 200D 2695 FE0F +P:1F9D1 1F3FB 200D 2695 +P:1F9D1 1F3FC 200D 2695 FE0F +P:1F9D1 1F3FC 200D 2695 +P:1F9D1 1F3FD 200D 2695 FE0F +P:1F9D1 1F3FD 200D 2695 +P:1F9D1 1F3FE 200D 2695 FE0F +P:1F9D1 1F3FE 200D 2695 +P:1F9D1 1F3FF 200D 2695 FE0F +P:1F9D1 1F3FF 200D 2695 +P:1F468 200D 2695 FE0F +P:1F468 200D 2695 +P:1F468 1F3FB 200D 2695 FE0F +P:1F468 1F3FB 200D 2695 +P:1F468 1F3FC 200D 2695 FE0F +P:1F468 1F3FC 200D 2695 +P:1F468 1F3FD 200D 2695 FE0F +P:1F468 1F3FD 200D 2695 +P:1F468 1F3FE 200D 2695 FE0F +P:1F468 1F3FE 200D 2695 +P:1F468 1F3FF 200D 2695 FE0F +P:1F468 1F3FF 200D 2695 +P:1F469 200D 2695 FE0F +P:1F469 200D 2695 +P:1F469 1F3FB 200D 2695 FE0F +P:1F469 1F3FB 200D 2695 +P:1F469 1F3FC 200D 2695 FE0F +P:1F469 1F3FC 200D 2695 +P:1F469 1F3FD 200D 2695 FE0F +P:1F469 1F3FD 200D 2695 +P:1F469 1F3FE 200D 2695 FE0F +P:1F469 1F3FE 200D 2695 +P:1F469 1F3FF 200D 2695 FE0F +P:1F469 1F3FF 200D 2695 +P:1F9D1 200D 1F393 +P:1F9D1 1F3FB 200D 1F393 +P:1F9D1 1F3FC 200D 1F393 +P:1F9D1 1F3FD 200D 1F393 +P:1F9D1 1F3FE 200D 1F393 +P:1F9D1 1F3FF 200D 1F393 +P:1F468 200D 1F393 +P:1F468 1F3FB 200D 1F393 +P:1F468 1F3FC 200D 1F393 +P:1F468 1F3FD 200D 1F393 +P:1F468 1F3FE 200D 1F393 +P:1F468 1F3FF 200D 1F393 +P:1F469 200D 1F393 +P:1F469 1F3FB 200D 1F393 +P:1F469 1F3FC 200D 1F393 +P:1F469 1F3FD 200D 1F393 +P:1F469 1F3FE 200D 1F393 +P:1F469 1F3FF 200D 1F393 +P:1F9D1 200D 1F3EB +P:1F9D1 1F3FB 200D 1F3EB +P:1F9D1 1F3FC 200D 1F3EB +P:1F9D1 1F3FD 200D 1F3EB +P:1F9D1 1F3FE 200D 1F3EB +P:1F9D1 1F3FF 200D 1F3EB +P:1F468 200D 1F3EB +P:1F468 1F3FB 200D 1F3EB +P:1F468 1F3FC 200D 1F3EB +P:1F468 1F3FD 200D 1F3EB +P:1F468 1F3FE 200D 1F3EB +P:1F468 1F3FF 200D 1F3EB +P:1F469 200D 1F3EB +P:1F469 1F3FB 200D 1F3EB +P:1F469 1F3FC 200D 1F3EB +P:1F469 1F3FD 200D 1F3EB +P:1F469 1F3FE 200D 1F3EB +P:1F469 1F3FF 200D 1F3EB +P:1F9D1 200D 2696 FE0F +P:1F9D1 200D 2696 +P:1F9D1 1F3FB 200D 2696 FE0F +P:1F9D1 1F3FB 200D 2696 +P:1F9D1 1F3FC 200D 2696 FE0F +P:1F9D1 1F3FC 200D 2696 +P:1F9D1 1F3FD 200D 2696 FE0F +P:1F9D1 1F3FD 200D 2696 +P:1F9D1 1F3FE 200D 2696 FE0F +P:1F9D1 1F3FE 200D 2696 +P:1F9D1 1F3FF 200D 2696 FE0F +P:1F9D1 1F3FF 200D 2696 +P:1F468 200D 2696 FE0F +P:1F468 200D 2696 +P:1F468 1F3FB 200D 2696 FE0F +P:1F468 1F3FB 200D 2696 +P:1F468 1F3FC 200D 2696 FE0F +P:1F468 1F3FC 200D 2696 +P:1F468 1F3FD 200D 2696 FE0F +P:1F468 1F3FD 200D 2696 +P:1F468 1F3FE 200D 2696 FE0F +P:1F468 1F3FE 200D 2696 +P:1F468 1F3FF 200D 2696 FE0F +P:1F468 1F3FF 200D 2696 +P:1F469 200D 2696 FE0F +P:1F469 200D 2696 +P:1F469 1F3FB 200D 2696 FE0F +P:1F469 1F3FB 200D 2696 +P:1F469 1F3FC 200D 2696 FE0F +P:1F469 1F3FC 200D 2696 +P:1F469 1F3FD 200D 2696 FE0F +P:1F469 1F3FD 200D 2696 +P:1F469 1F3FE 200D 2696 FE0F +P:1F469 1F3FE 200D 2696 +P:1F469 1F3FF 200D 2696 FE0F +P:1F469 1F3FF 200D 2696 +P:1F9D1 200D 1F33E +P:1F9D1 1F3FB 200D 1F33E +P:1F9D1 1F3FC 200D 1F33E +P:1F9D1 1F3FD 200D 1F33E +P:1F9D1 1F3FE 200D 1F33E +P:1F9D1 1F3FF 200D 1F33E +P:1F468 200D 1F33E +P:1F468 1F3FB 200D 1F33E +P:1F468 1F3FC 200D 1F33E +P:1F468 1F3FD 200D 1F33E +P:1F468 1F3FE 200D 1F33E +P:1F468 1F3FF 200D 1F33E +P:1F469 200D 1F33E +P:1F469 1F3FB 200D 1F33E +P:1F469 1F3FC 200D 1F33E +P:1F469 1F3FD 200D 1F33E +P:1F469 1F3FE 200D 1F33E +P:1F469 1F3FF 200D 1F33E +P:1F9D1 200D 1F373 +P:1F9D1 1F3FB 200D 1F373 +P:1F9D1 1F3FC 200D 1F373 +P:1F9D1 1F3FD 200D 1F373 +P:1F9D1 1F3FE 200D 1F373 +P:1F9D1 1F3FF 200D 1F373 +P:1F468 200D 1F373 +P:1F468 1F3FB 200D 1F373 +P:1F468 1F3FC 200D 1F373 +P:1F468 1F3FD 200D 1F373 +P:1F468 1F3FE 200D 1F373 +P:1F468 1F3FF 200D 1F373 +P:1F469 200D 1F373 +P:1F469 1F3FB 200D 1F373 +P:1F469 1F3FC 200D 1F373 +P:1F469 1F3FD 200D 1F373 +P:1F469 1F3FE 200D 1F373 +P:1F469 1F3FF 200D 1F373 +P:1F9D1 200D 1F527 +P:1F9D1 1F3FB 200D 1F527 +P:1F9D1 1F3FC 200D 1F527 +P:1F9D1 1F3FD 200D 1F527 +P:1F9D1 1F3FE 200D 1F527 +P:1F9D1 1F3FF 200D 1F527 +P:1F468 200D 1F527 +P:1F468 1F3FB 200D 1F527 +P:1F468 1F3FC 200D 1F527 +P:1F468 1F3FD 200D 1F527 +P:1F468 1F3FE 200D 1F527 +P:1F468 1F3FF 200D 1F527 +P:1F469 200D 1F527 +P:1F469 1F3FB 200D 1F527 +P:1F469 1F3FC 200D 1F527 +P:1F469 1F3FD 200D 1F527 +P:1F469 1F3FE 200D 1F527 +P:1F469 1F3FF 200D 1F527 +P:1F9D1 200D 1F3ED +P:1F9D1 1F3FB 200D 1F3ED +P:1F9D1 1F3FC 200D 1F3ED +P:1F9D1 1F3FD 200D 1F3ED +P:1F9D1 1F3FE 200D 1F3ED +P:1F9D1 1F3FF 200D 1F3ED +P:1F468 200D 1F3ED +P:1F468 1F3FB 200D 1F3ED +P:1F468 1F3FC 200D 1F3ED +P:1F468 1F3FD 200D 1F3ED +P:1F468 1F3FE 200D 1F3ED +P:1F468 1F3FF 200D 1F3ED +P:1F469 200D 1F3ED +P:1F469 1F3FB 200D 1F3ED +P:1F469 1F3FC 200D 1F3ED +P:1F469 1F3FD 200D 1F3ED +P:1F469 1F3FE 200D 1F3ED +P:1F469 1F3FF 200D 1F3ED +P:1F9D1 200D 1F4BC +P:1F9D1 1F3FB 200D 1F4BC +P:1F9D1 1F3FC 200D 1F4BC +P:1F9D1 1F3FD 200D 1F4BC +P:1F9D1 1F3FE 200D 1F4BC +P:1F9D1 1F3FF 200D 1F4BC +P:1F468 200D 1F4BC +P:1F468 1F3FB 200D 1F4BC +P:1F468 1F3FC 200D 1F4BC +P:1F468 1F3FD 200D 1F4BC +P:1F468 1F3FE 200D 1F4BC +P:1F468 1F3FF 200D 1F4BC +P:1F469 200D 1F4BC +P:1F469 1F3FB 200D 1F4BC +P:1F469 1F3FC 200D 1F4BC +P:1F469 1F3FD 200D 1F4BC +P:1F469 1F3FE 200D 1F4BC +P:1F469 1F3FF 200D 1F4BC +P:1F9D1 200D 1F52C +P:1F9D1 1F3FB 200D 1F52C +P:1F9D1 1F3FC 200D 1F52C +P:1F9D1 1F3FD 200D 1F52C +P:1F9D1 1F3FE 200D 1F52C +P:1F9D1 1F3FF 200D 1F52C +P:1F468 200D 1F52C +P:1F468 1F3FB 200D 1F52C +P:1F468 1F3FC 200D 1F52C +P:1F468 1F3FD 200D 1F52C +P:1F468 1F3FE 200D 1F52C +P:1F468 1F3FF 200D 1F52C +P:1F469 200D 1F52C +P:1F469 1F3FB 200D 1F52C +P:1F469 1F3FC 200D 1F52C +P:1F469 1F3FD 200D 1F52C +P:1F469 1F3FE 200D 1F52C +P:1F469 1F3FF 200D 1F52C +P:1F9D1 200D 1F4BB +P:1F9D1 1F3FB 200D 1F4BB +P:1F9D1 1F3FC 200D 1F4BB +P:1F9D1 1F3FD 200D 1F4BB +P:1F9D1 1F3FE 200D 1F4BB +P:1F9D1 1F3FF 200D 1F4BB +P:1F468 200D 1F4BB +P:1F468 1F3FB 200D 1F4BB +P:1F468 1F3FC 200D 1F4BB +P:1F468 1F3FD 200D 1F4BB +P:1F468 1F3FE 200D 1F4BB +P:1F468 1F3FF 200D 1F4BB +P:1F469 200D 1F4BB +P:1F469 1F3FB 200D 1F4BB +P:1F469 1F3FC 200D 1F4BB +P:1F469 1F3FD 200D 1F4BB +P:1F469 1F3FE 200D 1F4BB +P:1F469 1F3FF 200D 1F4BB +P:1F9D1 200D 1F3A4 +P:1F9D1 1F3FB 200D 1F3A4 +P:1F9D1 1F3FC 200D 1F3A4 +P:1F9D1 1F3FD 200D 1F3A4 +P:1F9D1 1F3FE 200D 1F3A4 +P:1F9D1 1F3FF 200D 1F3A4 +P:1F468 200D 1F3A4 +P:1F468 1F3FB 200D 1F3A4 +P:1F468 1F3FC 200D 1F3A4 +P:1F468 1F3FD 200D 1F3A4 +P:1F468 1F3FE 200D 1F3A4 +P:1F468 1F3FF 200D 1F3A4 +P:1F469 200D 1F3A4 +P:1F469 1F3FB 200D 1F3A4 +P:1F469 1F3FC 200D 1F3A4 +P:1F469 1F3FD 200D 1F3A4 +P:1F469 1F3FE 200D 1F3A4 +P:1F469 1F3FF 200D 1F3A4 +P:1F9D1 200D 1F3A8 +P:1F9D1 1F3FB 200D 1F3A8 +P:1F9D1 1F3FC 200D 1F3A8 +P:1F9D1 1F3FD 200D 1F3A8 +P:1F9D1 1F3FE 200D 1F3A8 +P:1F9D1 1F3FF 200D 1F3A8 +P:1F468 200D 1F3A8 +P:1F468 1F3FB 200D 1F3A8 +P:1F468 1F3FC 200D 1F3A8 +P:1F468 1F3FD 200D 1F3A8 +P:1F468 1F3FE 200D 1F3A8 +P:1F468 1F3FF 200D 1F3A8 +P:1F469 200D 1F3A8 +P:1F469 1F3FB 200D 1F3A8 +P:1F469 1F3FC 200D 1F3A8 +P:1F469 1F3FD 200D 1F3A8 +P:1F469 1F3FE 200D 1F3A8 +P:1F469 1F3FF 200D 1F3A8 +P:1F9D1 200D 2708 FE0F +P:1F9D1 200D 2708 +P:1F9D1 1F3FB 200D 2708 FE0F +P:1F9D1 1F3FB 200D 2708 +P:1F9D1 1F3FC 200D 2708 FE0F +P:1F9D1 1F3FC 200D 2708 +P:1F9D1 1F3FD 200D 2708 FE0F +P:1F9D1 1F3FD 200D 2708 +P:1F9D1 1F3FE 200D 2708 FE0F +P:1F9D1 1F3FE 200D 2708 +P:1F9D1 1F3FF 200D 2708 FE0F +P:1F9D1 1F3FF 200D 2708 +P:1F468 200D 2708 FE0F +P:1F468 200D 2708 +P:1F468 1F3FB 200D 2708 FE0F +P:1F468 1F3FB 200D 2708 +P:1F468 1F3FC 200D 2708 FE0F +P:1F468 1F3FC 200D 2708 +P:1F468 1F3FD 200D 2708 FE0F +P:1F468 1F3FD 200D 2708 +P:1F468 1F3FE 200D 2708 FE0F +P:1F468 1F3FE 200D 2708 +P:1F468 1F3FF 200D 2708 FE0F +P:1F468 1F3FF 200D 2708 +P:1F469 200D 2708 FE0F +P:1F469 200D 2708 +P:1F469 1F3FB 200D 2708 FE0F +P:1F469 1F3FB 200D 2708 +P:1F469 1F3FC 200D 2708 FE0F +P:1F469 1F3FC 200D 2708 +P:1F469 1F3FD 200D 2708 FE0F +P:1F469 1F3FD 200D 2708 +P:1F469 1F3FE 200D 2708 FE0F +P:1F469 1F3FE 200D 2708 +P:1F469 1F3FF 200D 2708 FE0F +P:1F469 1F3FF 200D 2708 +P:1F9D1 200D 1F680 +P:1F9D1 1F3FB 200D 1F680 +P:1F9D1 1F3FC 200D 1F680 +P:1F9D1 1F3FD 200D 1F680 +P:1F9D1 1F3FE 200D 1F680 +P:1F9D1 1F3FF 200D 1F680 +P:1F468 200D 1F680 +P:1F468 1F3FB 200D 1F680 +P:1F468 1F3FC 200D 1F680 +P:1F468 1F3FD 200D 1F680 +P:1F468 1F3FE 200D 1F680 +P:1F468 1F3FF 200D 1F680 +P:1F469 200D 1F680 +P:1F469 1F3FB 200D 1F680 +P:1F469 1F3FC 200D 1F680 +P:1F469 1F3FD 200D 1F680 +P:1F469 1F3FE 200D 1F680 +P:1F469 1F3FF 200D 1F680 +P:1F9D1 200D 1F692 +P:1F9D1 1F3FB 200D 1F692 +P:1F9D1 1F3FC 200D 1F692 +P:1F9D1 1F3FD 200D 1F692 +P:1F9D1 1F3FE 200D 1F692 +P:1F9D1 1F3FF 200D 1F692 +P:1F468 200D 1F692 +P:1F468 1F3FB 200D 1F692 +P:1F468 1F3FC 200D 1F692 +P:1F468 1F3FD 200D 1F692 +P:1F468 1F3FE 200D 1F692 +P:1F468 1F3FF 200D 1F692 +P:1F469 200D 1F692 +P:1F469 1F3FB 200D 1F692 +P:1F469 1F3FC 200D 1F692 +P:1F469 1F3FD 200D 1F692 +P:1F469 1F3FE 200D 1F692 +P:1F469 1F3FF 200D 1F692 +P:1F46E +P:1F46E 1F3FB +P:1F46E 1F3FC +P:1F46E 1F3FD +P:1F46E 1F3FE +P:1F46E 1F3FF +P:1F46E 200D 2642 FE0F +P:1F46E 200D 2642 +P:1F46E 1F3FB 200D 2642 FE0F +P:1F46E 1F3FB 200D 2642 +P:1F46E 1F3FC 200D 2642 FE0F +P:1F46E 1F3FC 200D 2642 +P:1F46E 1F3FD 200D 2642 FE0F +P:1F46E 1F3FD 200D 2642 +P:1F46E 1F3FE 200D 2642 FE0F +P:1F46E 1F3FE 200D 2642 +P:1F46E 1F3FF 200D 2642 FE0F +P:1F46E 1F3FF 200D 2642 +P:1F46E 200D 2640 FE0F +P:1F46E 200D 2640 +P:1F46E 1F3FB 200D 2640 FE0F +P:1F46E 1F3FB 200D 2640 +P:1F46E 1F3FC 200D 2640 FE0F +P:1F46E 1F3FC 200D 2640 +P:1F46E 1F3FD 200D 2640 FE0F +P:1F46E 1F3FD 200D 2640 +P:1F46E 1F3FE 200D 2640 FE0F +P:1F46E 1F3FE 200D 2640 +P:1F46E 1F3FF 200D 2640 FE0F +P:1F46E 1F3FF 200D 2640 +P:1F575 FE0F +P:1F575 +P:1F575 1F3FB +P:1F575 1F3FC +P:1F575 1F3FD +P:1F575 1F3FE +P:1F575 1F3FF +P:1F575 FE0F 200D 2642 FE0F +P:1F575 200D 2642 FE0F +P:1F575 FE0F 200D 2642 +P:1F575 200D 2642 +P:1F575 1F3FB 200D 2642 FE0F +P:1F575 1F3FB 200D 2642 +P:1F575 1F3FC 200D 2642 FE0F +P:1F575 1F3FC 200D 2642 +P:1F575 1F3FD 200D 2642 FE0F +P:1F575 1F3FD 200D 2642 +P:1F575 1F3FE 200D 2642 FE0F +P:1F575 1F3FE 200D 2642 +P:1F575 1F3FF 200D 2642 FE0F +P:1F575 1F3FF 200D 2642 +P:1F575 FE0F 200D 2640 FE0F +P:1F575 200D 2640 FE0F +P:1F575 FE0F 200D 2640 +P:1F575 200D 2640 +P:1F575 1F3FB 200D 2640 FE0F +P:1F575 1F3FB 200D 2640 +P:1F575 1F3FC 200D 2640 FE0F +P:1F575 1F3FC 200D 2640 +P:1F575 1F3FD 200D 2640 FE0F +P:1F575 1F3FD 200D 2640 +P:1F575 1F3FE 200D 2640 FE0F +P:1F575 1F3FE 200D 2640 +P:1F575 1F3FF 200D 2640 FE0F +P:1F575 1F3FF 200D 2640 +P:1F482 +P:1F482 1F3FB +P:1F482 1F3FC +P:1F482 1F3FD +P:1F482 1F3FE +P:1F482 1F3FF +P:1F482 200D 2642 FE0F +P:1F482 200D 2642 +P:1F482 1F3FB 200D 2642 FE0F +P:1F482 1F3FB 200D 2642 +P:1F482 1F3FC 200D 2642 FE0F +P:1F482 1F3FC 200D 2642 +P:1F482 1F3FD 200D 2642 FE0F +P:1F482 1F3FD 200D 2642 +P:1F482 1F3FE 200D 2642 FE0F +P:1F482 1F3FE 200D 2642 +P:1F482 1F3FF 200D 2642 FE0F +P:1F482 1F3FF 200D 2642 +P:1F482 200D 2640 FE0F +P:1F482 200D 2640 +P:1F482 1F3FB 200D 2640 FE0F +P:1F482 1F3FB 200D 2640 +P:1F482 1F3FC 200D 2640 FE0F +P:1F482 1F3FC 200D 2640 +P:1F482 1F3FD 200D 2640 FE0F +P:1F482 1F3FD 200D 2640 +P:1F482 1F3FE 200D 2640 FE0F +P:1F482 1F3FE 200D 2640 +P:1F482 1F3FF 200D 2640 FE0F +P:1F482 1F3FF 200D 2640 +P:1F977 +P:1F977 1F3FB +P:1F977 1F3FC +P:1F977 1F3FD +P:1F977 1F3FE +P:1F977 1F3FF +P:1F477 +P:1F477 1F3FB +P:1F477 1F3FC +P:1F477 1F3FD +P:1F477 1F3FE +P:1F477 1F3FF +P:1F477 200D 2642 FE0F +P:1F477 200D 2642 +P:1F477 1F3FB 200D 2642 FE0F +P:1F477 1F3FB 200D 2642 +P:1F477 1F3FC 200D 2642 FE0F +P:1F477 1F3FC 200D 2642 +P:1F477 1F3FD 200D 2642 FE0F +P:1F477 1F3FD 200D 2642 +P:1F477 1F3FE 200D 2642 FE0F +P:1F477 1F3FE 200D 2642 +P:1F477 1F3FF 200D 2642 FE0F +P:1F477 1F3FF 200D 2642 +P:1F477 200D 2640 FE0F +P:1F477 200D 2640 +P:1F477 1F3FB 200D 2640 FE0F +P:1F477 1F3FB 200D 2640 +P:1F477 1F3FC 200D 2640 FE0F +P:1F477 1F3FC 200D 2640 +P:1F477 1F3FD 200D 2640 FE0F +P:1F477 1F3FD 200D 2640 +P:1F477 1F3FE 200D 2640 FE0F +P:1F477 1F3FE 200D 2640 +P:1F477 1F3FF 200D 2640 FE0F +P:1F477 1F3FF 200D 2640 +P:1FAC5 +P:1FAC5 1F3FB +P:1FAC5 1F3FC +P:1FAC5 1F3FD +P:1FAC5 1F3FE +P:1FAC5 1F3FF +P:1F934 +P:1F934 1F3FB +P:1F934 1F3FC +P:1F934 1F3FD +P:1F934 1F3FE +P:1F934 1F3FF +P:1F478 +P:1F478 1F3FB +P:1F478 1F3FC +P:1F478 1F3FD +P:1F478 1F3FE +P:1F478 1F3FF +P:1F473 +P:1F473 1F3FB +P:1F473 1F3FC +P:1F473 1F3FD +P:1F473 1F3FE +P:1F473 1F3FF +P:1F473 200D 2642 FE0F +P:1F473 200D 2642 +P:1F473 1F3FB 200D 2642 FE0F +P:1F473 1F3FB 200D 2642 +P:1F473 1F3FC 200D 2642 FE0F +P:1F473 1F3FC 200D 2642 +P:1F473 1F3FD 200D 2642 FE0F +P:1F473 1F3FD 200D 2642 +P:1F473 1F3FE 200D 2642 FE0F +P:1F473 1F3FE 200D 2642 +P:1F473 1F3FF 200D 2642 FE0F +P:1F473 1F3FF 200D 2642 +P:1F473 200D 2640 FE0F +P:1F473 200D 2640 +P:1F473 1F3FB 200D 2640 FE0F +P:1F473 1F3FB 200D 2640 +P:1F473 1F3FC 200D 2640 FE0F +P:1F473 1F3FC 200D 2640 +P:1F473 1F3FD 200D 2640 FE0F +P:1F473 1F3FD 200D 2640 +P:1F473 1F3FE 200D 2640 FE0F +P:1F473 1F3FE 200D 2640 +P:1F473 1F3FF 200D 2640 FE0F +P:1F473 1F3FF 200D 2640 +P:1F472 +P:1F472 1F3FB +P:1F472 1F3FC +P:1F472 1F3FD +P:1F472 1F3FE +P:1F472 1F3FF +P:1F9D5 +P:1F9D5 1F3FB +P:1F9D5 1F3FC +P:1F9D5 1F3FD +P:1F9D5 1F3FE +P:1F9D5 1F3FF +P:1F935 +P:1F935 1F3FB +P:1F935 1F3FC +P:1F935 1F3FD +P:1F935 1F3FE +P:1F935 1F3FF +P:1F935 200D 2642 FE0F +P:1F935 200D 2642 +P:1F935 1F3FB 200D 2642 FE0F +P:1F935 1F3FB 200D 2642 +P:1F935 1F3FC 200D 2642 FE0F +P:1F935 1F3FC 200D 2642 +P:1F935 1F3FD 200D 2642 FE0F +P:1F935 1F3FD 200D 2642 +P:1F935 1F3FE 200D 2642 FE0F +P:1F935 1F3FE 200D 2642 +P:1F935 1F3FF 200D 2642 FE0F +P:1F935 1F3FF 200D 2642 +P:1F935 200D 2640 FE0F +P:1F935 200D 2640 +P:1F935 1F3FB 200D 2640 FE0F +P:1F935 1F3FB 200D 2640 +P:1F935 1F3FC 200D 2640 FE0F +P:1F935 1F3FC 200D 2640 +P:1F935 1F3FD 200D 2640 FE0F +P:1F935 1F3FD 200D 2640 +P:1F935 1F3FE 200D 2640 FE0F +P:1F935 1F3FE 200D 2640 +P:1F935 1F3FF 200D 2640 FE0F +P:1F935 1F3FF 200D 2640 +P:1F470 +P:1F470 1F3FB +P:1F470 1F3FC +P:1F470 1F3FD +P:1F470 1F3FE +P:1F470 1F3FF +P:1F470 200D 2642 FE0F +P:1F470 200D 2642 +P:1F470 1F3FB 200D 2642 FE0F +P:1F470 1F3FB 200D 2642 +P:1F470 1F3FC 200D 2642 FE0F +P:1F470 1F3FC 200D 2642 +P:1F470 1F3FD 200D 2642 FE0F +P:1F470 1F3FD 200D 2642 +P:1F470 1F3FE 200D 2642 FE0F +P:1F470 1F3FE 200D 2642 +P:1F470 1F3FF 200D 2642 FE0F +P:1F470 1F3FF 200D 2642 +P:1F470 200D 2640 FE0F +P:1F470 200D 2640 +P:1F470 1F3FB 200D 2640 FE0F +P:1F470 1F3FB 200D 2640 +P:1F470 1F3FC 200D 2640 FE0F +P:1F470 1F3FC 200D 2640 +P:1F470 1F3FD 200D 2640 FE0F +P:1F470 1F3FD 200D 2640 +P:1F470 1F3FE 200D 2640 FE0F +P:1F470 1F3FE 200D 2640 +P:1F470 1F3FF 200D 2640 FE0F +P:1F470 1F3FF 200D 2640 +P:1F930 +P:1F930 1F3FB +P:1F930 1F3FC +P:1F930 1F3FD +P:1F930 1F3FE +P:1F930 1F3FF +P:1FAC3 +P:1FAC3 1F3FB +P:1FAC3 1F3FC +P:1FAC3 1F3FD +P:1FAC3 1F3FE +P:1FAC3 1F3FF +P:1FAC4 +P:1FAC4 1F3FB +P:1FAC4 1F3FC +P:1FAC4 1F3FD +P:1FAC4 1F3FE +P:1FAC4 1F3FF +P:1F931 +P:1F931 1F3FB +P:1F931 1F3FC +P:1F931 1F3FD +P:1F931 1F3FE +P:1F931 1F3FF +P:1F469 200D 1F37C +P:1F469 1F3FB 200D 1F37C +P:1F469 1F3FC 200D 1F37C +P:1F469 1F3FD 200D 1F37C +P:1F469 1F3FE 200D 1F37C +P:1F469 1F3FF 200D 1F37C +P:1F468 200D 1F37C +P:1F468 1F3FB 200D 1F37C +P:1F468 1F3FC 200D 1F37C +P:1F468 1F3FD 200D 1F37C +P:1F468 1F3FE 200D 1F37C +P:1F468 1F3FF 200D 1F37C +P:1F9D1 200D 1F37C +P:1F9D1 1F3FB 200D 1F37C +P:1F9D1 1F3FC 200D 1F37C +P:1F9D1 1F3FD 200D 1F37C +P:1F9D1 1F3FE 200D 1F37C +P:1F9D1 1F3FF 200D 1F37C +P:1F47C +P:1F47C 1F3FB +P:1F47C 1F3FC +P:1F47C 1F3FD +P:1F47C 1F3FE +P:1F47C 1F3FF +P:1F385 +P:1F385 1F3FB +P:1F385 1F3FC +P:1F385 1F3FD +P:1F385 1F3FE +P:1F385 1F3FF +P:1F936 +P:1F936 1F3FB +P:1F936 1F3FC +P:1F936 1F3FD +P:1F936 1F3FE +P:1F936 1F3FF +P:1F9D1 200D 1F384 +P:1F9D1 1F3FB 200D 1F384 +P:1F9D1 1F3FC 200D 1F384 +P:1F9D1 1F3FD 200D 1F384 +P:1F9D1 1F3FE 200D 1F384 +P:1F9D1 1F3FF 200D 1F384 +P:1F9B8 +P:1F9B8 1F3FB +P:1F9B8 1F3FC +P:1F9B8 1F3FD +P:1F9B8 1F3FE +P:1F9B8 1F3FF +P:1F9B8 200D 2642 FE0F +P:1F9B8 200D 2642 +P:1F9B8 1F3FB 200D 2642 FE0F +P:1F9B8 1F3FB 200D 2642 +P:1F9B8 1F3FC 200D 2642 FE0F +P:1F9B8 1F3FC 200D 2642 +P:1F9B8 1F3FD 200D 2642 FE0F +P:1F9B8 1F3FD 200D 2642 +P:1F9B8 1F3FE 200D 2642 FE0F +P:1F9B8 1F3FE 200D 2642 +P:1F9B8 1F3FF 200D 2642 FE0F +P:1F9B8 1F3FF 200D 2642 +P:1F9B8 200D 2640 FE0F +P:1F9B8 200D 2640 +P:1F9B8 1F3FB 200D 2640 FE0F +P:1F9B8 1F3FB 200D 2640 +P:1F9B8 1F3FC 200D 2640 FE0F +P:1F9B8 1F3FC 200D 2640 +P:1F9B8 1F3FD 200D 2640 FE0F +P:1F9B8 1F3FD 200D 2640 +P:1F9B8 1F3FE 200D 2640 FE0F +P:1F9B8 1F3FE 200D 2640 +P:1F9B8 1F3FF 200D 2640 FE0F +P:1F9B8 1F3FF 200D 2640 +P:1F9B9 +P:1F9B9 1F3FB +P:1F9B9 1F3FC +P:1F9B9 1F3FD +P:1F9B9 1F3FE +P:1F9B9 1F3FF +P:1F9B9 200D 2642 FE0F +P:1F9B9 200D 2642 +P:1F9B9 1F3FB 200D 2642 FE0F +P:1F9B9 1F3FB 200D 2642 +P:1F9B9 1F3FC 200D 2642 FE0F +P:1F9B9 1F3FC 200D 2642 +P:1F9B9 1F3FD 200D 2642 FE0F +P:1F9B9 1F3FD 200D 2642 +P:1F9B9 1F3FE 200D 2642 FE0F +P:1F9B9 1F3FE 200D 2642 +P:1F9B9 1F3FF 200D 2642 FE0F +P:1F9B9 1F3FF 200D 2642 +P:1F9B9 200D 2640 FE0F +P:1F9B9 200D 2640 +P:1F9B9 1F3FB 200D 2640 FE0F +P:1F9B9 1F3FB 200D 2640 +P:1F9B9 1F3FC 200D 2640 FE0F +P:1F9B9 1F3FC 200D 2640 +P:1F9B9 1F3FD 200D 2640 FE0F +P:1F9B9 1F3FD 200D 2640 +P:1F9B9 1F3FE 200D 2640 FE0F +P:1F9B9 1F3FE 200D 2640 +P:1F9B9 1F3FF 200D 2640 FE0F +P:1F9B9 1F3FF 200D 2640 +P:1F9D9 +P:1F9D9 1F3FB +P:1F9D9 1F3FC +P:1F9D9 1F3FD +P:1F9D9 1F3FE +P:1F9D9 1F3FF +P:1F9D9 200D 2642 FE0F +P:1F9D9 200D 2642 +P:1F9D9 1F3FB 200D 2642 FE0F +P:1F9D9 1F3FB 200D 2642 +P:1F9D9 1F3FC 200D 2642 FE0F +P:1F9D9 1F3FC 200D 2642 +P:1F9D9 1F3FD 200D 2642 FE0F +P:1F9D9 1F3FD 200D 2642 +P:1F9D9 1F3FE 200D 2642 FE0F +P:1F9D9 1F3FE 200D 2642 +P:1F9D9 1F3FF 200D 2642 FE0F +P:1F9D9 1F3FF 200D 2642 +P:1F9D9 200D 2640 FE0F +P:1F9D9 200D 2640 +P:1F9D9 1F3FB 200D 2640 FE0F +P:1F9D9 1F3FB 200D 2640 +P:1F9D9 1F3FC 200D 2640 FE0F +P:1F9D9 1F3FC 200D 2640 +P:1F9D9 1F3FD 200D 2640 FE0F +P:1F9D9 1F3FD 200D 2640 +P:1F9D9 1F3FE 200D 2640 FE0F +P:1F9D9 1F3FE 200D 2640 +P:1F9D9 1F3FF 200D 2640 FE0F +P:1F9D9 1F3FF 200D 2640 +P:1F9DA +P:1F9DA 1F3FB +P:1F9DA 1F3FC +P:1F9DA 1F3FD +P:1F9DA 1F3FE +P:1F9DA 1F3FF +P:1F9DA 200D 2642 FE0F +P:1F9DA 200D 2642 +P:1F9DA 1F3FB 200D 2642 FE0F +P:1F9DA 1F3FB 200D 2642 +P:1F9DA 1F3FC 200D 2642 FE0F +P:1F9DA 1F3FC 200D 2642 +P:1F9DA 1F3FD 200D 2642 FE0F +P:1F9DA 1F3FD 200D 2642 +P:1F9DA 1F3FE 200D 2642 FE0F +P:1F9DA 1F3FE 200D 2642 +P:1F9DA 1F3FF 200D 2642 FE0F +P:1F9DA 1F3FF 200D 2642 +P:1F9DA 200D 2640 FE0F +P:1F9DA 200D 2640 +P:1F9DA 1F3FB 200D 2640 FE0F +P:1F9DA 1F3FB 200D 2640 +P:1F9DA 1F3FC 200D 2640 FE0F +P:1F9DA 1F3FC 200D 2640 +P:1F9DA 1F3FD 200D 2640 FE0F +P:1F9DA 1F3FD 200D 2640 +P:1F9DA 1F3FE 200D 2640 FE0F +P:1F9DA 1F3FE 200D 2640 +P:1F9DA 1F3FF 200D 2640 FE0F +P:1F9DA 1F3FF 200D 2640 +P:1F9DB +P:1F9DB 1F3FB +P:1F9DB 1F3FC +P:1F9DB 1F3FD +P:1F9DB 1F3FE +P:1F9DB 1F3FF +P:1F9DB 200D 2642 FE0F +P:1F9DB 200D 2642 +P:1F9DB 1F3FB 200D 2642 FE0F +P:1F9DB 1F3FB 200D 2642 +P:1F9DB 1F3FC 200D 2642 FE0F +P:1F9DB 1F3FC 200D 2642 +P:1F9DB 1F3FD 200D 2642 FE0F +P:1F9DB 1F3FD 200D 2642 +P:1F9DB 1F3FE 200D 2642 FE0F +P:1F9DB 1F3FE 200D 2642 +P:1F9DB 1F3FF 200D 2642 FE0F +P:1F9DB 1F3FF 200D 2642 +P:1F9DB 200D 2640 FE0F +P:1F9DB 200D 2640 +P:1F9DB 1F3FB 200D 2640 FE0F +P:1F9DB 1F3FB 200D 2640 +P:1F9DB 1F3FC 200D 2640 FE0F +P:1F9DB 1F3FC 200D 2640 +P:1F9DB 1F3FD 200D 2640 FE0F +P:1F9DB 1F3FD 200D 2640 +P:1F9DB 1F3FE 200D 2640 FE0F +P:1F9DB 1F3FE 200D 2640 +P:1F9DB 1F3FF 200D 2640 FE0F +P:1F9DB 1F3FF 200D 2640 +P:1F9DC +P:1F9DC 1F3FB +P:1F9DC 1F3FC +P:1F9DC 1F3FD +P:1F9DC 1F3FE +P:1F9DC 1F3FF +P:1F9DC 200D 2642 FE0F +P:1F9DC 200D 2642 +P:1F9DC 1F3FB 200D 2642 FE0F +P:1F9DC 1F3FB 200D 2642 +P:1F9DC 1F3FC 200D 2642 FE0F +P:1F9DC 1F3FC 200D 2642 +P:1F9DC 1F3FD 200D 2642 FE0F +P:1F9DC 1F3FD 200D 2642 +P:1F9DC 1F3FE 200D 2642 FE0F +P:1F9DC 1F3FE 200D 2642 +P:1F9DC 1F3FF 200D 2642 FE0F +P:1F9DC 1F3FF 200D 2642 +P:1F9DC 200D 2640 FE0F +P:1F9DC 200D 2640 +P:1F9DC 1F3FB 200D 2640 FE0F +P:1F9DC 1F3FB 200D 2640 +P:1F9DC 1F3FC 200D 2640 FE0F +P:1F9DC 1F3FC 200D 2640 +P:1F9DC 1F3FD 200D 2640 FE0F +P:1F9DC 1F3FD 200D 2640 +P:1F9DC 1F3FE 200D 2640 FE0F +P:1F9DC 1F3FE 200D 2640 +P:1F9DC 1F3FF 200D 2640 FE0F +P:1F9DC 1F3FF 200D 2640 +P:1F9DD +P:1F9DD 1F3FB +P:1F9DD 1F3FC +P:1F9DD 1F3FD +P:1F9DD 1F3FE +P:1F9DD 1F3FF +P:1F9DD 200D 2642 FE0F +P:1F9DD 200D 2642 +P:1F9DD 1F3FB 200D 2642 FE0F +P:1F9DD 1F3FB 200D 2642 +P:1F9DD 1F3FC 200D 2642 FE0F +P:1F9DD 1F3FC 200D 2642 +P:1F9DD 1F3FD 200D 2642 FE0F +P:1F9DD 1F3FD 200D 2642 +P:1F9DD 1F3FE 200D 2642 FE0F +P:1F9DD 1F3FE 200D 2642 +P:1F9DD 1F3FF 200D 2642 FE0F +P:1F9DD 1F3FF 200D 2642 +P:1F9DD 200D 2640 FE0F +P:1F9DD 200D 2640 +P:1F9DD 1F3FB 200D 2640 FE0F +P:1F9DD 1F3FB 200D 2640 +P:1F9DD 1F3FC 200D 2640 FE0F +P:1F9DD 1F3FC 200D 2640 +P:1F9DD 1F3FD 200D 2640 FE0F +P:1F9DD 1F3FD 200D 2640 +P:1F9DD 1F3FE 200D 2640 FE0F +P:1F9DD 1F3FE 200D 2640 +P:1F9DD 1F3FF 200D 2640 FE0F +P:1F9DD 1F3FF 200D 2640 +P:1F9DE +P:1F9DE 200D 2642 FE0F +P:1F9DE 200D 2642 +P:1F9DE 200D 2640 FE0F +P:1F9DE 200D 2640 +P:1F9DF +P:1F9DF 200D 2642 FE0F +P:1F9DF 200D 2642 +P:1F9DF 200D 2640 FE0F +P:1F9DF 200D 2640 +P:1F9CC +P:1FAC8 +P:1F486 +P:1F486 1F3FB +P:1F486 1F3FC +P:1F486 1F3FD +P:1F486 1F3FE +P:1F486 1F3FF +P:1F486 200D 2642 FE0F +P:1F486 200D 2642 +P:1F486 1F3FB 200D 2642 FE0F +P:1F486 1F3FB 200D 2642 +P:1F486 1F3FC 200D 2642 FE0F +P:1F486 1F3FC 200D 2642 +P:1F486 1F3FD 200D 2642 FE0F +P:1F486 1F3FD 200D 2642 +P:1F486 1F3FE 200D 2642 FE0F +P:1F486 1F3FE 200D 2642 +P:1F486 1F3FF 200D 2642 FE0F +P:1F486 1F3FF 200D 2642 +P:1F486 200D 2640 FE0F +P:1F486 200D 2640 +P:1F486 1F3FB 200D 2640 FE0F +P:1F486 1F3FB 200D 2640 +P:1F486 1F3FC 200D 2640 FE0F +P:1F486 1F3FC 200D 2640 +P:1F486 1F3FD 200D 2640 FE0F +P:1F486 1F3FD 200D 2640 +P:1F486 1F3FE 200D 2640 FE0F +P:1F486 1F3FE 200D 2640 +P:1F486 1F3FF 200D 2640 FE0F +P:1F486 1F3FF 200D 2640 +P:1F487 +P:1F487 1F3FB +P:1F487 1F3FC +P:1F487 1F3FD +P:1F487 1F3FE +P:1F487 1F3FF +P:1F487 200D 2642 FE0F +P:1F487 200D 2642 +P:1F487 1F3FB 200D 2642 FE0F +P:1F487 1F3FB 200D 2642 +P:1F487 1F3FC 200D 2642 FE0F +P:1F487 1F3FC 200D 2642 +P:1F487 1F3FD 200D 2642 FE0F +P:1F487 1F3FD 200D 2642 +P:1F487 1F3FE 200D 2642 FE0F +P:1F487 1F3FE 200D 2642 +P:1F487 1F3FF 200D 2642 FE0F +P:1F487 1F3FF 200D 2642 +P:1F487 200D 2640 FE0F +P:1F487 200D 2640 +P:1F487 1F3FB 200D 2640 FE0F +P:1F487 1F3FB 200D 2640 +P:1F487 1F3FC 200D 2640 FE0F +P:1F487 1F3FC 200D 2640 +P:1F487 1F3FD 200D 2640 FE0F +P:1F487 1F3FD 200D 2640 +P:1F487 1F3FE 200D 2640 FE0F +P:1F487 1F3FE 200D 2640 +P:1F487 1F3FF 200D 2640 FE0F +P:1F487 1F3FF 200D 2640 +P:1F6B6 +P:1F6B6 1F3FB +P:1F6B6 1F3FC +P:1F6B6 1F3FD +P:1F6B6 1F3FE +P:1F6B6 1F3FF +P:1F6B6 200D 2642 FE0F +P:1F6B6 200D 2642 +P:1F6B6 1F3FB 200D 2642 FE0F +P:1F6B6 1F3FB 200D 2642 +P:1F6B6 1F3FC 200D 2642 FE0F +P:1F6B6 1F3FC 200D 2642 +P:1F6B6 1F3FD 200D 2642 FE0F +P:1F6B6 1F3FD 200D 2642 +P:1F6B6 1F3FE 200D 2642 FE0F +P:1F6B6 1F3FE 200D 2642 +P:1F6B6 1F3FF 200D 2642 FE0F +P:1F6B6 1F3FF 200D 2642 +P:1F6B6 200D 2640 FE0F +P:1F6B6 200D 2640 +P:1F6B6 1F3FB 200D 2640 FE0F +P:1F6B6 1F3FB 200D 2640 +P:1F6B6 1F3FC 200D 2640 FE0F +P:1F6B6 1F3FC 200D 2640 +P:1F6B6 1F3FD 200D 2640 FE0F +P:1F6B6 1F3FD 200D 2640 +P:1F6B6 1F3FE 200D 2640 FE0F +P:1F6B6 1F3FE 200D 2640 +P:1F6B6 1F3FF 200D 2640 FE0F +P:1F6B6 1F3FF 200D 2640 +P:1F6B6 200D 27A1 FE0F +P:1F6B6 200D 27A1 +P:1F6B6 1F3FB 200D 27A1 FE0F +P:1F6B6 1F3FB 200D 27A1 +P:1F6B6 1F3FC 200D 27A1 FE0F +P:1F6B6 1F3FC 200D 27A1 +P:1F6B6 1F3FD 200D 27A1 FE0F +P:1F6B6 1F3FD 200D 27A1 +P:1F6B6 1F3FE 200D 27A1 FE0F +P:1F6B6 1F3FE 200D 27A1 +P:1F6B6 1F3FF 200D 27A1 FE0F +P:1F6B6 1F3FF 200D 27A1 +P:1F6B6 200D 2640 FE0F 200D 27A1 FE0F +P:1F6B6 200D 2640 200D 27A1 FE0F +P:1F6B6 200D 2640 FE0F 200D 27A1 +P:1F6B6 200D 2640 200D 27A1 +P:1F6B6 1F3FB 200D 2640 FE0F 200D 27A1 FE0F +P:1F6B6 1F3FB 200D 2640 200D 27A1 FE0F +P:1F6B6 1F3FB 200D 2640 FE0F 200D 27A1 +P:1F6B6 1F3FB 200D 2640 200D 27A1 +P:1F6B6 1F3FC 200D 2640 FE0F 200D 27A1 FE0F +P:1F6B6 1F3FC 200D 2640 200D 27A1 FE0F +P:1F6B6 1F3FC 200D 2640 FE0F 200D 27A1 +P:1F6B6 1F3FC 200D 2640 200D 27A1 +P:1F6B6 1F3FD 200D 2640 FE0F 200D 27A1 FE0F +P:1F6B6 1F3FD 200D 2640 200D 27A1 FE0F +P:1F6B6 1F3FD 200D 2640 FE0F 200D 27A1 +P:1F6B6 1F3FD 200D 2640 200D 27A1 +P:1F6B6 1F3FE 200D 2640 FE0F 200D 27A1 FE0F +P:1F6B6 1F3FE 200D 2640 200D 27A1 FE0F +P:1F6B6 1F3FE 200D 2640 FE0F 200D 27A1 +P:1F6B6 1F3FE 200D 2640 200D 27A1 +P:1F6B6 1F3FF 200D 2640 FE0F 200D 27A1 FE0F +P:1F6B6 1F3FF 200D 2640 200D 27A1 FE0F +P:1F6B6 1F3FF 200D 2640 FE0F 200D 27A1 +P:1F6B6 1F3FF 200D 2640 200D 27A1 +P:1F6B6 200D 2642 FE0F 200D 27A1 FE0F +P:1F6B6 200D 2642 200D 27A1 FE0F +P:1F6B6 200D 2642 FE0F 200D 27A1 +P:1F6B6 200D 2642 200D 27A1 +P:1F6B6 1F3FB 200D 2642 FE0F 200D 27A1 FE0F +P:1F6B6 1F3FB 200D 2642 200D 27A1 FE0F +P:1F6B6 1F3FB 200D 2642 FE0F 200D 27A1 +P:1F6B6 1F3FB 200D 2642 200D 27A1 +P:1F6B6 1F3FC 200D 2642 FE0F 200D 27A1 FE0F +P:1F6B6 1F3FC 200D 2642 200D 27A1 FE0F +P:1F6B6 1F3FC 200D 2642 FE0F 200D 27A1 +P:1F6B6 1F3FC 200D 2642 200D 27A1 +P:1F6B6 1F3FD 200D 2642 FE0F 200D 27A1 FE0F +P:1F6B6 1F3FD 200D 2642 200D 27A1 FE0F +P:1F6B6 1F3FD 200D 2642 FE0F 200D 27A1 +P:1F6B6 1F3FD 200D 2642 200D 27A1 +P:1F6B6 1F3FE 200D 2642 FE0F 200D 27A1 FE0F +P:1F6B6 1F3FE 200D 2642 200D 27A1 FE0F +P:1F6B6 1F3FE 200D 2642 FE0F 200D 27A1 +P:1F6B6 1F3FE 200D 2642 200D 27A1 +P:1F6B6 1F3FF 200D 2642 FE0F 200D 27A1 FE0F +P:1F6B6 1F3FF 200D 2642 200D 27A1 FE0F +P:1F6B6 1F3FF 200D 2642 FE0F 200D 27A1 +P:1F6B6 1F3FF 200D 2642 200D 27A1 +P:1F9CD +P:1F9CD 1F3FB +P:1F9CD 1F3FC +P:1F9CD 1F3FD +P:1F9CD 1F3FE +P:1F9CD 1F3FF +P:1F9CD 200D 2642 FE0F +P:1F9CD 200D 2642 +P:1F9CD 1F3FB 200D 2642 FE0F +P:1F9CD 1F3FB 200D 2642 +P:1F9CD 1F3FC 200D 2642 FE0F +P:1F9CD 1F3FC 200D 2642 +P:1F9CD 1F3FD 200D 2642 FE0F +P:1F9CD 1F3FD 200D 2642 +P:1F9CD 1F3FE 200D 2642 FE0F +P:1F9CD 1F3FE 200D 2642 +P:1F9CD 1F3FF 200D 2642 FE0F +P:1F9CD 1F3FF 200D 2642 +P:1F9CD 200D 2640 FE0F +P:1F9CD 200D 2640 +P:1F9CD 1F3FB 200D 2640 FE0F +P:1F9CD 1F3FB 200D 2640 +P:1F9CD 1F3FC 200D 2640 FE0F +P:1F9CD 1F3FC 200D 2640 +P:1F9CD 1F3FD 200D 2640 FE0F +P:1F9CD 1F3FD 200D 2640 +P:1F9CD 1F3FE 200D 2640 FE0F +P:1F9CD 1F3FE 200D 2640 +P:1F9CD 1F3FF 200D 2640 FE0F +P:1F9CD 1F3FF 200D 2640 +P:1F9CE +P:1F9CE 1F3FB +P:1F9CE 1F3FC +P:1F9CE 1F3FD +P:1F9CE 1F3FE +P:1F9CE 1F3FF +P:1F9CE 200D 2642 FE0F +P:1F9CE 200D 2642 +P:1F9CE 1F3FB 200D 2642 FE0F +P:1F9CE 1F3FB 200D 2642 +P:1F9CE 1F3FC 200D 2642 FE0F +P:1F9CE 1F3FC 200D 2642 +P:1F9CE 1F3FD 200D 2642 FE0F +P:1F9CE 1F3FD 200D 2642 +P:1F9CE 1F3FE 200D 2642 FE0F +P:1F9CE 1F3FE 200D 2642 +P:1F9CE 1F3FF 200D 2642 FE0F +P:1F9CE 1F3FF 200D 2642 +P:1F9CE 200D 2640 FE0F +P:1F9CE 200D 2640 +P:1F9CE 1F3FB 200D 2640 FE0F +P:1F9CE 1F3FB 200D 2640 +P:1F9CE 1F3FC 200D 2640 FE0F +P:1F9CE 1F3FC 200D 2640 +P:1F9CE 1F3FD 200D 2640 FE0F +P:1F9CE 1F3FD 200D 2640 +P:1F9CE 1F3FE 200D 2640 FE0F +P:1F9CE 1F3FE 200D 2640 +P:1F9CE 1F3FF 200D 2640 FE0F +P:1F9CE 1F3FF 200D 2640 +P:1F9CE 200D 27A1 FE0F +P:1F9CE 200D 27A1 +P:1F9CE 1F3FB 200D 27A1 FE0F +P:1F9CE 1F3FB 200D 27A1 +P:1F9CE 1F3FC 200D 27A1 FE0F +P:1F9CE 1F3FC 200D 27A1 +P:1F9CE 1F3FD 200D 27A1 FE0F +P:1F9CE 1F3FD 200D 27A1 +P:1F9CE 1F3FE 200D 27A1 FE0F +P:1F9CE 1F3FE 200D 27A1 +P:1F9CE 1F3FF 200D 27A1 FE0F +P:1F9CE 1F3FF 200D 27A1 +P:1F9CE 200D 2640 FE0F 200D 27A1 FE0F +P:1F9CE 200D 2640 200D 27A1 FE0F +P:1F9CE 200D 2640 FE0F 200D 27A1 +P:1F9CE 200D 2640 200D 27A1 +P:1F9CE 1F3FB 200D 2640 FE0F 200D 27A1 FE0F +P:1F9CE 1F3FB 200D 2640 200D 27A1 FE0F +P:1F9CE 1F3FB 200D 2640 FE0F 200D 27A1 +P:1F9CE 1F3FB 200D 2640 200D 27A1 +P:1F9CE 1F3FC 200D 2640 FE0F 200D 27A1 FE0F +P:1F9CE 1F3FC 200D 2640 200D 27A1 FE0F +P:1F9CE 1F3FC 200D 2640 FE0F 200D 27A1 +P:1F9CE 1F3FC 200D 2640 200D 27A1 +P:1F9CE 1F3FD 200D 2640 FE0F 200D 27A1 FE0F +P:1F9CE 1F3FD 200D 2640 200D 27A1 FE0F +P:1F9CE 1F3FD 200D 2640 FE0F 200D 27A1 +P:1F9CE 1F3FD 200D 2640 200D 27A1 +P:1F9CE 1F3FE 200D 2640 FE0F 200D 27A1 FE0F +P:1F9CE 1F3FE 200D 2640 200D 27A1 FE0F +P:1F9CE 1F3FE 200D 2640 FE0F 200D 27A1 +P:1F9CE 1F3FE 200D 2640 200D 27A1 +P:1F9CE 1F3FF 200D 2640 FE0F 200D 27A1 FE0F +P:1F9CE 1F3FF 200D 2640 200D 27A1 FE0F +P:1F9CE 1F3FF 200D 2640 FE0F 200D 27A1 +P:1F9CE 1F3FF 200D 2640 200D 27A1 +P:1F9CE 200D 2642 FE0F 200D 27A1 FE0F +P:1F9CE 200D 2642 200D 27A1 FE0F +P:1F9CE 200D 2642 FE0F 200D 27A1 +P:1F9CE 200D 2642 200D 27A1 +P:1F9CE 1F3FB 200D 2642 FE0F 200D 27A1 FE0F +P:1F9CE 1F3FB 200D 2642 200D 27A1 FE0F +P:1F9CE 1F3FB 200D 2642 FE0F 200D 27A1 +P:1F9CE 1F3FB 200D 2642 200D 27A1 +P:1F9CE 1F3FC 200D 2642 FE0F 200D 27A1 FE0F +P:1F9CE 1F3FC 200D 2642 200D 27A1 FE0F +P:1F9CE 1F3FC 200D 2642 FE0F 200D 27A1 +P:1F9CE 1F3FC 200D 2642 200D 27A1 +P:1F9CE 1F3FD 200D 2642 FE0F 200D 27A1 FE0F +P:1F9CE 1F3FD 200D 2642 200D 27A1 FE0F +P:1F9CE 1F3FD 200D 2642 FE0F 200D 27A1 +P:1F9CE 1F3FD 200D 2642 200D 27A1 +P:1F9CE 1F3FE 200D 2642 FE0F 200D 27A1 FE0F +P:1F9CE 1F3FE 200D 2642 200D 27A1 FE0F +P:1F9CE 1F3FE 200D 2642 FE0F 200D 27A1 +P:1F9CE 1F3FE 200D 2642 200D 27A1 +P:1F9CE 1F3FF 200D 2642 FE0F 200D 27A1 FE0F +P:1F9CE 1F3FF 200D 2642 200D 27A1 FE0F +P:1F9CE 1F3FF 200D 2642 FE0F 200D 27A1 +P:1F9CE 1F3FF 200D 2642 200D 27A1 +P:1F9D1 200D 1F9AF +P:1F9D1 1F3FB 200D 1F9AF +P:1F9D1 1F3FC 200D 1F9AF +P:1F9D1 1F3FD 200D 1F9AF +P:1F9D1 1F3FE 200D 1F9AF +P:1F9D1 1F3FF 200D 1F9AF +P:1F9D1 200D 1F9AF 200D 27A1 FE0F +P:1F9D1 200D 1F9AF 200D 27A1 +P:1F9D1 1F3FB 200D 1F9AF 200D 27A1 FE0F +P:1F9D1 1F3FB 200D 1F9AF 200D 27A1 +P:1F9D1 1F3FC 200D 1F9AF 200D 27A1 FE0F +P:1F9D1 1F3FC 200D 1F9AF 200D 27A1 +P:1F9D1 1F3FD 200D 1F9AF 200D 27A1 FE0F +P:1F9D1 1F3FD 200D 1F9AF 200D 27A1 +P:1F9D1 1F3FE 200D 1F9AF 200D 27A1 FE0F +P:1F9D1 1F3FE 200D 1F9AF 200D 27A1 +P:1F9D1 1F3FF 200D 1F9AF 200D 27A1 FE0F +P:1F9D1 1F3FF 200D 1F9AF 200D 27A1 +P:1F468 200D 1F9AF +P:1F468 1F3FB 200D 1F9AF +P:1F468 1F3FC 200D 1F9AF +P:1F468 1F3FD 200D 1F9AF +P:1F468 1F3FE 200D 1F9AF +P:1F468 1F3FF 200D 1F9AF +P:1F468 200D 1F9AF 200D 27A1 FE0F +P:1F468 200D 1F9AF 200D 27A1 +P:1F468 1F3FB 200D 1F9AF 200D 27A1 FE0F +P:1F468 1F3FB 200D 1F9AF 200D 27A1 +P:1F468 1F3FC 200D 1F9AF 200D 27A1 FE0F +P:1F468 1F3FC 200D 1F9AF 200D 27A1 +P:1F468 1F3FD 200D 1F9AF 200D 27A1 FE0F +P:1F468 1F3FD 200D 1F9AF 200D 27A1 +P:1F468 1F3FE 200D 1F9AF 200D 27A1 FE0F +P:1F468 1F3FE 200D 1F9AF 200D 27A1 +P:1F468 1F3FF 200D 1F9AF 200D 27A1 FE0F +P:1F468 1F3FF 200D 1F9AF 200D 27A1 +P:1F469 200D 1F9AF +P:1F469 1F3FB 200D 1F9AF +P:1F469 1F3FC 200D 1F9AF +P:1F469 1F3FD 200D 1F9AF +P:1F469 1F3FE 200D 1F9AF +P:1F469 1F3FF 200D 1F9AF +P:1F469 200D 1F9AF 200D 27A1 FE0F +P:1F469 200D 1F9AF 200D 27A1 +P:1F469 1F3FB 200D 1F9AF 200D 27A1 FE0F +P:1F469 1F3FB 200D 1F9AF 200D 27A1 +P:1F469 1F3FC 200D 1F9AF 200D 27A1 FE0F +P:1F469 1F3FC 200D 1F9AF 200D 27A1 +P:1F469 1F3FD 200D 1F9AF 200D 27A1 FE0F +P:1F469 1F3FD 200D 1F9AF 200D 27A1 +P:1F469 1F3FE 200D 1F9AF 200D 27A1 FE0F +P:1F469 1F3FE 200D 1F9AF 200D 27A1 +P:1F469 1F3FF 200D 1F9AF 200D 27A1 FE0F +P:1F469 1F3FF 200D 1F9AF 200D 27A1 +P:1F9D1 200D 1F9BC +P:1F9D1 1F3FB 200D 1F9BC +P:1F9D1 1F3FC 200D 1F9BC +P:1F9D1 1F3FD 200D 1F9BC +P:1F9D1 1F3FE 200D 1F9BC +P:1F9D1 1F3FF 200D 1F9BC +P:1F9D1 200D 1F9BC 200D 27A1 FE0F +P:1F9D1 200D 1F9BC 200D 27A1 +P:1F9D1 1F3FB 200D 1F9BC 200D 27A1 FE0F +P:1F9D1 1F3FB 200D 1F9BC 200D 27A1 +P:1F9D1 1F3FC 200D 1F9BC 200D 27A1 FE0F +P:1F9D1 1F3FC 200D 1F9BC 200D 27A1 +P:1F9D1 1F3FD 200D 1F9BC 200D 27A1 FE0F +P:1F9D1 1F3FD 200D 1F9BC 200D 27A1 +P:1F9D1 1F3FE 200D 1F9BC 200D 27A1 FE0F +P:1F9D1 1F3FE 200D 1F9BC 200D 27A1 +P:1F9D1 1F3FF 200D 1F9BC 200D 27A1 FE0F +P:1F9D1 1F3FF 200D 1F9BC 200D 27A1 +P:1F468 200D 1F9BC +P:1F468 1F3FB 200D 1F9BC +P:1F468 1F3FC 200D 1F9BC +P:1F468 1F3FD 200D 1F9BC +P:1F468 1F3FE 200D 1F9BC +P:1F468 1F3FF 200D 1F9BC +P:1F468 200D 1F9BC 200D 27A1 FE0F +P:1F468 200D 1F9BC 200D 27A1 +P:1F468 1F3FB 200D 1F9BC 200D 27A1 FE0F +P:1F468 1F3FB 200D 1F9BC 200D 27A1 +P:1F468 1F3FC 200D 1F9BC 200D 27A1 FE0F +P:1F468 1F3FC 200D 1F9BC 200D 27A1 +P:1F468 1F3FD 200D 1F9BC 200D 27A1 FE0F +P:1F468 1F3FD 200D 1F9BC 200D 27A1 +P:1F468 1F3FE 200D 1F9BC 200D 27A1 FE0F +P:1F468 1F3FE 200D 1F9BC 200D 27A1 +P:1F468 1F3FF 200D 1F9BC 200D 27A1 FE0F +P:1F468 1F3FF 200D 1F9BC 200D 27A1 +P:1F469 200D 1F9BC +P:1F469 1F3FB 200D 1F9BC +P:1F469 1F3FC 200D 1F9BC +P:1F469 1F3FD 200D 1F9BC +P:1F469 1F3FE 200D 1F9BC +P:1F469 1F3FF 200D 1F9BC +P:1F469 200D 1F9BC 200D 27A1 FE0F +P:1F469 200D 1F9BC 200D 27A1 +P:1F469 1F3FB 200D 1F9BC 200D 27A1 FE0F +P:1F469 1F3FB 200D 1F9BC 200D 27A1 +P:1F469 1F3FC 200D 1F9BC 200D 27A1 FE0F + """.trimIndent(), + """ +P:1F469 1F3FC 200D 1F9BC 200D 27A1 +P:1F469 1F3FD 200D 1F9BC 200D 27A1 FE0F +P:1F469 1F3FD 200D 1F9BC 200D 27A1 +P:1F469 1F3FE 200D 1F9BC 200D 27A1 FE0F +P:1F469 1F3FE 200D 1F9BC 200D 27A1 +P:1F469 1F3FF 200D 1F9BC 200D 27A1 FE0F +P:1F469 1F3FF 200D 1F9BC 200D 27A1 +P:1F9D1 200D 1F9BD +P:1F9D1 1F3FB 200D 1F9BD +P:1F9D1 1F3FC 200D 1F9BD +P:1F9D1 1F3FD 200D 1F9BD +P:1F9D1 1F3FE 200D 1F9BD +P:1F9D1 1F3FF 200D 1F9BD +P:1F9D1 200D 1F9BD 200D 27A1 FE0F +P:1F9D1 200D 1F9BD 200D 27A1 +P:1F9D1 1F3FB 200D 1F9BD 200D 27A1 FE0F +P:1F9D1 1F3FB 200D 1F9BD 200D 27A1 +P:1F9D1 1F3FC 200D 1F9BD 200D 27A1 FE0F +P:1F9D1 1F3FC 200D 1F9BD 200D 27A1 +P:1F9D1 1F3FD 200D 1F9BD 200D 27A1 FE0F +P:1F9D1 1F3FD 200D 1F9BD 200D 27A1 +P:1F9D1 1F3FE 200D 1F9BD 200D 27A1 FE0F +P:1F9D1 1F3FE 200D 1F9BD 200D 27A1 +P:1F9D1 1F3FF 200D 1F9BD 200D 27A1 FE0F +P:1F9D1 1F3FF 200D 1F9BD 200D 27A1 +P:1F468 200D 1F9BD +P:1F468 1F3FB 200D 1F9BD +P:1F468 1F3FC 200D 1F9BD +P:1F468 1F3FD 200D 1F9BD +P:1F468 1F3FE 200D 1F9BD +P:1F468 1F3FF 200D 1F9BD +P:1F468 200D 1F9BD 200D 27A1 FE0F +P:1F468 200D 1F9BD 200D 27A1 +P:1F468 1F3FB 200D 1F9BD 200D 27A1 FE0F +P:1F468 1F3FB 200D 1F9BD 200D 27A1 +P:1F468 1F3FC 200D 1F9BD 200D 27A1 FE0F +P:1F468 1F3FC 200D 1F9BD 200D 27A1 +P:1F468 1F3FD 200D 1F9BD 200D 27A1 FE0F +P:1F468 1F3FD 200D 1F9BD 200D 27A1 +P:1F468 1F3FE 200D 1F9BD 200D 27A1 FE0F +P:1F468 1F3FE 200D 1F9BD 200D 27A1 +P:1F468 1F3FF 200D 1F9BD 200D 27A1 FE0F +P:1F468 1F3FF 200D 1F9BD 200D 27A1 +P:1F469 200D 1F9BD +P:1F469 1F3FB 200D 1F9BD +P:1F469 1F3FC 200D 1F9BD +P:1F469 1F3FD 200D 1F9BD +P:1F469 1F3FE 200D 1F9BD +P:1F469 1F3FF 200D 1F9BD +P:1F469 200D 1F9BD 200D 27A1 FE0F +P:1F469 200D 1F9BD 200D 27A1 +P:1F469 1F3FB 200D 1F9BD 200D 27A1 FE0F +P:1F469 1F3FB 200D 1F9BD 200D 27A1 +P:1F469 1F3FC 200D 1F9BD 200D 27A1 FE0F +P:1F469 1F3FC 200D 1F9BD 200D 27A1 +P:1F469 1F3FD 200D 1F9BD 200D 27A1 FE0F +P:1F469 1F3FD 200D 1F9BD 200D 27A1 +P:1F469 1F3FE 200D 1F9BD 200D 27A1 FE0F +P:1F469 1F3FE 200D 1F9BD 200D 27A1 +P:1F469 1F3FF 200D 1F9BD 200D 27A1 FE0F +P:1F469 1F3FF 200D 1F9BD 200D 27A1 +P:1F3C3 +P:1F3C3 1F3FB +P:1F3C3 1F3FC +P:1F3C3 1F3FD +P:1F3C3 1F3FE +P:1F3C3 1F3FF +P:1F3C3 200D 2642 FE0F +P:1F3C3 200D 2642 +P:1F3C3 1F3FB 200D 2642 FE0F +P:1F3C3 1F3FB 200D 2642 +P:1F3C3 1F3FC 200D 2642 FE0F +P:1F3C3 1F3FC 200D 2642 +P:1F3C3 1F3FD 200D 2642 FE0F +P:1F3C3 1F3FD 200D 2642 +P:1F3C3 1F3FE 200D 2642 FE0F +P:1F3C3 1F3FE 200D 2642 +P:1F3C3 1F3FF 200D 2642 FE0F +P:1F3C3 1F3FF 200D 2642 +P:1F3C3 200D 2640 FE0F +P:1F3C3 200D 2640 +P:1F3C3 1F3FB 200D 2640 FE0F +P:1F3C3 1F3FB 200D 2640 +P:1F3C3 1F3FC 200D 2640 FE0F +P:1F3C3 1F3FC 200D 2640 +P:1F3C3 1F3FD 200D 2640 FE0F +P:1F3C3 1F3FD 200D 2640 +P:1F3C3 1F3FE 200D 2640 FE0F +P:1F3C3 1F3FE 200D 2640 +P:1F3C3 1F3FF 200D 2640 FE0F +P:1F3C3 1F3FF 200D 2640 +P:1F3C3 200D 27A1 FE0F +P:1F3C3 200D 27A1 +P:1F3C3 1F3FB 200D 27A1 FE0F +P:1F3C3 1F3FB 200D 27A1 +P:1F3C3 1F3FC 200D 27A1 FE0F +P:1F3C3 1F3FC 200D 27A1 +P:1F3C3 1F3FD 200D 27A1 FE0F +P:1F3C3 1F3FD 200D 27A1 +P:1F3C3 1F3FE 200D 27A1 FE0F +P:1F3C3 1F3FE 200D 27A1 +P:1F3C3 1F3FF 200D 27A1 FE0F +P:1F3C3 1F3FF 200D 27A1 +P:1F3C3 200D 2640 FE0F 200D 27A1 FE0F +P:1F3C3 200D 2640 200D 27A1 FE0F +P:1F3C3 200D 2640 FE0F 200D 27A1 +P:1F3C3 200D 2640 200D 27A1 +P:1F3C3 1F3FB 200D 2640 FE0F 200D 27A1 FE0F +P:1F3C3 1F3FB 200D 2640 200D 27A1 FE0F +P:1F3C3 1F3FB 200D 2640 FE0F 200D 27A1 +P:1F3C3 1F3FB 200D 2640 200D 27A1 +P:1F3C3 1F3FC 200D 2640 FE0F 200D 27A1 FE0F +P:1F3C3 1F3FC 200D 2640 200D 27A1 FE0F +P:1F3C3 1F3FC 200D 2640 FE0F 200D 27A1 +P:1F3C3 1F3FC 200D 2640 200D 27A1 +P:1F3C3 1F3FD 200D 2640 FE0F 200D 27A1 FE0F +P:1F3C3 1F3FD 200D 2640 200D 27A1 FE0F +P:1F3C3 1F3FD 200D 2640 FE0F 200D 27A1 +P:1F3C3 1F3FD 200D 2640 200D 27A1 +P:1F3C3 1F3FE 200D 2640 FE0F 200D 27A1 FE0F +P:1F3C3 1F3FE 200D 2640 200D 27A1 FE0F +P:1F3C3 1F3FE 200D 2640 FE0F 200D 27A1 +P:1F3C3 1F3FE 200D 2640 200D 27A1 +P:1F3C3 1F3FF 200D 2640 FE0F 200D 27A1 FE0F +P:1F3C3 1F3FF 200D 2640 200D 27A1 FE0F +P:1F3C3 1F3FF 200D 2640 FE0F 200D 27A1 +P:1F3C3 1F3FF 200D 2640 200D 27A1 +P:1F3C3 200D 2642 FE0F 200D 27A1 FE0F +P:1F3C3 200D 2642 200D 27A1 FE0F +P:1F3C3 200D 2642 FE0F 200D 27A1 +P:1F3C3 200D 2642 200D 27A1 +P:1F3C3 1F3FB 200D 2642 FE0F 200D 27A1 FE0F +P:1F3C3 1F3FB 200D 2642 200D 27A1 FE0F +P:1F3C3 1F3FB 200D 2642 FE0F 200D 27A1 +P:1F3C3 1F3FB 200D 2642 200D 27A1 +P:1F3C3 1F3FC 200D 2642 FE0F 200D 27A1 FE0F +P:1F3C3 1F3FC 200D 2642 200D 27A1 FE0F +P:1F3C3 1F3FC 200D 2642 FE0F 200D 27A1 +P:1F3C3 1F3FC 200D 2642 200D 27A1 +P:1F3C3 1F3FD 200D 2642 FE0F 200D 27A1 FE0F +P:1F3C3 1F3FD 200D 2642 200D 27A1 FE0F +P:1F3C3 1F3FD 200D 2642 FE0F 200D 27A1 +P:1F3C3 1F3FD 200D 2642 200D 27A1 +P:1F3C3 1F3FE 200D 2642 FE0F 200D 27A1 FE0F +P:1F3C3 1F3FE 200D 2642 200D 27A1 FE0F +P:1F3C3 1F3FE 200D 2642 FE0F 200D 27A1 +P:1F3C3 1F3FE 200D 2642 200D 27A1 +P:1F3C3 1F3FF 200D 2642 FE0F 200D 27A1 FE0F +P:1F3C3 1F3FF 200D 2642 200D 27A1 FE0F +P:1F3C3 1F3FF 200D 2642 FE0F 200D 27A1 +P:1F3C3 1F3FF 200D 2642 200D 27A1 +P:1F9D1 200D 1FA70 +P:1F9D1 1F3FB 200D 1FA70 +P:1F9D1 1F3FC 200D 1FA70 +P:1F9D1 1F3FD 200D 1FA70 +P:1F9D1 1F3FE 200D 1FA70 +P:1F9D1 1F3FF 200D 1FA70 +P:1F483 +P:1F483 1F3FB +P:1F483 1F3FC +P:1F483 1F3FD +P:1F483 1F3FE +P:1F483 1F3FF +P:1F57A +P:1F57A 1F3FB +P:1F57A 1F3FC +P:1F57A 1F3FD +P:1F57A 1F3FE +P:1F57A 1F3FF +P:1F574 FE0F +P:1F574 +P:1F574 1F3FB +P:1F574 1F3FC +P:1F574 1F3FD +P:1F574 1F3FE +P:1F574 1F3FF +P:1F46F +P:1F46F 1F3FB +P:1F46F 1F3FC +P:1F46F 1F3FD +P:1F46F 1F3FE +P:1F46F 1F3FF +P:1F46F 200D 2642 FE0F +P:1F46F 200D 2642 +P:1F46F 1F3FB 200D 2642 FE0F +P:1F46F 1F3FB 200D 2642 +P:1F46F 1F3FC 200D 2642 FE0F +P:1F46F 1F3FC 200D 2642 +P:1F46F 1F3FD 200D 2642 FE0F +P:1F46F 1F3FD 200D 2642 +P:1F46F 1F3FE 200D 2642 FE0F +P:1F46F 1F3FE 200D 2642 +P:1F46F 1F3FF 200D 2642 FE0F +P:1F46F 1F3FF 200D 2642 +P:1F46F 200D 2640 FE0F +P:1F46F 200D 2640 +P:1F46F 1F3FB 200D 2640 FE0F +P:1F46F 1F3FB 200D 2640 +P:1F46F 1F3FC 200D 2640 FE0F +P:1F46F 1F3FC 200D 2640 +P:1F46F 1F3FD 200D 2640 FE0F +P:1F46F 1F3FD 200D 2640 +P:1F46F 1F3FE 200D 2640 FE0F +P:1F46F 1F3FE 200D 2640 +P:1F46F 1F3FF 200D 2640 FE0F +P:1F46F 1F3FF 200D 2640 +P:1F9D1 1F3FB 200D 1F430 200D 1F9D1 1F3FC +P:1F9D1 1F3FB 200D 1F430 200D 1F9D1 1F3FD +P:1F9D1 1F3FB 200D 1F430 200D 1F9D1 1F3FE +P:1F9D1 1F3FB 200D 1F430 200D 1F9D1 1F3FF +P:1F9D1 1F3FC 200D 1F430 200D 1F9D1 1F3FB +P:1F9D1 1F3FC 200D 1F430 200D 1F9D1 1F3FD +P:1F9D1 1F3FC 200D 1F430 200D 1F9D1 1F3FE +P:1F9D1 1F3FC 200D 1F430 200D 1F9D1 1F3FF +P:1F9D1 1F3FD 200D 1F430 200D 1F9D1 1F3FB +P:1F9D1 1F3FD 200D 1F430 200D 1F9D1 1F3FC +P:1F9D1 1F3FD 200D 1F430 200D 1F9D1 1F3FE +P:1F9D1 1F3FD 200D 1F430 200D 1F9D1 1F3FF +P:1F9D1 1F3FE 200D 1F430 200D 1F9D1 1F3FB +P:1F9D1 1F3FE 200D 1F430 200D 1F9D1 1F3FC +P:1F9D1 1F3FE 200D 1F430 200D 1F9D1 1F3FD +P:1F9D1 1F3FE 200D 1F430 200D 1F9D1 1F3FF +P:1F9D1 1F3FF 200D 1F430 200D 1F9D1 1F3FB +P:1F9D1 1F3FF 200D 1F430 200D 1F9D1 1F3FC +P:1F9D1 1F3FF 200D 1F430 200D 1F9D1 1F3FD +P:1F9D1 1F3FF 200D 1F430 200D 1F9D1 1F3FE +P:1F468 1F3FB 200D 1F430 200D 1F468 1F3FC +P:1F468 1F3FB 200D 1F430 200D 1F468 1F3FD +P:1F468 1F3FB 200D 1F430 200D 1F468 1F3FE +P:1F468 1F3FB 200D 1F430 200D 1F468 1F3FF +P:1F468 1F3FC 200D 1F430 200D 1F468 1F3FB +P:1F468 1F3FC 200D 1F430 200D 1F468 1F3FD +P:1F468 1F3FC 200D 1F430 200D 1F468 1F3FE +P:1F468 1F3FC 200D 1F430 200D 1F468 1F3FF +P:1F468 1F3FD 200D 1F430 200D 1F468 1F3FB +P:1F468 1F3FD 200D 1F430 200D 1F468 1F3FC +P:1F468 1F3FD 200D 1F430 200D 1F468 1F3FE +P:1F468 1F3FD 200D 1F430 200D 1F468 1F3FF +P:1F468 1F3FE 200D 1F430 200D 1F468 1F3FB +P:1F468 1F3FE 200D 1F430 200D 1F468 1F3FC +P:1F468 1F3FE 200D 1F430 200D 1F468 1F3FD +P:1F468 1F3FE 200D 1F430 200D 1F468 1F3FF +P:1F468 1F3FF 200D 1F430 200D 1F468 1F3FB +P:1F468 1F3FF 200D 1F430 200D 1F468 1F3FC +P:1F468 1F3FF 200D 1F430 200D 1F468 1F3FD +P:1F468 1F3FF 200D 1F430 200D 1F468 1F3FE +P:1F469 1F3FB 200D 1F430 200D 1F469 1F3FC +P:1F469 1F3FB 200D 1F430 200D 1F469 1F3FD +P:1F469 1F3FB 200D 1F430 200D 1F469 1F3FE +P:1F469 1F3FB 200D 1F430 200D 1F469 1F3FF +P:1F469 1F3FC 200D 1F430 200D 1F469 1F3FB +P:1F469 1F3FC 200D 1F430 200D 1F469 1F3FD +P:1F469 1F3FC 200D 1F430 200D 1F469 1F3FE +P:1F469 1F3FC 200D 1F430 200D 1F469 1F3FF +P:1F469 1F3FD 200D 1F430 200D 1F469 1F3FB +P:1F469 1F3FD 200D 1F430 200D 1F469 1F3FC +P:1F469 1F3FD 200D 1F430 200D 1F469 1F3FE +P:1F469 1F3FD 200D 1F430 200D 1F469 1F3FF +P:1F469 1F3FE 200D 1F430 200D 1F469 1F3FB +P:1F469 1F3FE 200D 1F430 200D 1F469 1F3FC +P:1F469 1F3FE 200D 1F430 200D 1F469 1F3FD +P:1F469 1F3FE 200D 1F430 200D 1F469 1F3FF +P:1F469 1F3FF 200D 1F430 200D 1F469 1F3FB +P:1F469 1F3FF 200D 1F430 200D 1F469 1F3FC +P:1F469 1F3FF 200D 1F430 200D 1F469 1F3FD +P:1F469 1F3FF 200D 1F430 200D 1F469 1F3FE +P:1F9D6 +P:1F9D6 1F3FB +P:1F9D6 1F3FC +P:1F9D6 1F3FD +P:1F9D6 1F3FE +P:1F9D6 1F3FF +P:1F9D6 200D 2642 FE0F +P:1F9D6 200D 2642 +P:1F9D6 1F3FB 200D 2642 FE0F +P:1F9D6 1F3FB 200D 2642 +P:1F9D6 1F3FC 200D 2642 FE0F +P:1F9D6 1F3FC 200D 2642 +P:1F9D6 1F3FD 200D 2642 FE0F +P:1F9D6 1F3FD 200D 2642 +P:1F9D6 1F3FE 200D 2642 FE0F +P:1F9D6 1F3FE 200D 2642 +P:1F9D6 1F3FF 200D 2642 FE0F +P:1F9D6 1F3FF 200D 2642 +P:1F9D6 200D 2640 FE0F +P:1F9D6 200D 2640 +P:1F9D6 1F3FB 200D 2640 FE0F +P:1F9D6 1F3FB 200D 2640 +P:1F9D6 1F3FC 200D 2640 FE0F +P:1F9D6 1F3FC 200D 2640 +P:1F9D6 1F3FD 200D 2640 FE0F +P:1F9D6 1F3FD 200D 2640 +P:1F9D6 1F3FE 200D 2640 FE0F +P:1F9D6 1F3FE 200D 2640 +P:1F9D6 1F3FF 200D 2640 FE0F +P:1F9D6 1F3FF 200D 2640 +P:1F9D7 +P:1F9D7 1F3FB +P:1F9D7 1F3FC +P:1F9D7 1F3FD +P:1F9D7 1F3FE +P:1F9D7 1F3FF +P:1F9D7 200D 2642 FE0F +P:1F9D7 200D 2642 +P:1F9D7 1F3FB 200D 2642 FE0F +P:1F9D7 1F3FB 200D 2642 +P:1F9D7 1F3FC 200D 2642 FE0F +P:1F9D7 1F3FC 200D 2642 +P:1F9D7 1F3FD 200D 2642 FE0F +P:1F9D7 1F3FD 200D 2642 +P:1F9D7 1F3FE 200D 2642 FE0F +P:1F9D7 1F3FE 200D 2642 +P:1F9D7 1F3FF 200D 2642 FE0F +P:1F9D7 1F3FF 200D 2642 +P:1F9D7 200D 2640 FE0F +P:1F9D7 200D 2640 +P:1F9D7 1F3FB 200D 2640 FE0F +P:1F9D7 1F3FB 200D 2640 +P:1F9D7 1F3FC 200D 2640 FE0F +P:1F9D7 1F3FC 200D 2640 +P:1F9D7 1F3FD 200D 2640 FE0F +P:1F9D7 1F3FD 200D 2640 +P:1F9D7 1F3FE 200D 2640 FE0F +P:1F9D7 1F3FE 200D 2640 +P:1F9D7 1F3FF 200D 2640 FE0F +P:1F9D7 1F3FF 200D 2640 +P:1F93A +P:1F3C7 +P:1F3C7 1F3FB +P:1F3C7 1F3FC +P:1F3C7 1F3FD +P:1F3C7 1F3FE +P:1F3C7 1F3FF +P:26F7 FE0F +P:26F7 +P:1F3C2 +P:1F3C2 1F3FB +P:1F3C2 1F3FC +P:1F3C2 1F3FD +P:1F3C2 1F3FE +P:1F3C2 1F3FF +P:1F3CC FE0F +P:1F3CC +P:1F3CC 1F3FB +P:1F3CC 1F3FC +P:1F3CC 1F3FD +P:1F3CC 1F3FE +P:1F3CC 1F3FF +P:1F3CC FE0F 200D 2642 FE0F +P:1F3CC 200D 2642 FE0F +P:1F3CC FE0F 200D 2642 +P:1F3CC 200D 2642 +P:1F3CC 1F3FB 200D 2642 FE0F +P:1F3CC 1F3FB 200D 2642 +P:1F3CC 1F3FC 200D 2642 FE0F +P:1F3CC 1F3FC 200D 2642 +P:1F3CC 1F3FD 200D 2642 FE0F +P:1F3CC 1F3FD 200D 2642 +P:1F3CC 1F3FE 200D 2642 FE0F +P:1F3CC 1F3FE 200D 2642 +P:1F3CC 1F3FF 200D 2642 FE0F +P:1F3CC 1F3FF 200D 2642 +P:1F3CC FE0F 200D 2640 FE0F +P:1F3CC 200D 2640 FE0F +P:1F3CC FE0F 200D 2640 +P:1F3CC 200D 2640 +P:1F3CC 1F3FB 200D 2640 FE0F +P:1F3CC 1F3FB 200D 2640 +P:1F3CC 1F3FC 200D 2640 FE0F +P:1F3CC 1F3FC 200D 2640 +P:1F3CC 1F3FD 200D 2640 FE0F +P:1F3CC 1F3FD 200D 2640 +P:1F3CC 1F3FE 200D 2640 FE0F +P:1F3CC 1F3FE 200D 2640 +P:1F3CC 1F3FF 200D 2640 FE0F +P:1F3CC 1F3FF 200D 2640 +P:1F3C4 +P:1F3C4 1F3FB +P:1F3C4 1F3FC +P:1F3C4 1F3FD +P:1F3C4 1F3FE +P:1F3C4 1F3FF +P:1F3C4 200D 2642 FE0F +P:1F3C4 200D 2642 +P:1F3C4 1F3FB 200D 2642 FE0F +P:1F3C4 1F3FB 200D 2642 +P:1F3C4 1F3FC 200D 2642 FE0F +P:1F3C4 1F3FC 200D 2642 +P:1F3C4 1F3FD 200D 2642 FE0F +P:1F3C4 1F3FD 200D 2642 +P:1F3C4 1F3FE 200D 2642 FE0F +P:1F3C4 1F3FE 200D 2642 +P:1F3C4 1F3FF 200D 2642 FE0F +P:1F3C4 1F3FF 200D 2642 +P:1F3C4 200D 2640 FE0F +P:1F3C4 200D 2640 +P:1F3C4 1F3FB 200D 2640 FE0F +P:1F3C4 1F3FB 200D 2640 +P:1F3C4 1F3FC 200D 2640 FE0F +P:1F3C4 1F3FC 200D 2640 +P:1F3C4 1F3FD 200D 2640 FE0F +P:1F3C4 1F3FD 200D 2640 +P:1F3C4 1F3FE 200D 2640 FE0F +P:1F3C4 1F3FE 200D 2640 +P:1F3C4 1F3FF 200D 2640 FE0F +P:1F3C4 1F3FF 200D 2640 +P:1F6A3 +P:1F6A3 1F3FB +P:1F6A3 1F3FC +P:1F6A3 1F3FD +P:1F6A3 1F3FE +P:1F6A3 1F3FF +P:1F6A3 200D 2642 FE0F +P:1F6A3 200D 2642 +P:1F6A3 1F3FB 200D 2642 FE0F +P:1F6A3 1F3FB 200D 2642 +P:1F6A3 1F3FC 200D 2642 FE0F +P:1F6A3 1F3FC 200D 2642 +P:1F6A3 1F3FD 200D 2642 FE0F +P:1F6A3 1F3FD 200D 2642 +P:1F6A3 1F3FE 200D 2642 FE0F +P:1F6A3 1F3FE 200D 2642 +P:1F6A3 1F3FF 200D 2642 FE0F +P:1F6A3 1F3FF 200D 2642 +P:1F6A3 200D 2640 FE0F +P:1F6A3 200D 2640 +P:1F6A3 1F3FB 200D 2640 FE0F +P:1F6A3 1F3FB 200D 2640 +P:1F6A3 1F3FC 200D 2640 FE0F +P:1F6A3 1F3FC 200D 2640 +P:1F6A3 1F3FD 200D 2640 FE0F +P:1F6A3 1F3FD 200D 2640 +P:1F6A3 1F3FE 200D 2640 FE0F +P:1F6A3 1F3FE 200D 2640 +P:1F6A3 1F3FF 200D 2640 FE0F +P:1F6A3 1F3FF 200D 2640 +P:1F3CA +P:1F3CA 1F3FB +P:1F3CA 1F3FC +P:1F3CA 1F3FD +P:1F3CA 1F3FE +P:1F3CA 1F3FF +P:1F3CA 200D 2642 FE0F +P:1F3CA 200D 2642 +P:1F3CA 1F3FB 200D 2642 FE0F +P:1F3CA 1F3FB 200D 2642 +P:1F3CA 1F3FC 200D 2642 FE0F +P:1F3CA 1F3FC 200D 2642 +P:1F3CA 1F3FD 200D 2642 FE0F +P:1F3CA 1F3FD 200D 2642 +P:1F3CA 1F3FE 200D 2642 FE0F +P:1F3CA 1F3FE 200D 2642 +P:1F3CA 1F3FF 200D 2642 FE0F +P:1F3CA 1F3FF 200D 2642 +P:1F3CA 200D 2640 FE0F +P:1F3CA 200D 2640 +P:1F3CA 1F3FB 200D 2640 FE0F +P:1F3CA 1F3FB 200D 2640 +P:1F3CA 1F3FC 200D 2640 FE0F +P:1F3CA 1F3FC 200D 2640 +P:1F3CA 1F3FD 200D 2640 FE0F +P:1F3CA 1F3FD 200D 2640 +P:1F3CA 1F3FE 200D 2640 FE0F +P:1F3CA 1F3FE 200D 2640 +P:1F3CA 1F3FF 200D 2640 FE0F +P:1F3CA 1F3FF 200D 2640 +P:26F9 FE0F +P:26F9 +P:26F9 1F3FB +P:26F9 1F3FC +P:26F9 1F3FD +P:26F9 1F3FE +P:26F9 1F3FF +P:26F9 FE0F 200D 2642 FE0F +P:26F9 200D 2642 FE0F +P:26F9 FE0F 200D 2642 +P:26F9 200D 2642 +P:26F9 1F3FB 200D 2642 FE0F +P:26F9 1F3FB 200D 2642 +P:26F9 1F3FC 200D 2642 FE0F +P:26F9 1F3FC 200D 2642 +P:26F9 1F3FD 200D 2642 FE0F +P:26F9 1F3FD 200D 2642 +P:26F9 1F3FE 200D 2642 FE0F +P:26F9 1F3FE 200D 2642 +P:26F9 1F3FF 200D 2642 FE0F +P:26F9 1F3FF 200D 2642 +P:26F9 FE0F 200D 2640 FE0F +P:26F9 200D 2640 FE0F +P:26F9 FE0F 200D 2640 +P:26F9 200D 2640 +P:26F9 1F3FB 200D 2640 FE0F +P:26F9 1F3FB 200D 2640 +P:26F9 1F3FC 200D 2640 FE0F +P:26F9 1F3FC 200D 2640 +P:26F9 1F3FD 200D 2640 FE0F +P:26F9 1F3FD 200D 2640 +P:26F9 1F3FE 200D 2640 FE0F +P:26F9 1F3FE 200D 2640 +P:26F9 1F3FF 200D 2640 FE0F +P:26F9 1F3FF 200D 2640 +P:1F3CB FE0F +P:1F3CB +P:1F3CB 1F3FB +P:1F3CB 1F3FC +P:1F3CB 1F3FD +P:1F3CB 1F3FE +P:1F3CB 1F3FF +P:1F3CB FE0F 200D 2642 FE0F +P:1F3CB 200D 2642 FE0F +P:1F3CB FE0F 200D 2642 +P:1F3CB 200D 2642 +P:1F3CB 1F3FB 200D 2642 FE0F +P:1F3CB 1F3FB 200D 2642 +P:1F3CB 1F3FC 200D 2642 FE0F +P:1F3CB 1F3FC 200D 2642 +P:1F3CB 1F3FD 200D 2642 FE0F +P:1F3CB 1F3FD 200D 2642 +P:1F3CB 1F3FE 200D 2642 FE0F +P:1F3CB 1F3FE 200D 2642 +P:1F3CB 1F3FF 200D 2642 FE0F +P:1F3CB 1F3FF 200D 2642 +P:1F3CB FE0F 200D 2640 FE0F +P:1F3CB 200D 2640 FE0F +P:1F3CB FE0F 200D 2640 +P:1F3CB 200D 2640 +P:1F3CB 1F3FB 200D 2640 FE0F +P:1F3CB 1F3FB 200D 2640 +P:1F3CB 1F3FC 200D 2640 FE0F +P:1F3CB 1F3FC 200D 2640 +P:1F3CB 1F3FD 200D 2640 FE0F +P:1F3CB 1F3FD 200D 2640 +P:1F3CB 1F3FE 200D 2640 FE0F +P:1F3CB 1F3FE 200D 2640 +P:1F3CB 1F3FF 200D 2640 FE0F +P:1F3CB 1F3FF 200D 2640 +P:1F6B4 +P:1F6B4 1F3FB +P:1F6B4 1F3FC +P:1F6B4 1F3FD +P:1F6B4 1F3FE +P:1F6B4 1F3FF +P:1F6B4 200D 2642 FE0F +P:1F6B4 200D 2642 +P:1F6B4 1F3FB 200D 2642 FE0F +P:1F6B4 1F3FB 200D 2642 +P:1F6B4 1F3FC 200D 2642 FE0F +P:1F6B4 1F3FC 200D 2642 +P:1F6B4 1F3FD 200D 2642 FE0F +P:1F6B4 1F3FD 200D 2642 +P:1F6B4 1F3FE 200D 2642 FE0F +P:1F6B4 1F3FE 200D 2642 +P:1F6B4 1F3FF 200D 2642 FE0F +P:1F6B4 1F3FF 200D 2642 +P:1F6B4 200D 2640 FE0F +P:1F6B4 200D 2640 +P:1F6B4 1F3FB 200D 2640 FE0F +P:1F6B4 1F3FB 200D 2640 +P:1F6B4 1F3FC 200D 2640 FE0F +P:1F6B4 1F3FC 200D 2640 +P:1F6B4 1F3FD 200D 2640 FE0F +P:1F6B4 1F3FD 200D 2640 +P:1F6B4 1F3FE 200D 2640 FE0F +P:1F6B4 1F3FE 200D 2640 +P:1F6B4 1F3FF 200D 2640 FE0F +P:1F6B4 1F3FF 200D 2640 +P:1F6B5 +P:1F6B5 1F3FB +P:1F6B5 1F3FC +P:1F6B5 1F3FD +P:1F6B5 1F3FE +P:1F6B5 1F3FF +P:1F6B5 200D 2642 FE0F +P:1F6B5 200D 2642 +P:1F6B5 1F3FB 200D 2642 FE0F +P:1F6B5 1F3FB 200D 2642 +P:1F6B5 1F3FC 200D 2642 FE0F +P:1F6B5 1F3FC 200D 2642 +P:1F6B5 1F3FD 200D 2642 FE0F +P:1F6B5 1F3FD 200D 2642 +P:1F6B5 1F3FE 200D 2642 FE0F +P:1F6B5 1F3FE 200D 2642 +P:1F6B5 1F3FF 200D 2642 FE0F +P:1F6B5 1F3FF 200D 2642 +P:1F6B5 200D 2640 FE0F +P:1F6B5 200D 2640 +P:1F6B5 1F3FB 200D 2640 FE0F +P:1F6B5 1F3FB 200D 2640 +P:1F6B5 1F3FC 200D 2640 FE0F +P:1F6B5 1F3FC 200D 2640 +P:1F6B5 1F3FD 200D 2640 FE0F +P:1F6B5 1F3FD 200D 2640 +P:1F6B5 1F3FE 200D 2640 FE0F +P:1F6B5 1F3FE 200D 2640 +P:1F6B5 1F3FF 200D 2640 FE0F +P:1F6B5 1F3FF 200D 2640 +P:1F938 +P:1F938 1F3FB +P:1F938 1F3FC +P:1F938 1F3FD +P:1F938 1F3FE +P:1F938 1F3FF +P:1F938 200D 2642 FE0F +P:1F938 200D 2642 +P:1F938 1F3FB 200D 2642 FE0F +P:1F938 1F3FB 200D 2642 +P:1F938 1F3FC 200D 2642 FE0F +P:1F938 1F3FC 200D 2642 +P:1F938 1F3FD 200D 2642 FE0F +P:1F938 1F3FD 200D 2642 +P:1F938 1F3FE 200D 2642 FE0F +P:1F938 1F3FE 200D 2642 +P:1F938 1F3FF 200D 2642 FE0F +P:1F938 1F3FF 200D 2642 +P:1F938 200D 2640 FE0F +P:1F938 200D 2640 +P:1F938 1F3FB 200D 2640 FE0F +P:1F938 1F3FB 200D 2640 +P:1F938 1F3FC 200D 2640 FE0F +P:1F938 1F3FC 200D 2640 +P:1F938 1F3FD 200D 2640 FE0F +P:1F938 1F3FD 200D 2640 +P:1F938 1F3FE 200D 2640 FE0F +P:1F938 1F3FE 200D 2640 +P:1F938 1F3FF 200D 2640 FE0F +P:1F938 1F3FF 200D 2640 +P:1F93C +P:1F93C 1F3FB +P:1F93C 1F3FC +P:1F93C 1F3FD +P:1F93C 1F3FE +P:1F93C 1F3FF +P:1F93C 200D 2642 FE0F +P:1F93C 200D 2642 +P:1F93C 1F3FB 200D 2642 FE0F +P:1F93C 1F3FB 200D 2642 +P:1F93C 1F3FC 200D 2642 FE0F +P:1F93C 1F3FC 200D 2642 +P:1F93C 1F3FD 200D 2642 FE0F +P:1F93C 1F3FD 200D 2642 +P:1F93C 1F3FE 200D 2642 FE0F +P:1F93C 1F3FE 200D 2642 +P:1F93C 1F3FF 200D 2642 FE0F +P:1F93C 1F3FF 200D 2642 +P:1F93C 200D 2640 FE0F +P:1F93C 200D 2640 +P:1F93C 1F3FB 200D 2640 FE0F +P:1F93C 1F3FB 200D 2640 +P:1F93C 1F3FC 200D 2640 FE0F +P:1F93C 1F3FC 200D 2640 +P:1F93C 1F3FD 200D 2640 FE0F +P:1F93C 1F3FD 200D 2640 +P:1F93C 1F3FE 200D 2640 FE0F +P:1F93C 1F3FE 200D 2640 +P:1F93C 1F3FF 200D 2640 FE0F +P:1F93C 1F3FF 200D 2640 +P:1F9D1 1F3FB 200D 1FAEF 200D 1F9D1 1F3FC +P:1F9D1 1F3FB 200D 1FAEF 200D 1F9D1 1F3FD +P:1F9D1 1F3FB 200D 1FAEF 200D 1F9D1 1F3FE +P:1F9D1 1F3FB 200D 1FAEF 200D 1F9D1 1F3FF +P:1F9D1 1F3FC 200D 1FAEF 200D 1F9D1 1F3FB +P:1F9D1 1F3FC 200D 1FAEF 200D 1F9D1 1F3FD +P:1F9D1 1F3FC 200D 1FAEF 200D 1F9D1 1F3FE +P:1F9D1 1F3FC 200D 1FAEF 200D 1F9D1 1F3FF +P:1F9D1 1F3FD 200D 1FAEF 200D 1F9D1 1F3FB +P:1F9D1 1F3FD 200D 1FAEF 200D 1F9D1 1F3FC +P:1F9D1 1F3FD 200D 1FAEF 200D 1F9D1 1F3FE +P:1F9D1 1F3FD 200D 1FAEF 200D 1F9D1 1F3FF +P:1F9D1 1F3FE 200D 1FAEF 200D 1F9D1 1F3FB +P:1F9D1 1F3FE 200D 1FAEF 200D 1F9D1 1F3FC +P:1F9D1 1F3FE 200D 1FAEF 200D 1F9D1 1F3FD +P:1F9D1 1F3FE 200D 1FAEF 200D 1F9D1 1F3FF +P:1F9D1 1F3FF 200D 1FAEF 200D 1F9D1 1F3FB +P:1F9D1 1F3FF 200D 1FAEF 200D 1F9D1 1F3FC +P:1F9D1 1F3FF 200D 1FAEF 200D 1F9D1 1F3FD +P:1F9D1 1F3FF 200D 1FAEF 200D 1F9D1 1F3FE +P:1F468 1F3FB 200D 1FAEF 200D 1F468 1F3FC +P:1F468 1F3FB 200D 1FAEF 200D 1F468 1F3FD +P:1F468 1F3FB 200D 1FAEF 200D 1F468 1F3FE +P:1F468 1F3FB 200D 1FAEF 200D 1F468 1F3FF +P:1F468 1F3FC 200D 1FAEF 200D 1F468 1F3FB +P:1F468 1F3FC 200D 1FAEF 200D 1F468 1F3FD +P:1F468 1F3FC 200D 1FAEF 200D 1F468 1F3FE +P:1F468 1F3FC 200D 1FAEF 200D 1F468 1F3FF +P:1F468 1F3FD 200D 1FAEF 200D 1F468 1F3FB +P:1F468 1F3FD 200D 1FAEF 200D 1F468 1F3FC +P:1F468 1F3FD 200D 1FAEF 200D 1F468 1F3FE +P:1F468 1F3FD 200D 1FAEF 200D 1F468 1F3FF +P:1F468 1F3FE 200D 1FAEF 200D 1F468 1F3FB +P:1F468 1F3FE 200D 1FAEF 200D 1F468 1F3FC +P:1F468 1F3FE 200D 1FAEF 200D 1F468 1F3FD +P:1F468 1F3FE 200D 1FAEF 200D 1F468 1F3FF +P:1F468 1F3FF 200D 1FAEF 200D 1F468 1F3FB +P:1F468 1F3FF 200D 1FAEF 200D 1F468 1F3FC +P:1F468 1F3FF 200D 1FAEF 200D 1F468 1F3FD +P:1F468 1F3FF 200D 1FAEF 200D 1F468 1F3FE +P:1F469 1F3FB 200D 1FAEF 200D 1F469 1F3FC +P:1F469 1F3FB 200D 1FAEF 200D 1F469 1F3FD +P:1F469 1F3FB 200D 1FAEF 200D 1F469 1F3FE +P:1F469 1F3FB 200D 1FAEF 200D 1F469 1F3FF +P:1F469 1F3FC 200D 1FAEF 200D 1F469 1F3FB +P:1F469 1F3FC 200D 1FAEF 200D 1F469 1F3FD +P:1F469 1F3FC 200D 1FAEF 200D 1F469 1F3FE +P:1F469 1F3FC 200D 1FAEF 200D 1F469 1F3FF +P:1F469 1F3FD 200D 1FAEF 200D 1F469 1F3FB +P:1F469 1F3FD 200D 1FAEF 200D 1F469 1F3FC +P:1F469 1F3FD 200D 1FAEF 200D 1F469 1F3FE +P:1F469 1F3FD 200D 1FAEF 200D 1F469 1F3FF +P:1F469 1F3FE 200D 1FAEF 200D 1F469 1F3FB +P:1F469 1F3FE 200D 1FAEF 200D 1F469 1F3FC +P:1F469 1F3FE 200D 1FAEF 200D 1F469 1F3FD +P:1F469 1F3FE 200D 1FAEF 200D 1F469 1F3FF +P:1F469 1F3FF 200D 1FAEF 200D 1F469 1F3FB +P:1F469 1F3FF 200D 1FAEF 200D 1F469 1F3FC +P:1F469 1F3FF 200D 1FAEF 200D 1F469 1F3FD +P:1F469 1F3FF 200D 1FAEF 200D 1F469 1F3FE +P:1F93D +P:1F93D 1F3FB +P:1F93D 1F3FC +P:1F93D 1F3FD +P:1F93D 1F3FE +P:1F93D 1F3FF +P:1F93D 200D 2642 FE0F +P:1F93D 200D 2642 +P:1F93D 1F3FB 200D 2642 FE0F +P:1F93D 1F3FB 200D 2642 +P:1F93D 1F3FC 200D 2642 FE0F +P:1F93D 1F3FC 200D 2642 +P:1F93D 1F3FD 200D 2642 FE0F +P:1F93D 1F3FD 200D 2642 +P:1F93D 1F3FE 200D 2642 FE0F +P:1F93D 1F3FE 200D 2642 +P:1F93D 1F3FF 200D 2642 FE0F +P:1F93D 1F3FF 200D 2642 +P:1F93D 200D 2640 FE0F +P:1F93D 200D 2640 +P:1F93D 1F3FB 200D 2640 FE0F +P:1F93D 1F3FB 200D 2640 +P:1F93D 1F3FC 200D 2640 FE0F +P:1F93D 1F3FC 200D 2640 +P:1F93D 1F3FD 200D 2640 FE0F +P:1F93D 1F3FD 200D 2640 +P:1F93D 1F3FE 200D 2640 FE0F +P:1F93D 1F3FE 200D 2640 +P:1F93D 1F3FF 200D 2640 FE0F +P:1F93D 1F3FF 200D 2640 +P:1F93E +P:1F93E 1F3FB +P:1F93E 1F3FC +P:1F93E 1F3FD +P:1F93E 1F3FE +P:1F93E 1F3FF +P:1F93E 200D 2642 FE0F +P:1F93E 200D 2642 +P:1F93E 1F3FB 200D 2642 FE0F +P:1F93E 1F3FB 200D 2642 +P:1F93E 1F3FC 200D 2642 FE0F +P:1F93E 1F3FC 200D 2642 +P:1F93E 1F3FD 200D 2642 FE0F +P:1F93E 1F3FD 200D 2642 +P:1F93E 1F3FE 200D 2642 FE0F +P:1F93E 1F3FE 200D 2642 +P:1F93E 1F3FF 200D 2642 FE0F +P:1F93E 1F3FF 200D 2642 +P:1F93E 200D 2640 FE0F +P:1F93E 200D 2640 +P:1F93E 1F3FB 200D 2640 FE0F +P:1F93E 1F3FB 200D 2640 +P:1F93E 1F3FC 200D 2640 FE0F +P:1F93E 1F3FC 200D 2640 +P:1F93E 1F3FD 200D 2640 FE0F +P:1F93E 1F3FD 200D 2640 +P:1F93E 1F3FE 200D 2640 FE0F +P:1F93E 1F3FE 200D 2640 +P:1F93E 1F3FF 200D 2640 FE0F +P:1F93E 1F3FF 200D 2640 +P:1F939 +P:1F939 1F3FB +P:1F939 1F3FC +P:1F939 1F3FD +P:1F939 1F3FE +P:1F939 1F3FF +P:1F939 200D 2642 FE0F +P:1F939 200D 2642 +P:1F939 1F3FB 200D 2642 FE0F +P:1F939 1F3FB 200D 2642 +P:1F939 1F3FC 200D 2642 FE0F +P:1F939 1F3FC 200D 2642 +P:1F939 1F3FD 200D 2642 FE0F +P:1F939 1F3FD 200D 2642 +P:1F939 1F3FE 200D 2642 FE0F +P:1F939 1F3FE 200D 2642 +P:1F939 1F3FF 200D 2642 FE0F +P:1F939 1F3FF 200D 2642 +P:1F939 200D 2640 FE0F +P:1F939 200D 2640 +P:1F939 1F3FB 200D 2640 FE0F +P:1F939 1F3FB 200D 2640 +P:1F939 1F3FC 200D 2640 FE0F +P:1F939 1F3FC 200D 2640 +P:1F939 1F3FD 200D 2640 FE0F +P:1F939 1F3FD 200D 2640 +P:1F939 1F3FE 200D 2640 FE0F +P:1F939 1F3FE 200D 2640 +P:1F939 1F3FF 200D 2640 FE0F +P:1F939 1F3FF 200D 2640 +P:1F9D8 +P:1F9D8 1F3FB +P:1F9D8 1F3FC +P:1F9D8 1F3FD +P:1F9D8 1F3FE +P:1F9D8 1F3FF +P:1F9D8 200D 2642 FE0F +P:1F9D8 200D 2642 +P:1F9D8 1F3FB 200D 2642 FE0F +P:1F9D8 1F3FB 200D 2642 +P:1F9D8 1F3FC 200D 2642 FE0F +P:1F9D8 1F3FC 200D 2642 +P:1F9D8 1F3FD 200D 2642 FE0F +P:1F9D8 1F3FD 200D 2642 +P:1F9D8 1F3FE 200D 2642 FE0F +P:1F9D8 1F3FE 200D 2642 +P:1F9D8 1F3FF 200D 2642 FE0F +P:1F9D8 1F3FF 200D 2642 +P:1F9D8 200D 2640 FE0F +P:1F9D8 200D 2640 +P:1F9D8 1F3FB 200D 2640 FE0F +P:1F9D8 1F3FB 200D 2640 +P:1F9D8 1F3FC 200D 2640 FE0F +P:1F9D8 1F3FC 200D 2640 +P:1F9D8 1F3FD 200D 2640 FE0F +P:1F9D8 1F3FD 200D 2640 +P:1F9D8 1F3FE 200D 2640 FE0F +P:1F9D8 1F3FE 200D 2640 +P:1F9D8 1F3FF 200D 2640 FE0F +P:1F9D8 1F3FF 200D 2640 +P:1F6C0 +P:1F6C0 1F3FB +P:1F6C0 1F3FC +P:1F6C0 1F3FD +P:1F6C0 1F3FE +P:1F6C0 1F3FF +P:1F6CC +P:1F6CC 1F3FB +P:1F6CC 1F3FC +P:1F6CC 1F3FD +P:1F6CC 1F3FE +P:1F6CC 1F3FF +P:1F9D1 200D 1F91D 200D 1F9D1 +P:1F9D1 1F3FB 200D 1F91D 200D 1F9D1 1F3FB +P:1F9D1 1F3FB 200D 1F91D 200D 1F9D1 1F3FC +P:1F9D1 1F3FB 200D 1F91D 200D 1F9D1 1F3FD +P:1F9D1 1F3FB 200D 1F91D 200D 1F9D1 1F3FE +P:1F9D1 1F3FB 200D 1F91D 200D 1F9D1 1F3FF +P:1F9D1 1F3FC 200D 1F91D 200D 1F9D1 1F3FB +P:1F9D1 1F3FC 200D 1F91D 200D 1F9D1 1F3FC +P:1F9D1 1F3FC 200D 1F91D 200D 1F9D1 1F3FD +P:1F9D1 1F3FC 200D 1F91D 200D 1F9D1 1F3FE +P:1F9D1 1F3FC 200D 1F91D 200D 1F9D1 1F3FF +P:1F9D1 1F3FD 200D 1F91D 200D 1F9D1 1F3FB +P:1F9D1 1F3FD 200D 1F91D 200D 1F9D1 1F3FC +P:1F9D1 1F3FD 200D 1F91D 200D 1F9D1 1F3FD +P:1F9D1 1F3FD 200D 1F91D 200D 1F9D1 1F3FE +P:1F9D1 1F3FD 200D 1F91D 200D 1F9D1 1F3FF +P:1F9D1 1F3FE 200D 1F91D 200D 1F9D1 1F3FB +P:1F9D1 1F3FE 200D 1F91D 200D 1F9D1 1F3FC +P:1F9D1 1F3FE 200D 1F91D 200D 1F9D1 1F3FD +P:1F9D1 1F3FE 200D 1F91D 200D 1F9D1 1F3FE +P:1F9D1 1F3FE 200D 1F91D 200D 1F9D1 1F3FF +P:1F9D1 1F3FF 200D 1F91D 200D 1F9D1 1F3FB +P:1F9D1 1F3FF 200D 1F91D 200D 1F9D1 1F3FC +P:1F9D1 1F3FF 200D 1F91D 200D 1F9D1 1F3FD +P:1F9D1 1F3FF 200D 1F91D 200D 1F9D1 1F3FE +P:1F9D1 1F3FF 200D 1F91D 200D 1F9D1 1F3FF +P:1F46D +P:1F46D 1F3FB +P:1F469 1F3FB 200D 1F91D 200D 1F469 1F3FC +P:1F469 1F3FB 200D 1F91D 200D 1F469 1F3FD +P:1F469 1F3FB 200D 1F91D 200D 1F469 1F3FE +P:1F469 1F3FB 200D 1F91D 200D 1F469 1F3FF +P:1F469 1F3FC 200D 1F91D 200D 1F469 1F3FB +P:1F46D 1F3FC +P:1F469 1F3FC 200D 1F91D 200D 1F469 1F3FD +P:1F469 1F3FC 200D 1F91D 200D 1F469 1F3FE +P:1F469 1F3FC 200D 1F91D 200D 1F469 1F3FF +P:1F469 1F3FD 200D 1F91D 200D 1F469 1F3FB +P:1F469 1F3FD 200D 1F91D 200D 1F469 1F3FC +P:1F46D 1F3FD +P:1F469 1F3FD 200D 1F91D 200D 1F469 1F3FE +P:1F469 1F3FD 200D 1F91D 200D 1F469 1F3FF +P:1F469 1F3FE 200D 1F91D 200D 1F469 1F3FB +P:1F469 1F3FE 200D 1F91D 200D 1F469 1F3FC +P:1F469 1F3FE 200D 1F91D 200D 1F469 1F3FD +P:1F46D 1F3FE +P:1F469 1F3FE 200D 1F91D 200D 1F469 1F3FF +P:1F469 1F3FF 200D 1F91D 200D 1F469 1F3FB +P:1F469 1F3FF 200D 1F91D 200D 1F469 1F3FC +P:1F469 1F3FF 200D 1F91D 200D 1F469 1F3FD +P:1F469 1F3FF 200D 1F91D 200D 1F469 1F3FE +P:1F46D 1F3FF +P:1F46B +P:1F46B 1F3FB +P:1F469 1F3FB 200D 1F91D 200D 1F468 1F3FC +P:1F469 1F3FB 200D 1F91D 200D 1F468 1F3FD +P:1F469 1F3FB 200D 1F91D 200D 1F468 1F3FE +P:1F469 1F3FB 200D 1F91D 200D 1F468 1F3FF +P:1F469 1F3FC 200D 1F91D 200D 1F468 1F3FB +P:1F46B 1F3FC +P:1F469 1F3FC 200D 1F91D 200D 1F468 1F3FD +P:1F469 1F3FC 200D 1F91D 200D 1F468 1F3FE +P:1F469 1F3FC 200D 1F91D 200D 1F468 1F3FF +P:1F469 1F3FD 200D 1F91D 200D 1F468 1F3FB +P:1F469 1F3FD 200D 1F91D 200D 1F468 1F3FC +P:1F46B 1F3FD +P:1F469 1F3FD 200D 1F91D 200D 1F468 1F3FE +P:1F469 1F3FD 200D 1F91D 200D 1F468 1F3FF +P:1F469 1F3FE 200D 1F91D 200D 1F468 1F3FB +P:1F469 1F3FE 200D 1F91D 200D 1F468 1F3FC +P:1F469 1F3FE 200D 1F91D 200D 1F468 1F3FD +P:1F46B 1F3FE +P:1F469 1F3FE 200D 1F91D 200D 1F468 1F3FF +P:1F469 1F3FF 200D 1F91D 200D 1F468 1F3FB +P:1F469 1F3FF 200D 1F91D 200D 1F468 1F3FC +P:1F469 1F3FF 200D 1F91D 200D 1F468 1F3FD +P:1F469 1F3FF 200D 1F91D 200D 1F468 1F3FE +P:1F46B 1F3FF +P:1F46C +P:1F46C 1F3FB +P:1F468 1F3FB 200D 1F91D 200D 1F468 1F3FC +P:1F468 1F3FB 200D 1F91D 200D 1F468 1F3FD +P:1F468 1F3FB 200D 1F91D 200D 1F468 1F3FE +P:1F468 1F3FB 200D 1F91D 200D 1F468 1F3FF +P:1F468 1F3FC 200D 1F91D 200D 1F468 1F3FB +P:1F46C 1F3FC +P:1F468 1F3FC 200D 1F91D 200D 1F468 1F3FD +P:1F468 1F3FC 200D 1F91D 200D 1F468 1F3FE +P:1F468 1F3FC 200D 1F91D 200D 1F468 1F3FF +P:1F468 1F3FD 200D 1F91D 200D 1F468 1F3FB +P:1F468 1F3FD 200D 1F91D 200D 1F468 1F3FC +P:1F46C 1F3FD +P:1F468 1F3FD 200D 1F91D 200D 1F468 1F3FE +P:1F468 1F3FD 200D 1F91D 200D 1F468 1F3FF +P:1F468 1F3FE 200D 1F91D 200D 1F468 1F3FB +P:1F468 1F3FE 200D 1F91D 200D 1F468 1F3FC +P:1F468 1F3FE 200D 1F91D 200D 1F468 1F3FD +P:1F46C 1F3FE +P:1F468 1F3FE 200D 1F91D 200D 1F468 1F3FF +P:1F468 1F3FF 200D 1F91D 200D 1F468 1F3FB +P:1F468 1F3FF 200D 1F91D 200D 1F468 1F3FC +P:1F468 1F3FF 200D 1F91D 200D 1F468 1F3FD +P:1F468 1F3FF 200D 1F91D 200D 1F468 1F3FE +P:1F46C 1F3FF +P:1F48F +P:1F48F 1F3FB +P:1F48F 1F3FC +P:1F48F 1F3FD +P:1F48F 1F3FE +P:1F48F 1F3FF +P:1F9D1 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FC +P:1F9D1 1F3FB 200D 2764 200D 1F48B 200D 1F9D1 1F3FC +P:1F9D1 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FD +P:1F9D1 1F3FB 200D 2764 200D 1F48B 200D 1F9D1 1F3FD +P:1F9D1 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FE +P:1F9D1 1F3FB 200D 2764 200D 1F48B 200D 1F9D1 1F3FE +P:1F9D1 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FF +P:1F9D1 1F3FB 200D 2764 200D 1F48B 200D 1F9D1 1F3FF +P:1F9D1 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FB +P:1F9D1 1F3FC 200D 2764 200D 1F48B 200D 1F9D1 1F3FB +P:1F9D1 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FD +P:1F9D1 1F3FC 200D 2764 200D 1F48B 200D 1F9D1 1F3FD +P:1F9D1 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FE +P:1F9D1 1F3FC 200D 2764 200D 1F48B 200D 1F9D1 1F3FE +P:1F9D1 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FF +P:1F9D1 1F3FC 200D 2764 200D 1F48B 200D 1F9D1 1F3FF +P:1F9D1 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FB +P:1F9D1 1F3FD 200D 2764 200D 1F48B 200D 1F9D1 1F3FB +P:1F9D1 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FC +P:1F9D1 1F3FD 200D 2764 200D 1F48B 200D 1F9D1 1F3FC +P:1F9D1 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FE +P:1F9D1 1F3FD 200D 2764 200D 1F48B 200D 1F9D1 1F3FE +P:1F9D1 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FF +P:1F9D1 1F3FD 200D 2764 200D 1F48B 200D 1F9D1 1F3FF +P:1F9D1 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FB +P:1F9D1 1F3FE 200D 2764 200D 1F48B 200D 1F9D1 1F3FB +P:1F9D1 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FC +P:1F9D1 1F3FE 200D 2764 200D 1F48B 200D 1F9D1 1F3FC +P:1F9D1 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FD +P:1F9D1 1F3FE 200D 2764 200D 1F48B 200D 1F9D1 1F3FD +P:1F9D1 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FF +P:1F9D1 1F3FE 200D 2764 200D 1F48B 200D 1F9D1 1F3FF +P:1F9D1 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FB +P:1F9D1 1F3FF 200D 2764 200D 1F48B 200D 1F9D1 1F3FB +P:1F9D1 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FC +P:1F9D1 1F3FF 200D 2764 200D 1F48B 200D 1F9D1 1F3FC +P:1F9D1 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FD +P:1F9D1 1F3FF 200D 2764 200D 1F48B 200D 1F9D1 1F3FD +P:1F9D1 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F9D1 1F3FE +P:1F9D1 1F3FF 200D 2764 200D 1F48B 200D 1F9D1 1F3FE +P:1F469 200D 2764 FE0F 200D 1F48B 200D 1F468 +P:1F469 200D 2764 200D 1F48B 200D 1F468 +P:1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB +P:1F469 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FB +P:1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC +P:1F469 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FC +P:1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD +P:1F469 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FD +P:1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE +P:1F469 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FE +P:1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF +P:1F469 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FF +P:1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB +P:1F469 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FB +P:1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC +P:1F469 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FC +P:1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD +P:1F469 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FD +P:1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE +P:1F469 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FE +P:1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF +P:1F469 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FF +P:1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB +P:1F469 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FB +P:1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC +P:1F469 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FC +P:1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD +P:1F469 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FD +P:1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE +P:1F469 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FE +P:1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF +P:1F469 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FF +P:1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB +P:1F469 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FB +P:1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC +P:1F469 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FC +P:1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD +P:1F469 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FD +P:1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE +P:1F469 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FE +P:1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF +P:1F469 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FF +P:1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB +P:1F469 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FB +P:1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC +P:1F469 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FC +P:1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD +P:1F469 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FD +P:1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE +P:1F469 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FE +P:1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF +P:1F469 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FF +P:1F468 200D 2764 FE0F 200D 1F48B 200D 1F468 +P:1F468 200D 2764 200D 1F48B 200D 1F468 +P:1F468 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB +P:1F468 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FB +P:1F468 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC +P:1F468 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FC +P:1F468 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD +P:1F468 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FD +P:1F468 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE +P:1F468 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FE +P:1F468 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF +P:1F468 1F3FB 200D 2764 200D 1F48B 200D 1F468 1F3FF +P:1F468 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB +P:1F468 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FB +P:1F468 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC +P:1F468 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FC +P:1F468 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD +P:1F468 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FD +P:1F468 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE +P:1F468 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FE +P:1F468 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF +P:1F468 1F3FC 200D 2764 200D 1F48B 200D 1F468 1F3FF +P:1F468 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB +P:1F468 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FB +P:1F468 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC +P:1F468 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FC +P:1F468 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD +P:1F468 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FD +P:1F468 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE +P:1F468 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FE +P:1F468 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF +P:1F468 1F3FD 200D 2764 200D 1F48B 200D 1F468 1F3FF +P:1F468 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB +P:1F468 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FB +P:1F468 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC +P:1F468 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FC +P:1F468 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD +P:1F468 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FD +P:1F468 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE +P:1F468 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FE +P:1F468 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF +P:1F468 1F3FE 200D 2764 200D 1F48B 200D 1F468 1F3FF +P:1F468 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FB +P:1F468 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FB +P:1F468 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FC +P:1F468 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FC +P:1F468 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FD +P:1F468 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FD +P:1F468 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FE +P:1F468 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FE +P:1F468 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F468 1F3FF +P:1F468 1F3FF 200D 2764 200D 1F48B 200D 1F468 1F3FF +P:1F469 200D 2764 FE0F 200D 1F48B 200D 1F469 +P:1F469 200D 2764 200D 1F48B 200D 1F469 +P:1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FB +P:1F469 1F3FB 200D 2764 200D 1F48B 200D 1F469 1F3FB +P:1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FC +P:1F469 1F3FB 200D 2764 200D 1F48B 200D 1F469 1F3FC +P:1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FD +P:1F469 1F3FB 200D 2764 200D 1F48B 200D 1F469 1F3FD +P:1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FE +P:1F469 1F3FB 200D 2764 200D 1F48B 200D 1F469 1F3FE +P:1F469 1F3FB 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FF +P:1F469 1F3FB 200D 2764 200D 1F48B 200D 1F469 1F3FF +P:1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FB +P:1F469 1F3FC 200D 2764 200D 1F48B 200D 1F469 1F3FB +P:1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FC +P:1F469 1F3FC 200D 2764 200D 1F48B 200D 1F469 1F3FC +P:1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FD +P:1F469 1F3FC 200D 2764 200D 1F48B 200D 1F469 1F3FD +P:1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FE +P:1F469 1F3FC 200D 2764 200D 1F48B 200D 1F469 1F3FE +P:1F469 1F3FC 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FF +P:1F469 1F3FC 200D 2764 200D 1F48B 200D 1F469 1F3FF +P:1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FB +P:1F469 1F3FD 200D 2764 200D 1F48B 200D 1F469 1F3FB +P:1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FC +P:1F469 1F3FD 200D 2764 200D 1F48B 200D 1F469 1F3FC +P:1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FD +P:1F469 1F3FD 200D 2764 200D 1F48B 200D 1F469 1F3FD +P:1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FE +P:1F469 1F3FD 200D 2764 200D 1F48B 200D 1F469 1F3FE +P:1F469 1F3FD 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FF +P:1F469 1F3FD 200D 2764 200D 1F48B 200D 1F469 1F3FF +P:1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FB +P:1F469 1F3FE 200D 2764 200D 1F48B 200D 1F469 1F3FB +P:1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FC +P:1F469 1F3FE 200D 2764 200D 1F48B 200D 1F469 1F3FC +P:1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FD +P:1F469 1F3FE 200D 2764 200D 1F48B 200D 1F469 1F3FD +P:1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FE +P:1F469 1F3FE 200D 2764 200D 1F48B 200D 1F469 1F3FE +P:1F469 1F3FE 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FF +P:1F469 1F3FE 200D 2764 200D 1F48B 200D 1F469 1F3FF +P:1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FB +P:1F469 1F3FF 200D 2764 200D 1F48B 200D 1F469 1F3FB +P:1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FC +P:1F469 1F3FF 200D 2764 200D 1F48B 200D 1F469 1F3FC +P:1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FD +P:1F469 1F3FF 200D 2764 200D 1F48B 200D 1F469 1F3FD +P:1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FE +P:1F469 1F3FF 200D 2764 200D 1F48B 200D 1F469 1F3FE +P:1F469 1F3FF 200D 2764 FE0F 200D 1F48B 200D 1F469 1F3FF +P:1F469 1F3FF 200D 2764 200D 1F48B 200D 1F469 1F3FF +P:1F491 +P:1F491 1F3FB +P:1F491 1F3FC +P:1F491 1F3FD +P:1F491 1F3FE +P:1F491 1F3FF +P:1F9D1 1F3FB 200D 2764 FE0F 200D 1F9D1 1F3FC +P:1F9D1 1F3FB 200D 2764 200D 1F9D1 1F3FC +P:1F9D1 1F3FB 200D 2764 FE0F 200D 1F9D1 1F3FD +P:1F9D1 1F3FB 200D 2764 200D 1F9D1 1F3FD +P:1F9D1 1F3FB 200D 2764 FE0F 200D 1F9D1 1F3FE +P:1F9D1 1F3FB 200D 2764 200D 1F9D1 1F3FE +P:1F9D1 1F3FB 200D 2764 FE0F 200D 1F9D1 1F3FF +P:1F9D1 1F3FB 200D 2764 200D 1F9D1 1F3FF +P:1F9D1 1F3FC 200D 2764 FE0F 200D 1F9D1 1F3FB +P:1F9D1 1F3FC 200D 2764 200D 1F9D1 1F3FB +P:1F9D1 1F3FC 200D 2764 FE0F 200D 1F9D1 1F3FD +P:1F9D1 1F3FC 200D 2764 200D 1F9D1 1F3FD +P:1F9D1 1F3FC 200D 2764 FE0F 200D 1F9D1 1F3FE +P:1F9D1 1F3FC 200D 2764 200D 1F9D1 1F3FE +P:1F9D1 1F3FC 200D 2764 FE0F 200D 1F9D1 1F3FF +P:1F9D1 1F3FC 200D 2764 200D 1F9D1 1F3FF +P:1F9D1 1F3FD 200D 2764 FE0F 200D 1F9D1 1F3FB +P:1F9D1 1F3FD 200D 2764 200D 1F9D1 1F3FB +P:1F9D1 1F3FD 200D 2764 FE0F 200D 1F9D1 1F3FC +P:1F9D1 1F3FD 200D 2764 200D 1F9D1 1F3FC +P:1F9D1 1F3FD 200D 2764 FE0F 200D 1F9D1 1F3FE +P:1F9D1 1F3FD 200D 2764 200D 1F9D1 1F3FE +P:1F9D1 1F3FD 200D 2764 FE0F 200D 1F9D1 1F3FF +P:1F9D1 1F3FD 200D 2764 200D 1F9D1 1F3FF +P:1F9D1 1F3FE 200D 2764 FE0F 200D 1F9D1 1F3FB +P:1F9D1 1F3FE 200D 2764 200D 1F9D1 1F3FB +P:1F9D1 1F3FE 200D 2764 FE0F 200D 1F9D1 1F3FC +P:1F9D1 1F3FE 200D 2764 200D 1F9D1 1F3FC +P:1F9D1 1F3FE 200D 2764 FE0F 200D 1F9D1 1F3FD +P:1F9D1 1F3FE 200D 2764 200D 1F9D1 1F3FD +P:1F9D1 1F3FE 200D 2764 FE0F 200D 1F9D1 1F3FF +P:1F9D1 1F3FE 200D 2764 200D 1F9D1 1F3FF +P:1F9D1 1F3FF 200D 2764 FE0F 200D 1F9D1 1F3FB +P:1F9D1 1F3FF 200D 2764 200D 1F9D1 1F3FB +P:1F9D1 1F3FF 200D 2764 FE0F 200D 1F9D1 1F3FC +P:1F9D1 1F3FF 200D 2764 200D 1F9D1 1F3FC +P:1F9D1 1F3FF 200D 2764 FE0F 200D 1F9D1 1F3FD +P:1F9D1 1F3FF 200D 2764 200D 1F9D1 1F3FD +P:1F9D1 1F3FF 200D 2764 FE0F 200D 1F9D1 1F3FE +P:1F9D1 1F3FF 200D 2764 200D 1F9D1 1F3FE +P:1F469 200D 2764 FE0F 200D 1F468 +P:1F469 200D 2764 200D 1F468 +P:1F469 1F3FB 200D 2764 FE0F 200D 1F468 1F3FB +P:1F469 1F3FB 200D 2764 200D 1F468 1F3FB +P:1F469 1F3FB 200D 2764 FE0F 200D 1F468 1F3FC +P:1F469 1F3FB 200D 2764 200D 1F468 1F3FC +P:1F469 1F3FB 200D 2764 FE0F 200D 1F468 1F3FD +P:1F469 1F3FB 200D 2764 200D 1F468 1F3FD +P:1F469 1F3FB 200D 2764 FE0F 200D 1F468 1F3FE +P:1F469 1F3FB 200D 2764 200D 1F468 1F3FE +P:1F469 1F3FB 200D 2764 FE0F 200D 1F468 1F3FF +P:1F469 1F3FB 200D 2764 200D 1F468 1F3FF +P:1F469 1F3FC 200D 2764 FE0F 200D 1F468 1F3FB +P:1F469 1F3FC 200D 2764 200D 1F468 1F3FB +P:1F469 1F3FC 200D 2764 FE0F 200D 1F468 1F3FC +P:1F469 1F3FC 200D 2764 200D 1F468 1F3FC +P:1F469 1F3FC 200D 2764 FE0F 200D 1F468 1F3FD +P:1F469 1F3FC 200D 2764 200D 1F468 1F3FD +P:1F469 1F3FC 200D 2764 FE0F 200D 1F468 1F3FE +P:1F469 1F3FC 200D 2764 200D 1F468 1F3FE +P:1F469 1F3FC 200D 2764 FE0F 200D 1F468 1F3FF +P:1F469 1F3FC 200D 2764 200D 1F468 1F3FF +P:1F469 1F3FD 200D 2764 FE0F 200D 1F468 1F3FB +P:1F469 1F3FD 200D 2764 200D 1F468 1F3FB +P:1F469 1F3FD 200D 2764 FE0F 200D 1F468 1F3FC +P:1F469 1F3FD 200D 2764 200D 1F468 1F3FC +P:1F469 1F3FD 200D 2764 FE0F 200D 1F468 1F3FD +P:1F469 1F3FD 200D 2764 200D 1F468 1F3FD +P:1F469 1F3FD 200D 2764 FE0F 200D 1F468 1F3FE +P:1F469 1F3FD 200D 2764 200D 1F468 1F3FE +P:1F469 1F3FD 200D 2764 FE0F 200D 1F468 1F3FF +P:1F469 1F3FD 200D 2764 200D 1F468 1F3FF +P:1F469 1F3FE 200D 2764 FE0F 200D 1F468 1F3FB +P:1F469 1F3FE 200D 2764 200D 1F468 1F3FB +P:1F469 1F3FE 200D 2764 FE0F 200D 1F468 1F3FC +P:1F469 1F3FE 200D 2764 200D 1F468 1F3FC +P:1F469 1F3FE 200D 2764 FE0F 200D 1F468 1F3FD +P:1F469 1F3FE 200D 2764 200D 1F468 1F3FD +P:1F469 1F3FE 200D 2764 FE0F 200D 1F468 1F3FE +P:1F469 1F3FE 200D 2764 200D 1F468 1F3FE +P:1F469 1F3FE 200D 2764 FE0F 200D 1F468 1F3FF +P:1F469 1F3FE 200D 2764 200D 1F468 1F3FF +P:1F469 1F3FF 200D 2764 FE0F 200D 1F468 1F3FB +P:1F469 1F3FF 200D 2764 200D 1F468 1F3FB +P:1F469 1F3FF 200D 2764 FE0F 200D 1F468 1F3FC +P:1F469 1F3FF 200D 2764 200D 1F468 1F3FC +P:1F469 1F3FF 200D 2764 FE0F 200D 1F468 1F3FD +P:1F469 1F3FF 200D 2764 200D 1F468 1F3FD +P:1F469 1F3FF 200D 2764 FE0F 200D 1F468 1F3FE +P:1F469 1F3FF 200D 2764 200D 1F468 1F3FE +P:1F469 1F3FF 200D 2764 FE0F 200D 1F468 1F3FF +P:1F469 1F3FF 200D 2764 200D 1F468 1F3FF +P:1F468 200D 2764 FE0F 200D 1F468 +P:1F468 200D 2764 200D 1F468 +P:1F468 1F3FB 200D 2764 FE0F 200D 1F468 1F3FB +P:1F468 1F3FB 200D 2764 200D 1F468 1F3FB +P:1F468 1F3FB 200D 2764 FE0F 200D 1F468 1F3FC +P:1F468 1F3FB 200D 2764 200D 1F468 1F3FC +P:1F468 1F3FB 200D 2764 FE0F 200D 1F468 1F3FD +P:1F468 1F3FB 200D 2764 200D 1F468 1F3FD +P:1F468 1F3FB 200D 2764 FE0F 200D 1F468 1F3FE +P:1F468 1F3FB 200D 2764 200D 1F468 1F3FE +P:1F468 1F3FB 200D 2764 FE0F 200D 1F468 1F3FF +P:1F468 1F3FB 200D 2764 200D 1F468 1F3FF +P:1F468 1F3FC 200D 2764 FE0F 200D 1F468 1F3FB +P:1F468 1F3FC 200D 2764 200D 1F468 1F3FB +P:1F468 1F3FC 200D 2764 FE0F 200D 1F468 1F3FC +P:1F468 1F3FC 200D 2764 200D 1F468 1F3FC +P:1F468 1F3FC 200D 2764 FE0F 200D 1F468 1F3FD +P:1F468 1F3FC 200D 2764 200D 1F468 1F3FD +P:1F468 1F3FC 200D 2764 FE0F 200D 1F468 1F3FE +P:1F468 1F3FC 200D 2764 200D 1F468 1F3FE +P:1F468 1F3FC 200D 2764 FE0F 200D 1F468 1F3FF +P:1F468 1F3FC 200D 2764 200D 1F468 1F3FF +P:1F468 1F3FD 200D 2764 FE0F 200D 1F468 1F3FB +P:1F468 1F3FD 200D 2764 200D 1F468 1F3FB +P:1F468 1F3FD 200D 2764 FE0F 200D 1F468 1F3FC +P:1F468 1F3FD 200D 2764 200D 1F468 1F3FC +P:1F468 1F3FD 200D 2764 FE0F 200D 1F468 1F3FD +P:1F468 1F3FD 200D 2764 200D 1F468 1F3FD +P:1F468 1F3FD 200D 2764 FE0F 200D 1F468 1F3FE +P:1F468 1F3FD 200D 2764 200D 1F468 1F3FE +P:1F468 1F3FD 200D 2764 FE0F 200D 1F468 1F3FF +P:1F468 1F3FD 200D 2764 200D 1F468 1F3FF +P:1F468 1F3FE 200D 2764 FE0F 200D 1F468 1F3FB +P:1F468 1F3FE 200D 2764 200D 1F468 1F3FB +P:1F468 1F3FE 200D 2764 FE0F 200D 1F468 1F3FC +P:1F468 1F3FE 200D 2764 200D 1F468 1F3FC +P:1F468 1F3FE 200D 2764 FE0F 200D 1F468 1F3FD +P:1F468 1F3FE 200D 2764 200D 1F468 1F3FD +P:1F468 1F3FE 200D 2764 FE0F 200D 1F468 1F3FE +P:1F468 1F3FE 200D 2764 200D 1F468 1F3FE +P:1F468 1F3FE 200D 2764 FE0F 200D 1F468 1F3FF +P:1F468 1F3FE 200D 2764 200D 1F468 1F3FF +P:1F468 1F3FF 200D 2764 FE0F 200D 1F468 1F3FB +P:1F468 1F3FF 200D 2764 200D 1F468 1F3FB +P:1F468 1F3FF 200D 2764 FE0F 200D 1F468 1F3FC +P:1F468 1F3FF 200D 2764 200D 1F468 1F3FC +P:1F468 1F3FF 200D 2764 FE0F 200D 1F468 1F3FD +P:1F468 1F3FF 200D 2764 200D 1F468 1F3FD +P:1F468 1F3FF 200D 2764 FE0F 200D 1F468 1F3FE +P:1F468 1F3FF 200D 2764 200D 1F468 1F3FE +P:1F468 1F3FF 200D 2764 FE0F 200D 1F468 1F3FF +P:1F468 1F3FF 200D 2764 200D 1F468 1F3FF +P:1F469 200D 2764 FE0F 200D 1F469 +P:1F469 200D 2764 200D 1F469 +P:1F469 1F3FB 200D 2764 FE0F 200D 1F469 1F3FB +P:1F469 1F3FB 200D 2764 200D 1F469 1F3FB +P:1F469 1F3FB 200D 2764 FE0F 200D 1F469 1F3FC +P:1F469 1F3FB 200D 2764 200D 1F469 1F3FC +P:1F469 1F3FB 200D 2764 FE0F 200D 1F469 1F3FD +P:1F469 1F3FB 200D 2764 200D 1F469 1F3FD +P:1F469 1F3FB 200D 2764 FE0F 200D 1F469 1F3FE +P:1F469 1F3FB 200D 2764 200D 1F469 1F3FE +P:1F469 1F3FB 200D 2764 FE0F 200D 1F469 1F3FF +P:1F469 1F3FB 200D 2764 200D 1F469 1F3FF +P:1F469 1F3FC 200D 2764 FE0F 200D 1F469 1F3FB +P:1F469 1F3FC 200D 2764 200D 1F469 1F3FB +P:1F469 1F3FC 200D 2764 FE0F 200D 1F469 1F3FC +P:1F469 1F3FC 200D 2764 200D 1F469 1F3FC +P:1F469 1F3FC 200D 2764 FE0F 200D 1F469 1F3FD +P:1F469 1F3FC 200D 2764 200D 1F469 1F3FD +P:1F469 1F3FC 200D 2764 FE0F 200D 1F469 1F3FE +P:1F469 1F3FC 200D 2764 200D 1F469 1F3FE +P:1F469 1F3FC 200D 2764 FE0F 200D 1F469 1F3FF +P:1F469 1F3FC 200D 2764 200D 1F469 1F3FF +P:1F469 1F3FD 200D 2764 FE0F 200D 1F469 1F3FB +P:1F469 1F3FD 200D 2764 200D 1F469 1F3FB +P:1F469 1F3FD 200D 2764 FE0F 200D 1F469 1F3FC +P:1F469 1F3FD 200D 2764 200D 1F469 1F3FC +P:1F469 1F3FD 200D 2764 FE0F 200D 1F469 1F3FD +P:1F469 1F3FD 200D 2764 200D 1F469 1F3FD +P:1F469 1F3FD 200D 2764 FE0F 200D 1F469 1F3FE +P:1F469 1F3FD 200D 2764 200D 1F469 1F3FE +P:1F469 1F3FD 200D 2764 FE0F 200D 1F469 1F3FF +P:1F469 1F3FD 200D 2764 200D 1F469 1F3FF +P:1F469 1F3FE 200D 2764 FE0F 200D 1F469 1F3FB +P:1F469 1F3FE 200D 2764 200D 1F469 1F3FB +P:1F469 1F3FE 200D 2764 FE0F 200D 1F469 1F3FC +P:1F469 1F3FE 200D 2764 200D 1F469 1F3FC +P:1F469 1F3FE 200D 2764 FE0F 200D 1F469 1F3FD +P:1F469 1F3FE 200D 2764 200D 1F469 1F3FD +P:1F469 1F3FE 200D 2764 FE0F 200D 1F469 1F3FE +P:1F469 1F3FE 200D 2764 200D 1F469 1F3FE +P:1F469 1F3FE 200D 2764 FE0F 200D 1F469 1F3FF +P:1F469 1F3FE 200D 2764 200D 1F469 1F3FF +P:1F469 1F3FF 200D 2764 FE0F 200D 1F469 1F3FB +P:1F469 1F3FF 200D 2764 200D 1F469 1F3FB +P:1F469 1F3FF 200D 2764 FE0F 200D 1F469 1F3FC +P:1F469 1F3FF 200D 2764 200D 1F469 1F3FC +P:1F469 1F3FF 200D 2764 FE0F 200D 1F469 1F3FD +P:1F469 1F3FF 200D 2764 200D 1F469 1F3FD +P:1F469 1F3FF 200D 2764 FE0F 200D 1F469 1F3FE +P:1F469 1F3FF 200D 2764 200D 1F469 1F3FE +P:1F469 1F3FF 200D 2764 FE0F 200D 1F469 1F3FF +P:1F469 1F3FF 200D 2764 200D 1F469 1F3FF +P:1F468 200D 1F469 200D 1F466 +P:1F468 200D 1F469 200D 1F467 +P:1F468 200D 1F469 200D 1F467 200D 1F466 +P:1F468 200D 1F469 200D 1F466 200D 1F466 +P:1F468 200D 1F469 200D 1F467 200D 1F467 +P:1F468 200D 1F468 200D 1F466 +P:1F468 200D 1F468 200D 1F467 +P:1F468 200D 1F468 200D 1F467 200D 1F466 +P:1F468 200D 1F468 200D 1F466 200D 1F466 +P:1F468 200D 1F468 200D 1F467 200D 1F467 +P:1F469 200D 1F469 200D 1F466 +P:1F469 200D 1F469 200D 1F467 +P:1F469 200D 1F469 200D 1F467 200D 1F466 +P:1F469 200D 1F469 200D 1F466 200D 1F466 +P:1F469 200D 1F469 200D 1F467 200D 1F467 +P:1F468 200D 1F466 +P:1F468 200D 1F466 200D 1F466 +P:1F468 200D 1F467 +P:1F468 200D 1F467 200D 1F466 +P:1F468 200D 1F467 200D 1F467 +P:1F469 200D 1F466 +P:1F469 200D 1F466 200D 1F466 +P:1F469 200D 1F467 +P:1F469 200D 1F467 200D 1F466 +P:1F469 200D 1F467 200D 1F467 +P:1F5E3 FE0F +P:1F5E3 +P:1F464 +P:1F465 +P:1FAC2 +P:1F46A +P:1F9D1 200D 1F9D1 200D 1F9D2 +P:1F9D1 200D 1F9D1 200D 1F9D2 200D 1F9D2 +P:1F9D1 200D 1F9D2 +P:1F9D1 200D 1F9D2 200D 1F9D2 +P:1F463 +P:1FAC6 +N:1F435 +N:1F412 +N:1F98D +N:1F9A7 +N:1F436 +N:1F415 +N:1F9AE +N:1F415 200D 1F9BA +N:1F429 +N:1F43A +N:1F98A +N:1F99D +N:1F431 +N:1F408 +N:1F408 200D 2B1B +N:1F981 +N:1F42F +N:1F405 +N:1F406 +N:1F434 +N:1FACE +N:1FACF +N:1F40E +N:1F984 +N:1F993 +N:1F98C +N:1F9AC +N:1F42E +N:1F402 +N:1F403 +N:1F404 +N:1F437 +N:1F416 +N:1F417 +N:1F43D +N:1F40F +N:1F411 +N:1F410 +N:1F42A +N:1F42B +N:1F999 +N:1F992 +N:1F418 +N:1F9A3 +N:1F98F +N:1F99B +N:1F42D +N:1F401 +N:1F400 +N:1F439 +N:1F430 +N:1F407 +N:1F43F FE0F +N:1F43F +N:1F9AB +N:1F994 +N:1F987 +N:1F43B +N:1F43B 200D 2744 FE0F +N:1F43B 200D 2744 +N:1F428 +N:1F43C +N:1F9A5 +N:1F9A6 +N:1F9A8 +N:1F998 +N:1F9A1 +N:1F43E +N:1F983 +N:1F414 +N:1F413 +N:1F423 +N:1F424 +N:1F425 +N:1F426 +N:1F427 +N:1F54A FE0F +N:1F54A +N:1F985 +N:1F986 +N:1F9A2 +N:1F989 +N:1F9A4 +N:1FAB6 +N:1F9A9 +N:1F99A +N:1F99C +N:1FABD +N:1F426 200D 2B1B +N:1FABF +N:1F426 200D 1F525 +N:1F438 +N:1F40A +N:1F422 +N:1F98E +N:1F40D +N:1F432 +N:1F409 +N:1F995 +N:1F996 +N:1F433 +N:1F40B +N:1F42C +N:1FACD +N:1F9AD +N:1F41F +N:1F420 +N:1F421 +N:1F988 +N:1F419 +N:1F41A +N:1FAB8 +N:1FABC +N:1F980 +N:1F99E +N:1F990 +N:1F991 +N:1F9AA +N:1F40C +N:1F98B +N:1F41B +N:1F41C +N:1F41D +N:1FAB2 +N:1F41E +N:1F997 +N:1FAB3 +N:1F577 FE0F +N:1F577 +N:1F578 FE0F +N:1F578 +N:1F982 +N:1F99F +N:1FAB0 +N:1FAB1 +N:1F9A0 +N:1F490 +N:1F338 +N:1F4AE +N:1FAB7 +N:1F3F5 FE0F +N:1F3F5 +N:1F339 +N:1F940 +N:1F33A +N:1F33B +N:1F33C +N:1F337 +N:1FABB +N:1F331 +N:1FAB4 +N:1F332 +N:1F333 + """.trimIndent(), + """ +N:1F334 +N:1F335 +N:1F33E +N:1F33F +N:2618 FE0F +N:2618 +N:1F340 +N:1F341 +N:1F342 +N:1F343 +N:1FAB9 +N:1FABA +N:1F344 +N:1FABE +F:1F347 +F:1F348 +F:1F349 +F:1F34A +F:1F34B +F:1F34B 200D 1F7E9 +F:1F34C +F:1F34D +F:1F96D +F:1F34E +F:1F34F +F:1F350 +F:1F351 +F:1F352 +F:1F353 +F:1FAD0 +F:1F95D +F:1F345 +F:1FAD2 +F:1F965 +F:1F951 +F:1F346 +F:1F954 +F:1F955 +F:1F33D +F:1F336 FE0F +F:1F336 +F:1FAD1 +F:1F952 +F:1F96C +F:1F966 +F:1F9C4 +F:1F9C5 +F:1F95C +F:1FAD8 +F:1F330 +F:1FADA +F:1FADB +F:1F344 200D 1F7EB +F:1FADC +F:1F35E +F:1F950 +F:1F956 +F:1FAD3 +F:1F968 +F:1F96F +F:1F95E +F:1F9C7 +F:1F9C0 +F:1F356 +F:1F357 +F:1F969 +F:1F953 +F:1F354 +F:1F35F +F:1F355 +F:1F32D +F:1F96A +F:1F32E +F:1F32F +F:1FAD4 +F:1F959 +F:1F9C6 +F:1F95A +F:1F373 +F:1F958 +F:1F372 +F:1FAD5 +F:1F963 +F:1F957 +F:1F37F +F:1F9C8 +F:1F9C2 +F:1F96B +F:1F371 +F:1F358 +F:1F359 +F:1F35A +F:1F35B +F:1F35C +F:1F35D +F:1F360 +F:1F362 +F:1F363 +F:1F364 +F:1F365 +F:1F96E +F:1F361 +F:1F95F +F:1F960 +F:1F961 +F:1F366 +F:1F367 +F:1F368 +F:1F369 +F:1F36A +F:1F382 +F:1F370 +F:1F9C1 +F:1F967 +F:1F36B +F:1F36C +F:1F36D +F:1F36E +F:1F36F +F:1F37C +F:1F95B +F:2615 +F:1FAD6 +F:1F375 +F:1F376 +F:1F37E +F:1F377 +F:1F378 +F:1F379 +F:1F37A +F:1F37B +F:1F942 +F:1F943 +F:1FAD7 +F:1F964 +F:1F9CB +F:1F9C3 +F:1F9C9 +F:1F9CA +F:1F962 +F:1F37D FE0F +F:1F37D +F:1F374 +F:1F944 +F:1F52A +F:1FAD9 +F:1F3FA +T:1F30D +T:1F30E +T:1F30F +T:1F310 +T:1F5FA FE0F +T:1F5FA +T:1F5FE +T:1F9ED +T:1F3D4 FE0F +T:1F3D4 +T:26F0 FE0F +T:26F0 +T:1F6D8 +T:1F30B +T:1F5FB +T:1F3D5 FE0F +T:1F3D5 +T:1F3D6 FE0F +T:1F3D6 +T:1F3DC FE0F +T:1F3DC +T:1F3DD FE0F +T:1F3DD +T:1F3DE FE0F +T:1F3DE +T:1F3DF FE0F +T:1F3DF +T:1F3DB FE0F +T:1F3DB +T:1F3D7 FE0F +T:1F3D7 +T:1F9F1 +T:1FAA8 +T:1FAB5 +T:1F6D6 +T:1F3D8 FE0F +T:1F3D8 +T:1F3DA FE0F +T:1F3DA +T:1F3E0 +T:1F3E1 +T:1F3E2 +T:1F3E3 +T:1F3E4 +T:1F3E5 +T:1F3E6 +T:1F3E8 +T:1F3E9 +T:1F3EA +T:1F3EB +T:1F3EC +T:1F3ED +T:1F3EF +T:1F3F0 +T:1F492 +T:1F5FC +T:1F5FD +T:26EA +T:1F54C +T:1F6D5 +T:1F54D +T:26E9 FE0F +T:26E9 +T:1F54B +T:26F2 +T:26FA +T:1F301 +T:1F303 +T:1F3D9 FE0F +T:1F3D9 +T:1F304 +T:1F305 +T:1F306 +T:1F307 +T:1F309 +T:2668 FE0F +T:2668 +T:1F3A0 +T:1F6DD +T:1F3A1 +T:1F3A2 +T:1F488 +T:1F3AA +T:1F682 +T:1F683 +T:1F684 +T:1F685 +T:1F686 +T:1F687 +T:1F688 +T:1F689 +T:1F68A +T:1F69D +T:1F69E +T:1F68B +T:1F68C +T:1F68D +T:1F68E +T:1F690 +T:1F691 +T:1F692 +T:1F693 +T:1F694 +T:1F695 +T:1F696 +T:1F697 +T:1F698 +T:1F699 +T:1F6FB +T:1F69A +T:1F69B +T:1F69C +T:1F3CE FE0F +T:1F3CE +T:1F3CD FE0F +T:1F3CD +T:1F6F5 +T:1F9BD +T:1F9BC +T:1F6FA +T:1F6B2 +T:1F6F4 +T:1F6F9 +T:1F6FC +T:1F68F +T:1F6E3 FE0F +T:1F6E3 +T:1F6E4 FE0F +T:1F6E4 +T:1F6E2 FE0F +T:1F6E2 +T:26FD +T:1F6DE +T:1F6A8 +T:1F6A5 +T:1F6A6 +T:1F6D1 +T:1F6A7 +T:2693 +T:1F6DF +T:26F5 +T:1F6F6 +T:1F6A4 +T:1F6F3 FE0F +T:1F6F3 +T:26F4 FE0F +T:26F4 +T:1F6E5 FE0F +T:1F6E5 +T:1F6A2 +T:2708 FE0F +T:2708 +T:1F6E9 FE0F +T:1F6E9 +T:1F6EB +T:1F6EC +T:1FA82 +T:1F4BA +T:1F681 +T:1F69F +T:1F6A0 +T:1F6A1 +T:1F6F0 FE0F +T:1F6F0 +T:1F680 +T:1F6F8 +T:1F6CE FE0F +T:1F6CE +T:1F9F3 +T:231B +T:23F3 +T:231A +T:23F0 +T:23F1 FE0F +T:23F1 +T:23F2 FE0F +T:23F2 +T:1F570 FE0F +T:1F570 +T:1F55B +T:1F567 +T:1F550 +T:1F55C +T:1F551 +T:1F55D +T:1F552 +T:1F55E +T:1F553 +T:1F55F +T:1F554 +T:1F560 +T:1F555 +T:1F561 +T:1F556 +T:1F562 +T:1F557 +T:1F563 +T:1F558 +T:1F564 +T:1F559 +T:1F565 +T:1F55A +T:1F566 +T:1F311 +T:1F312 +T:1F313 +T:1F314 +T:1F315 +T:1F316 +T:1F317 +T:1F318 +T:1F319 +T:1F31A +T:1F31B +T:1F31C +T:1F321 FE0F +T:1F321 +T:2600 FE0F +T:2600 +T:1F31D +T:1F31E +T:1FA90 +T:2B50 +T:1F31F +T:1F320 +T:1F30C +T:2601 FE0F +T:2601 +T:26C5 +T:26C8 FE0F +T:26C8 +T:1F324 FE0F +T:1F324 +T:1F325 FE0F +T:1F325 +T:1F326 FE0F +T:1F326 +T:1F327 FE0F +T:1F327 +T:1F328 FE0F +T:1F328 +T:1F329 FE0F +T:1F329 +T:1F32A FE0F +T:1F32A +T:1F32B FE0F +T:1F32B +T:1F32C FE0F +T:1F32C +T:1F300 +T:1F308 +T:1F302 +T:2602 FE0F +T:2602 +T:2614 +T:26F1 FE0F +T:26F1 +T:26A1 +T:2744 FE0F +T:2744 +T:2603 FE0F +T:2603 +T:26C4 +T:2604 FE0F +T:2604 +T:1F525 +T:1F4A7 +T:1F30A +A:1F383 +A:1F384 +A:1F386 +A:1F387 +A:1F9E8 +A:2728 +A:1F388 +A:1F389 +A:1F38A +A:1F38B +A:1F38D +A:1F38E +A:1F38F +A:1F390 +A:1F391 +A:1F9E7 +A:1F380 +A:1F381 +A:1F397 FE0F +A:1F397 +A:1F39F FE0F +A:1F39F +A:1F3AB +A:1F396 FE0F +A:1F396 +A:1F3C6 +A:1F3C5 +A:1F947 +A:1F948 +A:1F949 +A:26BD +A:26BE +A:1F94E +A:1F3C0 +A:1F3D0 +A:1F3C8 +A:1F3C9 +A:1F3BE +A:1F94F +A:1F3B3 +A:1F3CF +A:1F3D1 +A:1F3D2 +A:1F94D +A:1F3D3 +A:1F3F8 +A:1F94A +A:1F94B +A:1F945 +A:26F3 +A:26F8 FE0F +A:26F8 +A:1F3A3 +A:1F93F +A:1F3BD +A:1F3BF +A:1F6F7 +A:1F94C +A:1F3AF +A:1FA80 +A:1FA81 +A:1F52B +A:1F3B1 +A:1F52E +A:1FA84 +A:1F3AE +A:1F579 FE0F +A:1F579 +A:1F3B0 +A:1F3B2 +A:1F9E9 +A:1F9F8 +A:1FA85 +A:1FAA9 +A:1FA86 +A:2660 FE0F +A:2660 +A:2665 FE0F +A:2665 +A:2666 FE0F +A:2666 +A:2663 FE0F +A:2663 +A:265F FE0F +A:265F +A:1F0CF +A:1F004 +A:1F3B4 +A:1F3AD +A:1F5BC FE0F +A:1F5BC +A:1F3A8 +A:1F9F5 +A:1FAA1 +A:1F9F6 +A:1FAA2 +O:1F453 +O:1F576 FE0F +O:1F576 +O:1F97D +O:1F97C +O:1F9BA +O:1F454 +O:1F455 +O:1F456 +O:1F9E3 +O:1F9E4 +O:1F9E5 +O:1F9E6 +O:1F457 +O:1F458 +O:1F97B +O:1FA71 +O:1FA72 +O:1FA73 +O:1F459 +O:1F45A +O:1FAAD +O:1F45B +O:1F45C +O:1F45D +O:1F6CD FE0F +O:1F6CD +O:1F392 +O:1FA74 +O:1F45E +O:1F45F +O:1F97E +O:1F97F +O:1F460 +O:1F461 +O:1FA70 +O:1F462 +O:1FAAE +O:1F451 +O:1F452 +O:1F3A9 +O:1F393 +O:1F9E2 +O:1FA96 +O:26D1 FE0F +O:26D1 +O:1F4FF +O:1F484 +O:1F48D +O:1F48E +O:1F507 +O:1F508 +O:1F509 +O:1F50A +O:1F4E2 +O:1F4E3 +O:1F4EF +O:1F514 +O:1F515 +O:1F3BC +O:1F3B5 +O:1F3B6 +O:1F399 FE0F +O:1F399 +O:1F39A FE0F +O:1F39A +O:1F39B FE0F +O:1F39B +O:1F3A4 +O:1F3A7 +O:1F4FB +O:1F3B7 +O:1F3BA +O:1FA8A +O:1FA97 +O:1F3B8 +O:1F3B9 +O:1F3BB +O:1FA95 +O:1F941 +O:1FA98 +O:1FA87 +O:1FA88 +O:1FA89 +O:1F4F1 +O:1F4F2 +O:260E FE0F +O:260E +O:1F4DE +O:1F4DF +O:1F4E0 +O:1F50B +O:1FAAB +O:1F50C +O:1F4BB +O:1F5A5 FE0F +O:1F5A5 +O:1F5A8 FE0F +O:1F5A8 +O:2328 FE0F +O:2328 +O:1F5B1 FE0F +O:1F5B1 +O:1F5B2 FE0F +O:1F5B2 +O:1F4BD +O:1F4BE +O:1F4BF +O:1F4C0 +O:1F9EE +O:1F3A5 +O:1F39E FE0F +O:1F39E +O:1F4FD FE0F +O:1F4FD +O:1F3AC +O:1F4FA +O:1F4F7 +O:1F4F8 +O:1F4F9 +O:1F4FC +O:1F50D +O:1F50E +O:1F56F FE0F +O:1F56F +O:1F4A1 +O:1F526 +O:1F3EE +O:1FA94 +O:1F4D4 +O:1F4D5 +O:1F4D6 +O:1F4D7 +O:1F4D8 +O:1F4D9 +O:1F4DA +O:1F4D3 +O:1F4D2 +O:1F4C3 +O:1F4DC +O:1F4C4 +O:1F4F0 +O:1F5DE FE0F +O:1F5DE +O:1F4D1 +O:1F516 +O:1F3F7 FE0F +O:1F3F7 +O:1FA99 +O:1F4B0 +O:1FA8E +O:1F4B4 +O:1F4B5 +O:1F4B6 +O:1F4B7 +O:1F4B8 +O:1F4B3 +O:1F9FE +O:1F4B9 +O:2709 FE0F +O:2709 +O:1F4E7 +O:1F4E8 +O:1F4E9 +O:1F4E4 +O:1F4E5 +O:1F4E6 +O:1F4EB +O:1F4EA +O:1F4EC +O:1F4ED +O:1F4EE +O:1F5F3 FE0F +O:1F5F3 +O:270F FE0F +O:270F +O:2712 FE0F +O:2712 +O:1F58B FE0F +O:1F58B +O:1F58A FE0F +O:1F58A +O:1F58C FE0F +O:1F58C +O:1F58D FE0F +O:1F58D +O:1F4DD +O:1F4BC +O:1F4C1 +O:1F4C2 +O:1F5C2 FE0F +O:1F5C2 +O:1F4C5 +O:1F4C6 +O:1F5D2 FE0F +O:1F5D2 +O:1F5D3 FE0F +O:1F5D3 +O:1F4C7 +O:1F4C8 +O:1F4C9 +O:1F4CA +O:1F4CB +O:1F4CC +O:1F4CD +O:1F4CE +O:1F587 FE0F +O:1F587 +O:1F4CF +O:1F4D0 +O:2702 FE0F +O:2702 +O:1F5C3 FE0F +O:1F5C3 +O:1F5C4 FE0F +O:1F5C4 +O:1F5D1 FE0F +O:1F5D1 +O:1F512 +O:1F513 +O:1F50F +O:1F510 +O:1F511 +O:1F5DD FE0F +O:1F5DD +O:1F528 +O:1FA93 +O:26CF FE0F +O:26CF +O:2692 FE0F +O:2692 +O:1F6E0 FE0F +O:1F6E0 +O:1F5E1 FE0F +O:1F5E1 +O:2694 FE0F +O:2694 +O:1F4A3 +O:1FA83 +O:1F3F9 +O:1F6E1 FE0F +O:1F6E1 +O:1FA9A +O:1F527 +O:1FA9B +O:1F529 +O:2699 FE0F +O:2699 +O:1F5DC FE0F +O:1F5DC +O:2696 FE0F +O:2696 +O:1F9AF +O:1F517 +O:26D3 FE0F 200D 1F4A5 +O:26D3 200D 1F4A5 +O:26D3 FE0F +O:26D3 +O:1FA9D +O:1F9F0 +O:1F9F2 +O:1FA9C +O:1FA8F +O:2697 FE0F +O:2697 +O:1F9EA +O:1F9EB +O:1F9EC +O:1F52C +O:1F52D +O:1F4E1 +O:1F489 +O:1FA78 +O:1F48A +O:1FA79 +O:1FA7C +O:1FA7A +O:1FA7B +O:1F6AA +O:1F6D7 +O:1FA9E +O:1FA9F +O:1F6CF FE0F +O:1F6CF +O:1F6CB FE0F +O:1F6CB +O:1FA91 +O:1F6BD +O:1FAA0 +O:1F6BF +O:1F6C1 +O:1FAA4 +O:1FA92 +O:1F9F4 +O:1F9F7 +O:1F9F9 +O:1F9FA +O:1F9FB +O:1FAA3 +O:1F9FC +O:1FAE7 +O:1FAA5 +O:1F9FD +O:1F9EF +O:1F6D2 +O:1F6AC +O:26B0 FE0F +O:26B0 +O:1FAA6 +O:26B1 FE0F +O:26B1 +O:1F9FF +O:1FAAC +O:1F5FF +O:1FAA7 +O:1FAAA +Y:1F3E7 +Y:1F6AE +Y:1F6B0 +Y:267F +Y:1F6B9 +Y:1F6BA +Y:1F6BB +Y:1F6BC +Y:1F6BE +Y:1F6C2 +Y:1F6C3 +Y:1F6C4 +Y:1F6C5 +Y:26A0 FE0F +Y:26A0 +Y:1F6B8 +Y:26D4 +Y:1F6AB +Y:1F6B3 +Y:1F6AD +Y:1F6AF +Y:1F6B1 +Y:1F6B7 +Y:1F4F5 +Y:1F51E +Y:2622 FE0F +Y:2622 +Y:2623 FE0F +Y:2623 +Y:2B06 FE0F +Y:2B06 +Y:2197 FE0F +Y:2197 +Y:27A1 FE0F +Y:27A1 +Y:2198 FE0F +Y:2198 +Y:2B07 FE0F +Y:2B07 +Y:2199 FE0F +Y:2199 +Y:2B05 FE0F +Y:2B05 +Y:2196 FE0F +Y:2196 +Y:2195 FE0F +Y:2195 +Y:2194 FE0F +Y:2194 +Y:21A9 FE0F +Y:21A9 +Y:21AA FE0F +Y:21AA +Y:2934 FE0F +Y:2934 +Y:2935 FE0F +Y:2935 +Y:1F503 +Y:1F504 +Y:1F519 +Y:1F51A +Y:1F51B +Y:1F51C +Y:1F51D +Y:1F6D0 +Y:269B FE0F +Y:269B +Y:1F549 FE0F +Y:1F549 +Y:2721 FE0F +Y:2721 +Y:2638 FE0F +Y:2638 +Y:262F FE0F +Y:262F +Y:271D FE0F +Y:271D +Y:2626 FE0F +Y:2626 +Y:262A FE0F +Y:262A +Y:262E FE0F +Y:262E +Y:1F54E +Y:1F52F +Y:1FAAF +Y:2648 +Y:2649 +Y:264A +Y:264B +Y:264C +Y:264D +Y:264E +Y:264F +Y:2650 +Y:2651 +Y:2652 +Y:2653 +Y:26CE +Y:1F500 +Y:1F501 +Y:1F502 +Y:25B6 FE0F +Y:25B6 +Y:23E9 +Y:23ED FE0F +Y:23ED +Y:23EF FE0F +Y:23EF +Y:25C0 FE0F +Y:25C0 +Y:23EA +Y:23EE FE0F +Y:23EE +Y:1F53C +Y:23EB +Y:1F53D +Y:23EC +Y:23F8 FE0F +Y:23F8 +Y:23F9 FE0F +Y:23F9 +Y:23FA FE0F +Y:23FA +Y:23CF FE0F +Y:23CF +Y:1F3A6 +Y:1F505 +Y:1F506 +Y:1F4F6 +Y:1F6DC +Y:1F4F3 +Y:1F4F4 +Y:2640 FE0F +Y:2640 +Y:2642 FE0F +Y:2642 +Y:26A7 FE0F +Y:26A7 +Y:2716 FE0F +Y:2716 +Y:2795 +Y:2796 +Y:2797 +Y:1F7F0 +Y:267E FE0F +Y:267E +Y:203C FE0F +Y:203C +Y:2049 FE0F +Y:2049 +Y:2753 +Y:2754 +Y:2755 +Y:2757 +Y:3030 FE0F +Y:3030 +Y:1F4B1 +Y:1F4B2 +Y:2695 FE0F +Y:2695 +Y:267B FE0F +Y:267B +Y:269C FE0F +Y:269C +Y:1F531 +Y:1F4DB +Y:1F530 +Y:2B55 +Y:2705 +Y:2611 FE0F +Y:2611 +Y:2714 FE0F +Y:2714 +Y:274C +Y:274E +Y:27B0 +Y:27BF +Y:303D FE0F +Y:303D +Y:2733 FE0F +Y:2733 +Y:2734 FE0F +Y:2734 +Y:2747 FE0F +Y:2747 +Y:00A9 FE0F +Y:00A9 +Y:00AE FE0F +Y:00AE +Y:2122 FE0F +Y:2122 +Y:1FADF +Y:0023 FE0F 20E3 +Y:0023 20E3 +Y:002A FE0F 20E3 +Y:002A 20E3 +Y:0030 FE0F 20E3 +Y:0030 20E3 +Y:0031 FE0F 20E3 +Y:0031 20E3 +Y:0032 FE0F 20E3 +Y:0032 20E3 +Y:0033 FE0F 20E3 +Y:0033 20E3 +Y:0034 FE0F 20E3 +Y:0034 20E3 +Y:0035 FE0F 20E3 +Y:0035 20E3 +Y:0036 FE0F 20E3 +Y:0036 20E3 +Y:0037 FE0F 20E3 +Y:0037 20E3 +Y:0038 FE0F 20E3 +Y:0038 20E3 +Y:0039 FE0F 20E3 +Y:0039 20E3 +Y:1F51F +Y:1F520 +Y:1F521 +Y:1F522 +Y:1F523 +Y:1F524 +Y:1F170 FE0F +Y:1F170 +Y:1F18E +Y:1F171 FE0F +Y:1F171 +Y:1F191 +Y:1F192 +Y:1F193 +Y:2139 FE0F +Y:2139 +Y:1F194 +Y:24C2 FE0F +Y:24C2 +Y:1F195 +Y:1F196 +Y:1F17E FE0F +Y:1F17E +Y:1F197 +Y:1F17F FE0F +Y:1F17F +Y:1F198 +Y:1F199 +Y:1F19A +Y:1F201 +Y:1F202 FE0F +Y:1F202 +Y:1F237 FE0F +Y:1F237 +Y:1F236 +Y:1F22F +Y:1F250 +Y:1F239 +Y:1F21A +Y:1F232 +Y:1F251 +Y:1F238 +Y:1F234 +Y:1F233 +Y:3297 FE0F +Y:3297 +Y:3299 FE0F +Y:3299 +Y:1F23A +Y:1F235 +Y:1F534 +Y:1F7E0 +Y:1F7E1 +Y:1F7E2 +Y:1F535 +Y:1F7E3 +Y:1F7E4 +Y:26AB +Y:26AA +Y:1F7E5 +Y:1F7E7 +Y:1F7E8 +Y:1F7E9 +Y:1F7E6 +Y:1F7EA +Y:1F7EB +Y:2B1B +Y:2B1C +Y:25FC FE0F +Y:25FC +Y:25FB FE0F +Y:25FB +Y:25FE +Y:25FD +Y:25AA FE0F +Y:25AA +Y:25AB FE0F +Y:25AB +Y:1F536 +Y:1F537 +Y:1F538 +Y:1F539 +Y:1F53A +Y:1F53B +Y:1F4A0 +Y:1F518 +Y:1F533 +Y:1F532 +L:1F3C1 +L:1F6A9 +L:1F38C +L:1F3F4 +L:1F3F3 FE0F +L:1F3F3 +L:1F3F3 FE0F 200D 1F308 +L:1F3F3 200D 1F308 +L:1F3F3 FE0F 200D 26A7 FE0F +L:1F3F3 200D 26A7 FE0F +L:1F3F3 FE0F 200D 26A7 +L:1F3F3 200D 26A7 +L:1F3F4 200D 2620 FE0F +L:1F3F4 200D 2620 +L:1F1E6 1F1E8 +L:1F1E6 1F1E9 +L:1F1E6 1F1EA +L:1F1E6 1F1EB +L:1F1E6 1F1EC +L:1F1E6 1F1EE +L:1F1E6 1F1F1 +L:1F1E6 1F1F2 +L:1F1E6 1F1F4 +L:1F1E6 1F1F6 +L:1F1E6 1F1F7 +L:1F1E6 1F1F8 +L:1F1E6 1F1F9 +L:1F1E6 1F1FA +L:1F1E6 1F1FC +L:1F1E6 1F1FD +L:1F1E6 1F1FF +L:1F1E7 1F1E6 +L:1F1E7 1F1E7 +L:1F1E7 1F1E9 +L:1F1E7 1F1EA +L:1F1E7 1F1EB +L:1F1E7 1F1EC +L:1F1E7 1F1ED +L:1F1E7 1F1EE +L:1F1E7 1F1EF +L:1F1E7 1F1F1 +L:1F1E7 1F1F2 +L:1F1E7 1F1F3 +L:1F1E7 1F1F4 +L:1F1E7 1F1F6 +L:1F1E7 1F1F7 +L:1F1E7 1F1F8 +L:1F1E7 1F1F9 +L:1F1E7 1F1FB +L:1F1E7 1F1FC +L:1F1E7 1F1FE +L:1F1E7 1F1FF +L:1F1E8 1F1E6 +L:1F1E8 1F1E8 +L:1F1E8 1F1E9 +L:1F1E8 1F1EB +L:1F1E8 1F1EC +L:1F1E8 1F1ED +L:1F1E8 1F1EE +L:1F1E8 1F1F0 +L:1F1E8 1F1F1 +L:1F1E8 1F1F2 +L:1F1E8 1F1F3 +L:1F1E8 1F1F4 +L:1F1E8 1F1F5 +L:1F1E8 1F1F6 +L:1F1E8 1F1F7 +L:1F1E8 1F1FA +L:1F1E8 1F1FB +L:1F1E8 1F1FC +L:1F1E8 1F1FD +L:1F1E8 1F1FE +L:1F1E8 1F1FF +L:1F1E9 1F1EA +L:1F1E9 1F1EC +L:1F1E9 1F1EF +L:1F1E9 1F1F0 +L:1F1E9 1F1F2 +L:1F1E9 1F1F4 +L:1F1E9 1F1FF +L:1F1EA 1F1E6 +L:1F1EA 1F1E8 +L:1F1EA 1F1EA +L:1F1EA 1F1EC +L:1F1EA 1F1ED +L:1F1EA 1F1F7 +L:1F1EA 1F1F8 +L:1F1EA 1F1F9 +L:1F1EA 1F1FA +L:1F1EB 1F1EE +L:1F1EB 1F1EF +L:1F1EB 1F1F0 +L:1F1EB 1F1F2 +L:1F1EB 1F1F4 +L:1F1EB 1F1F7 +L:1F1EC 1F1E6 +L:1F1EC 1F1E7 +L:1F1EC 1F1E9 +L:1F1EC 1F1EA +L:1F1EC 1F1EB +L:1F1EC 1F1EC +L:1F1EC 1F1ED +L:1F1EC 1F1EE +L:1F1EC 1F1F1 +L:1F1EC 1F1F2 +L:1F1EC 1F1F3 +L:1F1EC 1F1F5 +L:1F1EC 1F1F6 +L:1F1EC 1F1F7 +L:1F1EC 1F1F8 +L:1F1EC 1F1F9 +L:1F1EC 1F1FA +L:1F1EC 1F1FC +L:1F1EC 1F1FE +L:1F1ED 1F1F0 +L:1F1ED 1F1F2 +L:1F1ED 1F1F3 +L:1F1ED 1F1F7 +L:1F1ED 1F1F9 +L:1F1ED 1F1FA +L:1F1EE 1F1E8 +L:1F1EE 1F1E9 +L:1F1EE 1F1EA +L:1F1EE 1F1F1 +L:1F1EE 1F1F2 +L:1F1EE 1F1F3 +L:1F1EE 1F1F4 +L:1F1EE 1F1F6 +L:1F1EE 1F1F7 +L:1F1EE 1F1F8 +L:1F1EE 1F1F9 +L:1F1EF 1F1EA +L:1F1EF 1F1F2 +L:1F1EF 1F1F4 +L:1F1EF 1F1F5 +L:1F1F0 1F1EA +L:1F1F0 1F1EC +L:1F1F0 1F1ED +L:1F1F0 1F1EE +L:1F1F0 1F1F2 +L:1F1F0 1F1F3 +L:1F1F0 1F1F5 +L:1F1F0 1F1F7 +L:1F1F0 1F1FC +L:1F1F0 1F1FE +L:1F1F0 1F1FF +L:1F1F1 1F1E6 +L:1F1F1 1F1E7 +L:1F1F1 1F1E8 +L:1F1F1 1F1EE +L:1F1F1 1F1F0 +L:1F1F1 1F1F7 +L:1F1F1 1F1F8 +L:1F1F1 1F1F9 +L:1F1F1 1F1FA +L:1F1F1 1F1FB +L:1F1F1 1F1FE +L:1F1F2 1F1E6 +L:1F1F2 1F1E8 +L:1F1F2 1F1E9 +L:1F1F2 1F1EA +L:1F1F2 1F1EB +L:1F1F2 1F1EC +L:1F1F2 1F1ED +L:1F1F2 1F1F0 +L:1F1F2 1F1F1 +L:1F1F2 1F1F2 +L:1F1F2 1F1F3 +L:1F1F2 1F1F4 +L:1F1F2 1F1F5 +L:1F1F2 1F1F6 +L:1F1F2 1F1F7 +L:1F1F2 1F1F8 +L:1F1F2 1F1F9 +L:1F1F2 1F1FA +L:1F1F2 1F1FB +L:1F1F2 1F1FC +L:1F1F2 1F1FD +L:1F1F2 1F1FE +L:1F1F2 1F1FF +L:1F1F3 1F1E6 +L:1F1F3 1F1E8 +L:1F1F3 1F1EA +L:1F1F3 1F1EB +L:1F1F3 1F1EC +L:1F1F3 1F1EE +L:1F1F3 1F1F1 +L:1F1F3 1F1F4 +L:1F1F3 1F1F5 +L:1F1F3 1F1F7 +L:1F1F3 1F1FA +L:1F1F3 1F1FF +L:1F1F4 1F1F2 +L:1F1F5 1F1E6 +L:1F1F5 1F1EA +L:1F1F5 1F1EB +L:1F1F5 1F1EC +L:1F1F5 1F1ED +L:1F1F5 1F1F0 +L:1F1F5 1F1F1 +L:1F1F5 1F1F2 +L:1F1F5 1F1F3 +L:1F1F5 1F1F7 +L:1F1F5 1F1F8 +L:1F1F5 1F1F9 +L:1F1F5 1F1FC +L:1F1F5 1F1FE +L:1F1F6 1F1E6 +L:1F1F7 1F1EA +L:1F1F7 1F1F4 +L:1F1F7 1F1F8 +L:1F1F7 1F1FA +L:1F1F7 1F1FC +L:1F1F8 1F1E6 +L:1F1F8 1F1E7 +L:1F1F8 1F1E8 +L:1F1F8 1F1E9 +L:1F1F8 1F1EA +L:1F1F8 1F1EC +L:1F1F8 1F1ED +L:1F1F8 1F1EE +L:1F1F8 1F1EF +L:1F1F8 1F1F0 +L:1F1F8 1F1F1 +L:1F1F8 1F1F2 +L:1F1F8 1F1F3 +L:1F1F8 1F1F4 +L:1F1F8 1F1F7 +L:1F1F8 1F1F8 +L:1F1F8 1F1F9 +L:1F1F8 1F1FB +L:1F1F8 1F1FD +L:1F1F8 1F1FE +L:1F1F8 1F1FF +L:1F1F9 1F1E6 +L:1F1F9 1F1E8 +L:1F1F9 1F1E9 +L:1F1F9 1F1EB +L:1F1F9 1F1EC +L:1F1F9 1F1ED +L:1F1F9 1F1EF +L:1F1F9 1F1F0 +L:1F1F9 1F1F1 +L:1F1F9 1F1F2 +L:1F1F9 1F1F3 +L:1F1F9 1F1F4 +L:1F1F9 1F1F7 +L:1F1F9 1F1F9 +L:1F1F9 1F1FB +L:1F1F9 1F1FC +L:1F1F9 1F1FF +L:1F1FA 1F1E6 +L:1F1FA 1F1EC +L:1F1FA 1F1F2 +L:1F1FA 1F1F3 +L:1F1FA 1F1F8 +L:1F1FA 1F1FE +L:1F1FA 1F1FF +L:1F1FB 1F1E6 +L:1F1FB 1F1E8 +L:1F1FB 1F1EA +L:1F1FB 1F1EC +L:1F1FB 1F1EE +L:1F1FB 1F1F3 +L:1F1FB 1F1FA +L:1F1FC 1F1EB +L:1F1FC 1F1F8 +L:1F1FD 1F1F0 +L:1F1FE 1F1EA +L:1F1FE 1F1F9 +L:1F1FF 1F1E6 +L:1F1FF 1F1F2 +L:1F1FF 1F1FC +L:1F3F4 E0067 E0062 E0065 E006E E0067 E007F +L:1F3F4 E0067 E0062 E0073 E0063 E0074 E007F +L:1F3F4 E0067 E0062 E0077 E006C E0073 E007F + """.trimIndent() + ) +} diff --git a/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/CustomSymbolKeyboardView.kt b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/CustomSymbolKeyboardView.kt index 23c8d0ffd..031ab43fc 100644 --- a/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/CustomSymbolKeyboardView.kt +++ b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/CustomSymbolKeyboardView.kt @@ -682,7 +682,7 @@ class CustomSymbolKeyboardView @JvmOverloads constructor( categoryTab.addTab( categoryTab.newTab().setIcon( categoryIconRes[cat] ?: com.kazumaproject.core.R.drawable.logo_key - ) + ).setContentDescription(emojiCategoryLabelRes.getValue(cat)) ) } } @@ -1035,9 +1035,21 @@ class CustomSymbolKeyboardView @JvmOverloads constructor( ).toInt() } + private val emojiCategoryLabelRes = mapOf( + EmojiCategory.SMILEYS_EMOTION to R.string.emoji_category_smileys_emotion, + EmojiCategory.PEOPLE_BODY to R.string.emoji_category_people_body, + EmojiCategory.ANIMALS_NATURE to R.string.emoji_category_animals_nature, + EmojiCategory.FOOD_DRINK to R.string.emoji_category_food_drink, + EmojiCategory.TRAVEL_PLACES to R.string.emoji_category_travel_places, + EmojiCategory.ACTIVITIES to R.string.emoji_category_activities, + EmojiCategory.OBJECTS to R.string.emoji_category_objects, + EmojiCategory.SYMBOLS to R.string.emoji_category_symbols, + EmojiCategory.FLAGS to R.string.emoji_category_flags, + EmojiCategory.UNKNOWN to R.string.emoji_category_unknown, + ) + private val categoryIconRes = mapOf( - EmojiCategory.EMOTICONS to com.kazumaproject.core.R.drawable.mood_24px, - EmojiCategory.GESTURES to com.kazumaproject.core.R.drawable.thumb_up_24dp, + EmojiCategory.SMILEYS_EMOTION to com.kazumaproject.core.R.drawable.mood_24px, EmojiCategory.PEOPLE_BODY to com.kazumaproject.core.R.drawable.person_24dp, EmojiCategory.ANIMALS_NATURE to com.kazumaproject.core.R.drawable.pets_24dp, EmojiCategory.FOOD_DRINK to com.kazumaproject.core.R.drawable.fastfood_24dp, @@ -1049,35 +1061,7 @@ class CustomSymbolKeyboardView @JvmOverloads constructor( EmojiCategory.UNKNOWN to com.kazumaproject.core.R.drawable.question_mark_24dp ) - private val categoryOrder = Comparator { a, b -> - listOf( - EmojiCategory.EMOTICONS, - EmojiCategory.GESTURES, - EmojiCategory.PEOPLE_BODY, - EmojiCategory.ANIMALS_NATURE, - EmojiCategory.FOOD_DRINK, - EmojiCategory.TRAVEL_PLACES, - EmojiCategory.ACTIVITIES, - EmojiCategory.OBJECTS, - EmojiCategory.SYMBOLS, - EmojiCategory.FLAGS, - EmojiCategory.UNKNOWN - ).indexOf(a).compareTo( - listOf( - EmojiCategory.EMOTICONS, - EmojiCategory.GESTURES, - EmojiCategory.PEOPLE_BODY, - EmojiCategory.ANIMALS_NATURE, - EmojiCategory.FOOD_DRINK, - EmojiCategory.TRAVEL_PLACES, - EmojiCategory.ACTIVITIES, - EmojiCategory.OBJECTS, - EmojiCategory.SYMBOLS, - EmojiCategory.FLAGS, - EmojiCategory.UNKNOWN - ).indexOf(b) - ) - } + private val categoryOrder = compareBy { it.ordinal } fun switchToClipboardMode(selectCategoryIndex: Int = 0) { // Modeを切り替え diff --git a/symbol_keyboard/src/main/res/values-ja/strings.xml b/symbol_keyboard/src/main/res/values-ja/strings.xml index 7a0702816..1148d6f67 100644 --- a/symbol_keyboard/src/main/res/values-ja/strings.xml +++ b/symbol_keyboard/src/main/res/values-ja/strings.xml @@ -6,4 +6,14 @@ ピン留め ピン留めを解除 削除 + 顔文字、気分 + 人々 + 動物、自然 + 食品、飲料 + 移動、場所 + 活動、イベント + + 記号 + + その他 diff --git a/symbol_keyboard/src/main/res/values/strings.xml b/symbol_keyboard/src/main/res/values/strings.xml index 3aef28f3a..b4ed376ff 100644 --- a/symbol_keyboard/src/main/res/values/strings.xml +++ b/symbol_keyboard/src/main/res/values/strings.xml @@ -6,4 +6,14 @@ Pin Unpin Delete + Smileys & Emotions + People + Animals & Nature + Food & Drink + Travel & Places + Activities & Events + Objects + Symbols + Flags + Other diff --git a/symbol_keyboard/src/test/java/com/kazumaproject/domain/EmojiCategoryMetadataTest.kt b/symbol_keyboard/src/test/java/com/kazumaproject/domain/EmojiCategoryMetadataTest.kt new file mode 100644 index 000000000..e105ef0e8 --- /dev/null +++ b/symbol_keyboard/src/test/java/com/kazumaproject/domain/EmojiCategoryMetadataTest.kt @@ -0,0 +1,76 @@ +package com.kazumaproject.domain + +import com.kazumaproject.data.emoji.Emoji +import com.kazumaproject.data.emoji.EmojiCategory +import com.kazumaproject.data.emoticon.EmoticonCategory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class EmojiCategoryMetadataTest { + + @Test + fun unicodeSequencesUseTheirPublishedTopLevelGroups() { + val expected = mapOf( + "😀" to EmojiCategory.SMILEYS_EMOTION, + "☺" to EmojiCategory.SMILEYS_EMOTION, + "❤️‍🔥" to EmojiCategory.SMILEYS_EMOTION, + "👍" to EmojiCategory.PEOPLE_BODY, + "👍🏽" to EmojiCategory.PEOPLE_BODY, + "👩‍💻" to EmojiCategory.PEOPLE_BODY, + "🐶" to EmojiCategory.ANIMALS_NATURE, + "🍎" to EmojiCategory.FOOD_DRINK, + "✈️" to EmojiCategory.TRAVEL_PLACES, + "⚽" to EmojiCategory.ACTIVITIES, + "💡" to EmojiCategory.OBJECTS, + "#️⃣" to EmojiCategory.SYMBOLS, + "🇯🇵" to EmojiCategory.FLAGS, + "⌚️" to EmojiCategory.TRAVEL_PLACES, + "♈️" to EmojiCategory.SYMBOLS, + "🫩" to EmojiCategory.SMILEYS_EMOTION, + "🫪" to EmojiCategory.SMILEYS_EMOTION, + ) + + expected.forEach { (symbol, category) -> + assertEquals(symbol, category, categorizeEmoji(symbol)) + } + assertEquals(EmojiCategory.UNKNOWN, categorizeEmoji("not-an-emoji")) + } + + @Test + fun emojiSortUsesPublishedCldrOrderAndLeavesUnknownLast() { + val input = listOf( + Emoji("not-an-emoji", EmojiCategory.UNKNOWN), + Emoji("😃", categorizeEmoji("😃")), + Emoji("😀", categorizeEmoji("😀")), + Emoji("🇯🇵", categorizeEmoji("🇯🇵")), + ) + + assertEquals( + listOf("😀", "😃", "🇯🇵", "not-an-emoji"), + input.sortByEmojiCategory().map { it.symbol }, + ) + } + + @Test + fun originalEmoticonCategoriesRemainUnchanged() { + val expected = mapOf( + ":-)" to EmoticonCategory.SMILE, + "( •̀ㅁ•́;)" to EmoticonCategory.SWEAT, + "Σ(゚Д゚)" to EmoticonCategory.SURPRISE, + "(TT)" to EmoticonCategory.SADNESS, + "(-_-)" to EmoticonCategory.DISPLEASURE, + ) + + expected.forEach { (symbol, category) -> + assertEquals(symbol, category, symbol.toEmoticonCategory()) + } + assertEquals(EmoticonCategory.UNKNOWN, "not-an-emoticon".toEmoticonCategory()) + } + + @Test + fun generatedMetadataContainsThePinnedSourceData() { + assertEquals("17.0", EmojiCategoryMetadata.UNICODE_VERSION) + assertTrue(EmojiCategoryMetadata.emojiEntryCount() > 5_000) + } +} diff --git a/tenkey/src/main/java/com/kazumaproject/tenkey/TenKey.kt b/tenkey/src/main/java/com/kazumaproject/tenkey/TenKey.kt index fec4339f2..72aaea24f 100644 --- a/tenkey/src/main/java/com/kazumaproject/tenkey/TenKey.kt +++ b/tenkey/src/main/java/com/kazumaproject/tenkey/TenKey.kt @@ -49,6 +49,9 @@ import com.kazumaproject.core.domain.state.toTwoStateNumberReturnTargetOrNull import com.kazumaproject.core.data.popup.PopupViewStyle import com.kazumaproject.core.domain.flick.FlickDirection as CoreFlickDirection import com.kazumaproject.core.domain.flick.FlickGestureMath +import com.kazumaproject.core.domain.flick.FlickTextPreviewEmitter +import com.kazumaproject.core.domain.flick.FlickTextPreviewListener +import com.kazumaproject.core.domain.flick.FlickTextSelection import com.kazumaproject.core.domain.flick.FlickThresholdShape import com.kazumaproject.core.ui.effect.Blur import com.kazumaproject.core.ui.input_mode_witch.InputModeSwitch @@ -135,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 val flickTextPreviewEmitter = FlickTextPreviewEmitter() private var flickSensitivity: Int = 100 private var flickThresholdPx: Float = resolveFlickThresholdPx(flickSensitivity) @@ -1319,6 +1323,10 @@ class TenKey(context: Context, attributeSet: AttributeSet) : this.flickListener = flickListener } + fun setOnFlickTextPreviewListener(listener: FlickTextPreviewListener?) { + flickTextPreviewEmitter.listener = listener + } + /** Allow setting an external LongPressListener **/ fun setOnLongPressListener(longPressListener: LongPressListener) { this.longPressListener = longPressListener @@ -1382,6 +1390,8 @@ class TenKey(context: Context, attributeSet: AttributeSet) : keyTouchCancelListener = null inputModeChangedListener = null qwertyNumberModeRequestedListener = null + flickTextPreviewEmitter.cancel() + flickTextPreviewEmitter.listener = null longPressJob?.cancel() longPressJob = null isCursorMode = false @@ -1439,9 +1449,14 @@ class TenKey(context: Context, attributeSet: AttributeSet) : } if (isCursorMode) { + flickTextPreviewEmitter.cancel() return true } + flickTextPreviewEmitter.begin( + resolveTextSelection(key, GestureType.Tap) + ) + setKeyPressed() longPressJob = scope.launch { delay(longPressTimeout) @@ -1476,56 +1491,9 @@ class TenKey(context: Context, attributeSet: AttributeSet) : if (pressedKey.pointer == event.getPointerId(event.actionIndex)) { val gestureType = getGestureType(event) - // ← READING the state flow's current value: - val keyInfo = currentInputMode.value - .next(keyMap = keyMap, key = pressedKey.key, isTablet = false) - - Log.d("TenKey: ACTION_UP in pointer", "called $keyInfo $pressedKey") - - if (keyInfo == KeyInfo.Null) { - flickListener?.onFlick( - gestureType = gestureType, key = pressedKey.key, char = null - ) - if (pressedKey.key == Key.SideKeyInputMode) { - handleClickInputModeSwitch() - } else if (pressedKey.key == Key.SideKeyNumberMode) { - switchToNumberMode() - } - } else if (keyInfo is KeyInfo.KeyTapFlickInfo) { - when (gestureType) { - GestureType.Null -> {} - GestureType.Down -> {} - GestureType.Tap -> flickListener?.onFlick( - gestureType = gestureType, - key = pressedKey.key, - char = keyInfo.tap - ) - - GestureType.FlickLeft -> flickListener?.onFlick( - gestureType = gestureType, - key = pressedKey.key, - char = keyInfo.flickLeft - ) - - GestureType.FlickTop -> flickListener?.onFlick( - gestureType = gestureType, - key = pressedKey.key, - char = keyInfo.flickTop - ) - - GestureType.FlickRight -> flickListener?.onFlick( - gestureType = gestureType, - key = pressedKey.key, - char = keyInfo.flickRight - ) + Log.d("TenKey: ACTION_UP in pointer", "called $pressedKey") - GestureType.FlickBottom -> flickListener?.onFlick( - gestureType = gestureType, - key = pressedKey.key, - char = keyInfo.flickBottom - ) - } - } + dispatchResolvedGesture(pressedKey.key, gestureType) } Log.d("TenKey: ACTION_UP out", "called $pressedKey") resetAllKeys() @@ -1599,6 +1567,9 @@ class TenKey(context: Context, attributeSet: AttributeSet) : } else { getGestureType(event, pressedKey.pointer) } + flickTextPreviewEmitter.update( + resolveTextSelection(pressedKey.key, gestureType) + ) when (gestureType) { GestureType.Null -> {} GestureType.Down -> {} @@ -1641,37 +1612,9 @@ class TenKey(context: Context, attributeSet: AttributeSet) : if (pressedKey.key == Key.KeyDakutenSmall && currentInputMode.value == InputMode.ModeNumber) { setNumberSmallKeyPresentation() } - val keyInfo = currentInputMode.value - .next(keyMap = keyMap, key = pressedKey.key, isTablet = false) - if (keyInfo == KeyInfo.Null) { - flickListener?.onFlick( - gestureType = gestureType2, key = pressedKey.key, char = null - ) - } else if (keyInfo is KeyInfo.KeyTapFlickInfo) { - when (gestureType2) { - GestureType.Null -> {} - GestureType.Down -> {} - GestureType.Tap -> { - flickListener?.onFlick( - gestureType = gestureType2, - key = pressedKey.key, - char = keyInfo.tap - ) - val button = getButtonFromKey(pressedKey.key) - button?.let { - if (it is AppCompatButton) { - setTextForMode(it, currentInputMode.value) - } - if (it is AppCompatImageButton && currentInputMode.value == InputMode.ModeNumber && it == binding.keySmallLetter) { - setNumberSmallKeyPresentation() - } - } - } - - GestureType.FlickLeft, GestureType.FlickTop, GestureType.FlickRight, GestureType.FlickBottom -> { - setFlickActionPointerDown(keyInfo, gestureType2) - } - } + dispatchResolvedGesture(pressedKey.key, gestureType2) + (getButtonFromKey(pressedKey.key) as? AppCompatButton)?.let { button -> + setTextForMode(button, currentInputMode.value) } pressedKey = pressedKey.copy( key = key, pointer = pointer, initialX = if (pointer == 0) { @@ -1701,6 +1644,9 @@ class TenKey(context: Context, attributeSet: AttributeSet) : } ) setKeyPressed() + flickTextPreviewEmitter.begin( + resolveTextSelection(key, GestureType.Tap) + ) longPressJob = scope.launch { delay(longPressTimeout) if (pressedKey.key != Key.NotSelected) { @@ -1721,56 +1667,8 @@ class TenKey(context: Context, attributeSet: AttributeSet) : val gestureType = getGestureType( event, event.getPointerId(event.actionIndex) ) - val keyInfo = currentInputMode.value - .next(keyMap = keyMap, key = pressedKey.key, isTablet = false) - Log.d("TenKey: ACTION_POINTER_UP", "called [${pressedKey.key}]") - if (keyInfo == KeyInfo.Null) { - flickListener?.onFlick( - gestureType = gestureType, key = pressedKey.key, char = null - ) - if (pressedKey.key == Key.SideKeyInputMode) { - handleClickInputModeSwitch() - } else if (pressedKey.key == Key.SideKeyNumberMode) { - switchToNumberMode() - } - } else if (keyInfo is KeyInfo.KeyTapFlickInfo) { - when (gestureType) { - GestureType.Null -> {} - GestureType.Down -> {} - GestureType.Tap -> { - flickListener?.onFlick( - gestureType = gestureType, - key = pressedKey.key, - char = keyInfo.tap - ) - } - - GestureType.FlickLeft -> flickListener?.onFlick( - gestureType = gestureType, - key = pressedKey.key, - char = keyInfo.flickLeft - ) - - GestureType.FlickTop -> flickListener?.onFlick( - gestureType = gestureType, - key = pressedKey.key, - char = keyInfo.flickTop - ) - - GestureType.FlickRight -> flickListener?.onFlick( - gestureType = gestureType, - key = pressedKey.key, - char = keyInfo.flickRight - ) - - GestureType.FlickBottom -> flickListener?.onFlick( - gestureType = gestureType, - key = pressedKey.key, - char = keyInfo.flickBottom - ) - } - } + dispatchResolvedGesture(pressedKey.key, gestureType) val button = getButtonFromKey(pressedKey.key) button?.let { if (it is AppCompatButton) { @@ -1798,6 +1696,7 @@ class TenKey(context: Context, attributeSet: AttributeSet) : } private fun cancelActiveTouch(reason: KeyTouchCancelReason) { + flickTextPreviewEmitter.cancel() resetLongPressAction() resetAllKeys() @@ -2167,6 +2066,74 @@ class TenKey(context: Context, attributeSet: AttributeSet) : } } + private fun resolveTextSelection( + key: Key, + gestureType: GestureType, + ): FlickTextSelection { + val keyInfo = currentInputMode.value.next( + keyMap = keyMap, + key = key, + isTablet = false + ) + return resolveTextSelection(keyInfo, gestureType) + } + + private fun resolveTextSelection( + keyInfo: KeyInfo, + gestureType: GestureType, + ): FlickTextSelection { + val character = (keyInfo as? KeyInfo.KeyTapFlickInfo)?.let { info -> + when (gestureType) { + GestureType.Tap -> info.tap + GestureType.FlickLeft -> info.flickLeft + GestureType.FlickTop -> info.flickTop + GestureType.FlickRight -> info.flickRight + GestureType.FlickBottom -> info.flickBottom + GestureType.Null, + GestureType.Down -> null + } + } + return FlickTextSelection( + text = character?.toString(), + isFlick = gestureType != GestureType.Tap + ) + } + + private fun dispatchResolvedGesture(key: Key, gestureType: GestureType) { + val keyInfo = currentInputMode.value.next( + keyMap = keyMap, + key = key, + isTablet = false + ) + val selection = resolveTextSelection(keyInfo, gestureType) + flickTextPreviewEmitter.commit(selection) { + when (keyInfo) { + KeyInfo.Null -> { + flickListener?.onFlick( + gestureType = gestureType, + key = key, + char = null + ) + if (key == Key.SideKeyInputMode) { + handleClickInputModeSwitch() + } else if (key == Key.SideKeyNumberMode) { + switchToNumberMode() + } + } + + is KeyInfo.KeyTapFlickInfo -> { + if (gestureType != GestureType.Null && gestureType != GestureType.Down) { + flickListener?.onFlick( + gestureType = gestureType, + key = key, + char = selection.text?.firstOrNull() + ) + } + } + } + } + } + private fun resolveFlickThresholdPx(sensitivity: Int): Float { return FlickGestureMath.thresholdPxForSensitivity( sensitivity = sensitivity, diff --git a/tools/emoji_metadata/generate_category_metadata.py b/tools/emoji_metadata/generate_category_metadata.py new file mode 100644 index 000000000..617032384 --- /dev/null +++ b/tools/emoji_metadata/generate_category_metadata.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Generate Kotlin category metadata from the pinned Unicode source. + +The generated Kotlin is written to stdout so callers can review/apply it explicitly. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + + +EMOJI_GROUPS = { + "Smileys & Emotion": "S", + "People & Body": "P", + "Animals & Nature": "N", + "Food & Drink": "F", + "Travel & Places": "T", + "Activities": "A", + "Objects": "O", + "Symbols": "Y", + "Flags": "L", +} + +def parse_emoji_test(path: Path) -> tuple[str, list[str]]: + version = "unknown" + current_group: str | None = None + rows: list[str] = [] + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if line.startswith("# Version:"): + version = line.split(":", 1)[1].strip() + elif line.startswith("# group:"): + current_group = line.split(":", 1)[1].strip() + elif line and not line.startswith("#") and ";" in line: + category_code = EMOJI_GROUPS.get(current_group or "") + if category_code is None: + continue + code_points = line.split(";", 1)[0].strip() + rows.append(f"{category_code}:{code_points}") + return version, rows + + +def chunks(rows: list[str], max_chars: int = 48_000) -> list[str]: + result: list[str] = [] + current: list[str] = [] + current_size = 0 + for row in rows: + row_size = len(row) + 1 + if current and current_size + row_size > max_chars: + result.append("\n".join(current)) + current = [] + current_size = 0 + current.append(row) + current_size += row_size + if current: + result.append("\n".join(current)) + return result + + +def render_chunks(name: str, rows: list[str]) -> str: + rendered = [] + for chunk in chunks(rows): + rendered.append(' """\n' + chunk + '\n """.trimIndent()') + return f" private val {name} = arrayOf(\n" + ",\n".join(rendered) + "\n )" + + +def render_kotlin(emoji_version: str, emoji_rows: list[str]) -> str: + emoji_chunks = render_chunks("emojiDataChunks", emoji_rows) + return f'''// Generated by tools/emoji_metadata/generate_category_metadata.py. Do not edit manually. +// Unicode Emoji {emoji_version}: https://www.unicode.org/Public/{emoji_version}.0/emoji/emoji-test.txt +package com.kazumaproject.domain + +import com.kazumaproject.data.emoji.EmojiCategory + +internal object EmojiCategoryMetadata {{ + const val UNICODE_VERSION: String = "{emoji_version}" + + private data class EmojiMetadata(val category: EmojiCategory, val order: Int) + + private val emojiIndex: Map by lazy(LazyThreadSafetyMode.PUBLICATION) {{ + val result = HashMap({len(emoji_rows) * 2}) + var order = 0 + emojiDataChunks.forEach {{ chunk -> + chunk.lineSequence().filter(String::isNotBlank).forEach {{ line -> + val separator = line.indexOf(':') + val category = emojiCategory(line.substring(0, separator)) + val symbol = decodeCodePoints(line, separator + 1) + result.putIfAbsent(symbol, EmojiMetadata(category, order)) + order++ + }} + }} + result + }} + + // U+FE0E/U+FE0F select text/emoji presentation; they do not change the + // Unicode/CLDR group. Some bundled entries add U+FE0F to characters whose + // fully-qualified emoji-test sequence does not contain a selector. + private val presentationIndependentEmojiIndex: Map by + lazy(LazyThreadSafetyMode.PUBLICATION) {{ + val result = HashMap(emojiIndex.size * 2) + emojiIndex.forEach {{ (symbol, metadata) -> + val key = symbol.withoutPresentationSelectors() + val existing = result[key] + check(existing == null || existing.category == metadata.category) {{ + "Presentation-independent emoji category collision for $key" + }} + if (existing == null || metadata.order < existing.order) {{ + result[key] = metadata + }} + }} + result + }} + + fun emojiCategoryOf(symbol: String): EmojiCategory = + emojiMetadataOf(symbol)?.category ?: EmojiCategory.UNKNOWN + + fun emojiOrderOf(symbol: String): Int = emojiMetadataOf(symbol)?.order ?: Int.MAX_VALUE + + internal fun emojiEntryCount(): Int = emojiIndex.size + + private fun emojiMetadataOf(symbol: String): EmojiMetadata? = + emojiIndex[symbol] + ?: presentationIndependentEmojiIndex[symbol.withoutPresentationSelectors()] + + private fun String.withoutPresentationSelectors(): String = + replace("\uFE0E", "").replace("\uFE0F", "") + + private fun emojiCategory(code: String): EmojiCategory = when (code) {{ + "S" -> EmojiCategory.SMILEYS_EMOTION + "P" -> EmojiCategory.PEOPLE_BODY + "N" -> EmojiCategory.ANIMALS_NATURE + "F" -> EmojiCategory.FOOD_DRINK + "T" -> EmojiCategory.TRAVEL_PLACES + "A" -> EmojiCategory.ACTIVITIES + "O" -> EmojiCategory.OBJECTS + "Y" -> EmojiCategory.SYMBOLS + "L" -> EmojiCategory.FLAGS + else -> error("Unknown emoji category code: $code") + }} + + private fun decodeCodePoints(line: String, startIndex: Int): String {{ + val result = StringBuilder() + var tokenStart = startIndex + var index = startIndex + while (index <= line.length) {{ + if (index == line.length || line[index] == ' ') {{ + if (index > tokenStart) {{ + result.appendCodePoint(line.substring(tokenStart, index).toInt(16)) + }} + tokenStart = index + 1 + }} + index++ + }} + return result.toString() + }} + +{emoji_chunks} +}} +''' + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--emoji-test", type=Path, required=True) + args = parser.parse_args() + + emoji_version, emoji_rows = parse_emoji_test(args.emoji_test) + print(render_kotlin(emoji_version, emoji_rows), end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/zenz/build.gradle b/zenz/build.gradle index 1268f2774..9a102fa2f 100644 --- a/zenz/build.gradle +++ b/zenz/build.gradle @@ -10,10 +10,10 @@ if (localPropertiesFile.exists()) { } def zenzModelRepo = providers.gradleProperty("zenzModelRepo") - .orElse(localProperties.getProperty("zenzModelRepo") ?: "Miwa-Keita/zenz-v3.1-xsmall") + .orElse(localProperties.getProperty("zenzModelRepo") ?: "Miwa-Keita/zenz-v3.2-xsmall-gguf") .get() def zenzModelRevision = providers.gradleProperty("zenzModelRevision") - .orElse(localProperties.getProperty("zenzModelRevision") ?: "a728931c8b4867a53ca33d1ff3fe7360b0f15cd5") + .orElse(localProperties.getProperty("zenzModelRevision") ?: "4f5423f0fad41a73b1242eb96fe5c12ae4fdca83") .get() def zenzModelQuantization = providers.gradleProperty("zenzModelQuantization") .orElse(localProperties.getProperty("zenzModelQuantization") ?: "Q5_K_M") @@ -114,7 +114,7 @@ android { def prepareZenzModel = tasks.register("prepareZenzModel", Exec) { group = "zenz" - description = "Downloads the Zenz model from Hugging Face, converts it to GGUF, and quantizes it for packaging." + description = "Downloads the Zenz GGUF model from Hugging Face for packaging." def outputDir = generatedZenzAssetsDir.get().asFile diff --git a/zenz/scripts/prepare_zenz_model.sh b/zenz/scripts/prepare_zenz_model.sh index 3648a1729..17c3e5388 100644 --- a/zenz/scripts/prepare_zenz_model.sh +++ b/zenz/scripts/prepare_zenz_model.sh @@ -75,7 +75,6 @@ PIP_BIN="${VENV_DIR}/bin/pip" "${PIP_BIN}" install --upgrade pip >/dev/null "${PIP_BIN}" install huggingface_hub >/dev/null -"${PIP_BIN}" install -r "${LLAMA_CPP_DIR}/requirements/requirements-convert_hf_to_gguf.txt" >/dev/null rm -rf "${MODEL_DIR}" mkdir -p "${MODEL_DIR}" @@ -114,6 +113,23 @@ for attempt in $(seq 1 "${max_attempts}"); do sleep "${sleep_seconds}" done +# GGUF repositories already contain the final quantized model. Keep the same +# build/cache contract as the conversion path, but avoid converting and +# quantizing the file a second time. +GGUF_SOURCE_FILE="${MODEL_DIR}/${ZENZ_MODEL_ASSET_NAME}" +if [[ -f "${GGUF_SOURCE_FILE}" ]]; then + cp "${GGUF_SOURCE_FILE}" "${OUTPUT_FILE}" + printf '%s\n' "${EXPECTED_STAMP}" > "${STAMP_FILE}" + if [[ -n "${CACHE_FILE}" ]]; then + cp "${OUTPUT_FILE}" "${CACHE_FILE}" + cp "${STAMP_FILE}" "${CACHE_STAMP_FILE}" + fi + echo "Prepared ${OUTPUT_FILE} from ${GGUF_SOURCE_FILE}" + exit 0 +fi + +"${PIP_BIN}" install -r "${LLAMA_CPP_DIR}/requirements/requirements-convert_hf_to_gguf.txt" >/dev/null + cmake -S "${LLAMA_CPP_DIR}" -B "${HOST_BUILD_DIR}" \ -DCMAKE_BUILD_TYPE=Release \ -DLLAMA_BUILD_COMMON=ON \