diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml
new file mode 100644
index 000000000..85e9b74ce
--- /dev/null
+++ b/app/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/app/src/androidTest/java/it/palsoftware/pastiera/inputmethod/composition/HangulSystemImeInstrumentedTest.kt b/app/src/androidTest/java/it/palsoftware/pastiera/inputmethod/composition/HangulSystemImeInstrumentedTest.kt
new file mode 100644
index 000000000..7762350db
--- /dev/null
+++ b/app/src/androidTest/java/it/palsoftware/pastiera/inputmethod/composition/HangulSystemImeInstrumentedTest.kt
@@ -0,0 +1,151 @@
+package it.palsoftware.pastiera.inputmethod.composition
+
+import android.os.SystemClock
+import android.view.KeyEvent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Hardware key injection through Android's selected IME, never a direct composer call.
+ * Run against an isolated debug installation; the runner must restore the user's IME afterward.
+ */
+@RunWith(AndroidJUnit4::class)
+class HangulSystemImeInstrumentedTest {
+ private val instrumentation = InstrumentationRegistry.getInstrumentation()
+ private val context = instrumentation.targetContext
+
+ @Before
+ fun configureTestApp() {
+ context.contentResolver.call(android.net.Uri.parse("content://${context.packageName}.ime-test-settings"),
+ "configure", null, null)
+ val expected = android.content.ComponentName(context,
+ it.palsoftware.pastiera.inputmethod.PhysicalKeyboardInputMethodService::class.java).flattenToShortString()
+ assertEquals("Select isolated debug IME first", expected,
+ android.provider.Settings.Secure.getString(context.contentResolver,"default_input_method"))
+ }
+
+ private lateinit var activeEditor: EditorSession
+
+ private inner class EditorSession(local: Boolean = false) {
+ private val authority = if (local) "ime-test-settings" else "ime-editor-test"
+ fun command(method: String, arg: String? = null, extras: android.os.Bundle? = null) =
+ context.contentResolver.call(android.net.Uri.parse("content://${context.packageName}.$authority"),
+ method, arg, extras)!!
+ }
+ private fun editor(kind: String = "native", block: (EditorSession) -> Unit) {
+ val session = EditorSession(local = kind == "local_compose_string")
+ activeEditor = session
+ session.command("launch", if (kind == "local_compose_string") "compose_string" else kind)
+ try {
+ awaitCondition { session.command("state").getBoolean("ready") }
+ session.command("focus")
+ // Instrumentation restarts the target process, including its selected IME.
+ // Wait for Android to bind the service again before testing connected input.
+ var lastRestart = SystemClock.uptimeMillis()
+ awaitCondition {
+ val pid = session.command("state").getInt("pid")
+ val dump = android.os.ParcelFileDescriptor.AutoCloseInputStream(
+ instrumentation.uiAutomation.executeShellCommand("dumpsys input_method")
+ ).bufferedReader().use { it.readText() }
+ val bound = dump.contains("mBoundToMethod=true") &&
+ dump.lineSequence().any { it.contains("mEnabledSession=") && it.contains("pid=$pid ") }
+ if (!bound && SystemClock.uptimeMillis() - lastRestart > 500) {
+ // Re-request the empty editor connection if Android retained a stale
+ // binding while instrumentation replaced the previous process.
+ session.command("restartConnection")
+ lastRestart = SystemClock.uptimeMillis()
+ }
+ bound
+ }
+ SystemClock.sleep(700)
+ block(session)
+ } finally {
+ session.command("close")
+ awaitCondition { !session.command("state").getBoolean("ready") }
+ }
+ }
+ private fun keys(vararg codes: Int) {
+ check(activeEditor.command("state").getBoolean("ready")) { "Test editor lost window focus; stopping injection" }
+ codes.forEach { code ->
+ val now = SystemClock.uptimeMillis()
+ for (action in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) {
+ assertTrue(instrumentation.uiAutomation.injectInputEvent(
+ KeyEvent(now, now, action, code, 0, 0, android.view.KeyCharacterMap.VIRTUAL_KEYBOARD,
+ 0, 0, android.view.InputDevice.SOURCE_KEYBOARD), true))
+ }
+ }
+ }
+ private fun type(ascii: String) = keys(*ascii.map { KeyEvent.KEYCODE_A + (it-'a') }.toIntArray())
+ private fun read(s: EditorSession): String {
+ val result = s.command("read")
+ check(result.containsKey("text")) { "Editor has no text response: $result" }
+ return result.getString("text").orEmpty()
+ }
+ private fun awaitCondition(check: () -> Boolean) {
+ val end = SystemClock.uptimeMillis() + 15000
+ while (!check()) {
+ assertTrue("Condition timed out", SystemClock.uptimeMillis() < end)
+ SystemClock.sleep(25)
+ }
+ }
+ private fun awaitText(expected: String, s: EditorSession) {
+ val end = SystemClock.uptimeMillis() + 3000
+ while (read(s) != expected && SystemClock.uptimeMillis() < end) SystemClock.sleep(25)
+ assertEquals(expected, read(s))
+ }
+
+ @Test
+ fun nativeCompositionAndCompleteDeletion() = editor { s ->
+ type("gksrmf")
+ awaitText("한글", s)
+ repeat(6) { keys(KeyEvent.KEYCODE_DEL) }
+ awaitText("", s)
+ assertEquals(-1, s.command("read").getInt("composingStart"))
+ }
+ @Test
+ fun nativeSpaceAndPunctuationPreserveText() = editor { s ->
+ type("gksrmf")
+ keys(KeyEvent.KEYCODE_SPACE)
+ type("rk")
+ keys(KeyEvent.KEYCODE_PERIOD)
+ awaitText("한글 가.", s)
+ }
+ @Test
+ fun nativeLongRunAndBackspace() = editor { s ->
+ type("rk".repeat(63) + "rhk")
+ awaitText("가".repeat(63) + "과", s)
+ keys(KeyEvent.KEYCODE_DEL)
+ awaitText("가".repeat(63) + "고", s)
+ }
+ @Test
+ fun selectionReplacementAndRestart() = editor { s ->
+ type("gksrmf")
+ awaitText("한글", s)
+ s.command("select", extras = android.os.Bundle().apply { putInt("start",0); putInt("end",2) })
+ SystemClock.sleep(200)
+ type("rk")
+ awaitText("가", s)
+ s.command("restart")
+ SystemClock.sleep(200)
+ type("sk")
+ awaitText("나", s)
+ }
+ @Test
+ fun composeCompositionAndDeletion() = editor("compose") { s ->
+ type("gksrmf")
+ awaitText("한글", s)
+ repeat(6) { keys(KeyEvent.KEYCODE_DEL) }
+ awaitText("", s)
+ }
+ @Test
+ fun webViewCompositionAndBoundary() = editor("web") { s ->
+ type("gksrmf")
+ awaitText("한글", s)
+ keys(KeyEvent.KEYCODE_SPACE)
+ type("rk")
+ awaitText("한글 가", s)
+ }
+}
diff --git a/app/src/androidTest/java/it/palsoftware/pastiera/inputmethod/composition/ImeCompositionDeviceRegressionTest.kt b/app/src/androidTest/java/it/palsoftware/pastiera/inputmethod/composition/ImeCompositionDeviceRegressionTest.kt
new file mode 100644
index 000000000..b3bde0c59
--- /dev/null
+++ b/app/src/androidTest/java/it/palsoftware/pastiera/inputmethod/composition/ImeCompositionDeviceRegressionTest.kt
@@ -0,0 +1,194 @@
+package it.palsoftware.pastiera.inputmethod.composition
+
+import android.text.Selection
+import android.view.View
+import android.view.inputmethod.BaseInputConnection
+import android.view.inputmethod.ExtractedText
+import android.view.inputmethod.ExtractedTextRequest
+import it.palsoftware.pastiera.core.composition.NormalizedCompositionStroke
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+
+@RunWith(AndroidJUnit4::class)
+class ImeCompositionDeviceRegressionTest {
+ private fun onMain(action: () -> Unit) {
+ var failure: Throwable? = null
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ try { action() } catch (error: Throwable) { failure = error }
+ }
+ failure?.let { throw it }
+ }
+
+ private open class Editor : BaseInputConnection(View(InstrumentationRegistry.getInstrumentation().targetContext), true) {
+ var rejectComposing = false
+ var rejectCommit = false
+ var throwComposing = false
+ override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean {
+ if (throwComposing) throw IllegalStateException("Editor disconnected")
+ return !rejectComposing && super.setComposingText(text, newCursorPosition)
+ }
+ override fun commitText(text: CharSequence?, newCursorPosition: Int) =
+ !rejectCommit && super.commitText(text, newCursorPosition)
+ override fun getExtractedText(request: ExtractedTextRequest?, flags: Int) = ExtractedText().apply {
+ text = editable.toString()
+ startOffset = 0
+ selectionStart = Selection.getSelectionStart(editable)
+ selectionEnd = Selection.getSelectionEnd(editable)
+ }
+ }
+ private fun coordinator() = ImeCompositionCoordinator().apply {
+ startEditorSession()
+ selectLayout("korean_2set")
+ }
+ private fun type(c: ImeCompositionCoordinator, e: BaseInputConnection, text: String) {
+ text.forEach { assertTrue(c.handleMappedStroke(e, NormalizedCompositionStroke(it))) }
+ }
+ private fun acknowledge(c: ImeCompositionCoordinator, e: BaseInputConnection, old: Int): SelectionUpdateDisposition {
+ val cursor = Selection.getSelectionStart(e.editable)
+ return c.onUpdateSelection(old, old, cursor, cursor,
+ BaseInputConnection.getComposingSpanStart(e.editable!!),
+ BaseInputConnection.getComposingSpanEnd(e.editable!!))
+ }
+
+ @Test fun finalDeletePreservesCommittedPrefixAndEndsOwnership() = onMain {
+ val e = Editor(); val c = coordinator()
+ e.commitText("prefix ", 1)
+ type(c, e, "ㄱㅏㅂㅅ")
+ listOf("갑", "가", "ㄱ", "").forEach {
+ assertTrue(c.handleBackspace(e))
+ assertEquals("prefix $it", e.editable.toString())
+ }
+ assertFalse(c.isActive)
+ assertEquals(-1, BaseInputConnection.getComposingSpanStart(e.editable!!))
+ assertEquals(7, Selection.getSelectionStart(e.editable))
+ assertFalse(c.handleBackspace(e))
+ }
+
+ @Test fun replacingSelectionAndCoalescingUpdatesKeepsNewSyllableOwned() = onMain {
+ val e = Editor(); val c = coordinator()
+ e.commitText("prefix replace tail", 1)
+ e.setSelection(7, 14)
+ type(c, e, "ㄱㅏㄴㅏ")
+ assertEquals(SelectionUpdateDisposition.OWN_COMPOSING_UPDATE, c.onUpdateSelection(7,14,9,9,7,9))
+ type(c, e, "ㄴ")
+ assertEquals("prefix 가난 tail", e.editable.toString())
+ }
+
+ @Test fun lostRangeWithAndWithoutPendingWritesCannotReplayStaleRun() = onMain {
+ for (pending in listOf(false, true)) {
+ val e = Editor(); val c = coordinator()
+ type(c, e, "ㄱ")
+ assertEquals(SelectionUpdateDisposition.OWN_COMPOSING_UPDATE, acknowledge(c,e,0))
+ type(c, e, "ㅏ")
+ if (!pending) acknowledge(c,e,1)
+ e.finishComposingText()
+ assertEquals(SelectionUpdateDisposition.EXTERNAL_CHANGE, acknowledge(c,e,1))
+ assertFalse(c.isActive)
+ type(c, e, "ㄴ")
+ assertEquals("가ㄴ", e.editable.toString())
+ assertTrue(c.handleBackspace(e))
+ assertEquals("가", e.editable.toString())
+ }
+ }
+
+ @Test fun unexpectedRangeAtSameCursorReleasesOwnership() = onMain {
+ val e = Editor(); val c = coordinator()
+ type(c,e,"ㄱㅏㄴㅏ")
+ acknowledge(c,e,0)
+ e.setComposingRegion(1,2)
+ assertEquals(SelectionUpdateDisposition.EXTERNAL_CHANGE, acknowledge(c,e,2))
+ assertFalse(c.isActive)
+ }
+
+ @Test fun editorWithoutCandidateRangesCanAcknowledgeAbsoluteSelection() = onMain {
+ val e = Editor(); val c = coordinator()
+ type(c,e,"ㄱㅏㄴㅏ")
+ assertEquals(SelectionUpdateDisposition.OWN_COMPOSING_UPDATE,c.onUpdateSelection(0,0,2,2,-1,-1))
+ type(c,e,"ㄴ")
+ assertEquals("가난",e.editable.toString())
+ }
+
+ @Test fun rejectedComposingFallsBackOnEveryStrokeAndResetsInNewEditor() = onMain {
+ val e = Editor().apply { rejectComposing = true }; val c = coordinator()
+ var consumedShift = 0
+ "ㄱㅏㄴ".forEach { assertTrue(c.handleMappedStroke(e,NormalizedCompositionStroke(it)) { consumedShift++ }) }
+ assertEquals("ㄱㅏㄴ",e.editable.toString())
+ assertEquals(3,consumedShift)
+ assertFalse(c.isActive)
+ c.startEditorSession(); c.selectLayout("korean_2set")
+ val next = Editor()
+ type(c,next,"ㄱㅏ")
+ assertEquals("가",next.editable.toString())
+ assertTrue(c.isActive)
+ }
+
+ @Test fun rejectedAndThrowingWritesDoNotConsumeKeyOrShiftWhenCommitAlsoFails() = onMain {
+ for (throws in listOf(false,true)) {
+ val e = Editor().apply { rejectComposing = true; rejectCommit = true; throwComposing = throws }
+ val c = coordinator()
+ var shiftConsumed = false
+ assertFalse(c.handleMappedStroke(e,NormalizedCompositionStroke('ㄱ')) { shiftConsumed = true })
+ assertFalse(shiftConsumed)
+ assertFalse(c.isActive)
+ assertEquals("",e.editable.toString())
+ }
+ }
+
+ @Test fun failedReplacementCommitsOnlyNewStrokeAndKeepsPreviousText() = onMain {
+ val e = Editor(); val c = coordinator()
+ type(c,e,"ㄱㅏ")
+ e.rejectComposing = true
+ type(c,e,"ㄴ")
+ assertEquals("가ㄴ",e.editable.toString())
+ assertFalse(c.isActive)
+ }
+
+ @Test fun failedDeleteLeavesTextAndAllowsCallerFallback() = onMain {
+ val e = Editor(); val c = coordinator()
+ type(c,e,"ㄱ")
+ e.rejectComposing = true
+ assertFalse(c.handleBackspace(e))
+ assertFalse(c.isActive)
+ assertEquals("ㄱ",e.editable.toString())
+ }
+
+ @Test fun splitBoundariesPreserveCompoundVowelsFinalsAndCarryWithDeletion() = onMain {
+ val tails = listOf("ㄱㅗㅏ", "ㄱㅏㅂㅅ", "ㄱㅏㅂㅅㅏ", "ㄱㅗㅏㄴ", "ㄱㅏㄴㅏ")
+ for (padding in 123..128) for (tail in tails) {
+ val e = Editor(); val c = coordinator()
+ val prefix = "ㅏ".repeat(padding)
+ type(c,e,prefix+tail)
+ val reference = it.palsoftware.pastiera.core.composition.HangulComposer(maxStrokes = 1000)
+ (prefix+tail).forEach { reference.accept(NormalizedCompositionStroke(it)) }
+ assertEquals("padding=$padding tail=$tail", reference.snapshot?.renderedText,e.editable.toString())
+ repeat(tail.length) {
+ assertTrue(c.handleBackspace(e))
+ reference.backspace()
+ assertEquals(reference.snapshot?.renderedText,e.editable.toString())
+ }
+ }
+ }
+
+ @Test fun delayedAcknowledgedCallbackDoesNotInterruptNewerComposition() = onMain {
+ val e = Editor(); val c = coordinator()
+ type(c,e,"ㄱ")
+ acknowledge(c,e,0)
+ type(c,e,"ㅏㄴㅏ")
+ acknowledge(c,e,1)
+ assertEquals(SelectionUpdateDisposition.OWN_COMPOSING_UPDATE,
+ c.onUpdateSelection(0,0,1,1,0,1))
+ type(c,e,"ㄴ")
+ assertEquals("가난",e.editable.toString())
+ }
+
+ @Test fun newEditorIgnoresOldCallbacksUntilItOwnsText() = onMain {
+ val e = Editor(); val c = coordinator()
+ type(c,e,"ㄱㅏ")
+ c.startEditorSession(); c.selectLayout("korean_2set")
+ assertEquals(SelectionUpdateDisposition.NOT_TRACKED,c.onUpdateSelection(0,0,1,1,0,1))
+ assertFalse(c.isActive)
+ }
+}
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
new file mode 100644
index 000000000..1f8a222d9
--- /dev/null
+++ b/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/debug/java/it/palsoftware/pastiera/testing/ImeEditorTestActivity.kt b/app/src/debug/java/it/palsoftware/pastiera/testing/ImeEditorTestActivity.kt
new file mode 100644
index 000000000..0ffa57603
--- /dev/null
+++ b/app/src/debug/java/it/palsoftware/pastiera/testing/ImeEditorTestActivity.kt
@@ -0,0 +1,113 @@
+package it.palsoftware.pastiera.testing
+
+import android.os.Bundle
+import android.text.InputType
+import android.view.inputmethod.InputMethodManager
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.Button
+import android.widget.EditText
+import android.widget.LinearLayout
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.text.input.TextFieldValue
+
+/** Empty local editors for instrumentation; never included in release builds. */
+open class ImeEditorTestActivity : ComponentActivity() {
+ lateinit var nativeEditor: EditText
+ lateinit var webEditor: WebView
+ var composeValue by mutableStateOf(TextFieldValue())
+ var composeString by mutableStateOf("")
+ var composeFocusRequest by mutableIntStateOf(0)
+ var webReady = false
+ var kind = "native"
+
+ companion object { var current: ImeEditorTestActivity? = null }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ current = this
+ super.onCreate(savedInstanceState)
+ kind = intent.getStringExtra("editor") ?: "native"
+ when (kind) {
+ "compose", "compose_string" -> setContent {
+ val focus = remember { FocusRequester() }
+ MaterialTheme {
+ Column {
+ Text("Pastiera IME device test")
+ if (kind == "compose_string") OutlinedTextField(
+ value = composeString, onValueChange = { composeString = it },
+ keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
+ capitalization = androidx.compose.ui.text.input.KeyboardCapitalization.Sentences),
+ modifier = Modifier.fillMaxWidth().focusRequester(focus))
+ else OutlinedTextField(value = composeValue, onValueChange = { composeValue = it },
+ modifier = Modifier.fillMaxWidth().focusRequester(focus))
+ Button(onClick = { focus.requestFocus() }) { Text("Focus editor") }
+ }
+ }
+ LaunchedEffect(composeFocusRequest) {
+ if (composeFocusRequest > 0) focus.requestFocus()
+ }
+ }
+ "web" -> {
+ webEditor = WebView(this)
+ webEditor.settings.javaScriptEnabled = true
+ webEditor.webViewClient = object : WebViewClient() {
+ override fun onPageFinished(view: WebView, url: String) { webReady = true }
+ }
+ setContentView(webEditor)
+ webEditor.loadDataWithBaseURL(null,
+ "",
+ "text/html", "UTF-8", null)
+ }
+ else -> {
+ val root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
+ val button = Button(this).apply {
+ text = "Pastiera IME device test"
+ isFocusableInTouchMode = true
+ }
+ nativeEditor = EditText(this).apply {
+ inputType = intent.getIntExtra("inputType", InputType.TYPE_CLASS_TEXT)
+ hint = "Test editor"
+ }
+ root.addView(button)
+ root.addView(nativeEditor)
+ setContentView(root)
+ button.requestFocus()
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ if (current === this) current = null
+ if (kind == "web") webEditor.destroy()
+ super.onDestroy()
+ }
+
+ fun focusEditor() {
+ when (kind) {
+ "compose", "compose_string" -> composeFocusRequest++
+ "web" -> {
+ webEditor.requestFocus()
+ webEditor.evaluateJavascript("document.getElementById('editor').focus()", null)
+ getSystemService(InputMethodManager::class.java).showSoftInput(webEditor,0)
+ }
+ else -> {
+ nativeEditor.requestFocus()
+ getSystemService(InputMethodManager::class.java).showSoftInput(nativeEditor,0)
+ }
+ }
+ }
+}
+
+/** Mirrors the settings screen sharing a process with the IME. */
+class ImeLocalEditorTestActivity : ImeEditorTestActivity()
diff --git a/app/src/debug/java/it/palsoftware/pastiera/testing/ImeEditorTestProvider.kt b/app/src/debug/java/it/palsoftware/pastiera/testing/ImeEditorTestProvider.kt
new file mode 100644
index 000000000..364307750
--- /dev/null
+++ b/app/src/debug/java/it/palsoftware/pastiera/testing/ImeEditorTestProvider.kt
@@ -0,0 +1,91 @@
+package it.palsoftware.pastiera.testing
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.Intent
+import android.database.Cursor
+import android.net.Uri
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.view.inputmethod.BaseInputConnection
+import android.view.inputmethod.InputMethodManager
+import org.json.JSONTokener
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+/** Same-UID test control only. The editor uses a separate process so IME queries cannot
+ * block its UI thread (especially WebView's asynchronous InputConnection).
+ */
+open class ImeEditorTestProvider : ContentProvider() {
+ protected open val activityClass: Class = ImeEditorTestActivity::class.java
+ override fun onCreate() = true
+ override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
+ val result = Bundle().apply { putInt("pid", android.os.Process.myPid()) }
+ val done = CountDownLatch(1)
+ var failure: Throwable? = null
+ Handler(Looper.getMainLooper()).post {
+ try {
+ if (method == "launch") {
+ context!!.startActivity(Intent(context, activityClass)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ .putExtra("editor", arg ?: "native"))
+ } else {
+ val activity = ImeEditorTestActivity.current
+ result.putBoolean("ready", activity != null && activity.hasWindowFocus() &&
+ (activity.kind != "web" || activity.webReady))
+ if (activity != null) when (method) {
+ "focus" -> activity.focusEditor()
+ "close" -> activity.finish()
+ "select" -> activity.nativeEditor.setSelection(extras!!.getInt("start"), extras.getInt("end"))
+ "restartConnection" -> activity.currentFocus?.let {
+ activity.getSystemService(InputMethodManager::class.java).restartInput(it)
+ }
+ "restart" -> {
+ activity.nativeEditor.setText("")
+ activity.getSystemService(InputMethodManager::class.java).restartInput(activity.nativeEditor)
+ }
+ "read" -> when (activity.kind) {
+ "compose" -> result.putString("text", activity.composeValue.text)
+ "compose_string" -> result.putString("text", activity.composeString)
+ "web" -> {
+ activity.webEditor.evaluateJavascript("document.getElementById('editor').value") {
+ result.putString("text", JSONTokener(it).nextValue().toString())
+ done.countDown()
+ }
+ return@post
+ }
+ else -> {
+ result.putString("text", activity.nativeEditor.text.toString())
+ result.putInt("composingStart", BaseInputConnection.getComposingSpanStart(activity.nativeEditor.text))
+ }
+ }
+ }
+ }
+ } catch (error: Throwable) { failure = error }
+ done.countDown()
+ }
+ check(done.await(5, TimeUnit.SECONDS)) { "Editor command timed out: $method" }
+ failure?.let { throw it }
+ return result
+ }
+ override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? = null
+ override fun getType(uri: Uri): String? = null
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0
+ override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?) = 0
+}
+
+/** Configure in the IME process: SharedPreferences listeners do not cross processes. */
+class ImeTestSettingsProvider : ImeEditorTestProvider() {
+ override val activityClass = ImeLocalEditorTestActivity::class.java
+ override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
+ if (method != "configure") return super.call(method, arg, extras)
+ val app = context!!
+ it.palsoftware.pastiera.SettingsManager.setKeyboardLayout(app, "korean_2set")
+ it.palsoftware.pastiera.SettingsManager.setKeyboardLayoutAutoByLocale(app, false)
+ it.palsoftware.pastiera.SettingsManager.setAutoCorrectEnabled(app, false)
+ it.palsoftware.pastiera.SettingsManager.setAutoCapitalizeFirstLetter(app, false)
+ return Bundle.EMPTY
+ }
+}
diff --git a/app/src/main/assets/common/layouts/korean_2set.json b/app/src/main/assets/common/layouts/korean_2set.json
new file mode 100644
index 000000000..7f4093054
--- /dev/null
+++ b/app/src/main/assets/common/layouts/korean_2set.json
@@ -0,0 +1,19 @@
+{
+ "name": "한국어 두벌식 / Korean 2-set",
+ "description": "표준 두벌식 자판 배열입니다. 입력한 자모는 한글 음절로 조합됩니다. / Standard Korean 2-set keyboard layout. Jamo are composed into Hangul syllables as you type.",
+ "mappings": {
+ "KEYCODE_Q": { "lowercase": "ㅂ", "uppercase": "ㅃ" }, "KEYCODE_W": { "lowercase": "ㅈ", "uppercase": "ㅉ" },
+ "KEYCODE_E": { "lowercase": "ㄷ", "uppercase": "ㄸ" }, "KEYCODE_R": { "lowercase": "ㄱ", "uppercase": "ㄲ" },
+ "KEYCODE_T": { "lowercase": "ㅅ", "uppercase": "ㅆ" }, "KEYCODE_Y": { "lowercase": "ㅛ", "uppercase": "ㅛ" },
+ "KEYCODE_U": { "lowercase": "ㅕ", "uppercase": "ㅕ" }, "KEYCODE_I": { "lowercase": "ㅑ", "uppercase": "ㅑ" },
+ "KEYCODE_O": { "lowercase": "ㅐ", "uppercase": "ㅒ" }, "KEYCODE_P": { "lowercase": "ㅔ", "uppercase": "ㅖ" },
+ "KEYCODE_A": { "lowercase": "ㅁ", "uppercase": "ㅁ" }, "KEYCODE_S": { "lowercase": "ㄴ", "uppercase": "ㄴ" },
+ "KEYCODE_D": { "lowercase": "ㅇ", "uppercase": "ㅇ" }, "KEYCODE_F": { "lowercase": "ㄹ", "uppercase": "ㄹ" },
+ "KEYCODE_G": { "lowercase": "ㅎ", "uppercase": "ㅎ" }, "KEYCODE_H": { "lowercase": "ㅗ", "uppercase": "ㅗ" },
+ "KEYCODE_J": { "lowercase": "ㅓ", "uppercase": "ㅓ" }, "KEYCODE_K": { "lowercase": "ㅏ", "uppercase": "ㅏ" },
+ "KEYCODE_L": { "lowercase": "ㅣ", "uppercase": "ㅣ" }, "KEYCODE_Z": { "lowercase": "ㅋ", "uppercase": "ㅋ" },
+ "KEYCODE_X": { "lowercase": "ㅌ", "uppercase": "ㅌ" }, "KEYCODE_C": { "lowercase": "ㅊ", "uppercase": "ㅊ" },
+ "KEYCODE_V": { "lowercase": "ㅍ", "uppercase": "ㅍ" }, "KEYCODE_B": { "lowercase": "ㅠ", "uppercase": "ㅠ" },
+ "KEYCODE_N": { "lowercase": "ㅜ", "uppercase": "ㅜ" }, "KEYCODE_M": { "lowercase": "ㅡ", "uppercase": "ㅡ" }
+ }
+}
diff --git a/app/src/main/assets/common/locale_layout_mapping.json b/app/src/main/assets/common/locale_layout_mapping.json
index 3dad353ab..1228effdc 100644
--- a/app/src/main/assets/common/locale_layout_mapping.json
+++ b/app/src/main/assets/common/locale_layout_mapping.json
@@ -24,5 +24,7 @@
"vi_VN": "vietnamese_telex_qwerty",
"ru_RU": "russian_translit",
"sr_RS": "serbian_cyrillic",
- "uk_UA": "ukrainian"
+ "uk_UA": "ukrainian",
+ "ko": "korean_2set",
+ "ko_KR": "korean_2set"
}
diff --git a/app/src/main/java/it/palsoftware/pastiera/AccessibilitySettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/AccessibilitySettingsScreen.kt
index f25dafe2f..ffe595b4d 100644
--- a/app/src/main/java/it/palsoftware/pastiera/AccessibilitySettingsScreen.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/AccessibilitySettingsScreen.kt
@@ -122,7 +122,7 @@ fun AccessibilitySettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(96.dp)
+ .heightIn(min = 96.dp)
.settingRow(SettingLinkIds.ACCESSIBILITY_LIVE_READ)
) {
Row(
@@ -164,7 +164,7 @@ fun AccessibilitySettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(84.dp)
+ .heightIn(min = 84.dp)
.settingRow(SettingLinkIds.ACCESSIBILITY_READ_SECOND_ROW)
) {
Row(
@@ -206,7 +206,7 @@ fun AccessibilitySettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(84.dp)
+ .heightIn(min = 84.dp)
.settingRow(SettingLinkIds.ACCESSIBILITY_SUGGESTIONS_DELAY)
) {
Row(
@@ -284,7 +284,7 @@ fun AccessibilitySettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(96.dp)
+ .heightIn(min = 96.dp)
.settingRow(SettingLinkIds.ACCESSIBILITY_BOUNCE_KEYS)
) {
Row(
@@ -326,7 +326,7 @@ fun AccessibilitySettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(116.dp)
+ .heightIn(min = 116.dp)
.settingRow("accessibility.bounce_keys_delay")
) {
Column(
@@ -485,7 +485,7 @@ private fun BounceKeyToggleRow(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(76.dp)
+ .heightIn(min = 76.dp)
.settingRow(linkId)
) {
Row(
diff --git a/app/src/main/java/it/palsoftware/pastiera/AdvancedSettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/AdvancedSettingsScreen.kt
index bbd803357..0ed3055ef 100644
--- a/app/src/main/java/it/palsoftware/pastiera/AdvancedSettingsScreen.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/AdvancedSettingsScreen.kt
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
@@ -395,7 +396,7 @@ fun AdvancedSettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
.settingRow(SettingLinkIds.ADVANCED_BACKUP) {
backupLauncher.launch(defaultBackupName())
}
@@ -439,7 +440,7 @@ fun AdvancedSettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
.settingRow(SettingLinkIds.ADVANCED_RESTORE) {
restoreLauncher.launch(arrayOf("application/zip"))
}
@@ -483,7 +484,7 @@ fun AdvancedSettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
.settingRow(SettingLinkIds.ADVANCED_SWIPE_INCREMENTAL_THRESHOLD)
) {
Row(
@@ -575,7 +576,7 @@ fun AdvancedSettingsScreen(
val filtered = text.filter { it.isDigit() }.take(5)
clipboardRetentionTime = filtered
},
- placeholder = { Text("min") },
+ placeholder = { Text(stringResource(R.string.advanced_minutes_short)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.widthIn(max = 120.dp),
singleLine = true
@@ -655,7 +656,7 @@ fun AdvancedSettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
.clickable { navigateTo(AdvancedDestination.ImeTest) }
) {
Row(
@@ -673,13 +674,13 @@ fun AdvancedSettingsScreen(
)
Column(modifier = Modifier.weight(1f)) {
Text(
- text = "IME Test Screen",
+ text = stringResource(R.string.advanced_ime_test_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
maxLines = 1
)
Text(
- text = "Test all input field types and IME actions",
+ text = stringResource(R.string.advanced_ime_test_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
@@ -698,7 +699,7 @@ fun AdvancedSettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
.settingRow(SettingLinkIds.ADVANCED_SHOW_TUTORIAL) {
SettingsManager.resetTutorialCompleted(context)
val intent = Intent(context, TutorialActivity::class.java)
@@ -743,7 +744,7 @@ fun AdvancedSettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
.settingRow(SettingLinkIds.ADVANCED_SHOW_RELEASE_NOTES_TUTORIAL) {
val intent = Intent(context, TutorialActivity::class.java).apply {
putExtra(TutorialActivity.EXTRA_UPDATE_TUTORIAL, true)
diff --git a/app/src/main/java/it/palsoftware/pastiera/AutoCorrectSettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/AutoCorrectSettingsScreen.kt
index 25d749f3e..ae5979da5 100644
--- a/app/src/main/java/it/palsoftware/pastiera/AutoCorrectSettingsScreen.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/AutoCorrectSettingsScreen.kt
@@ -80,7 +80,7 @@ private fun LanguageItem(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
.clickable { onEdit() }
) {
Row(
diff --git a/app/src/main/java/it/palsoftware/pastiera/AutoCorrectionCategoryScreen.kt b/app/src/main/java/it/palsoftware/pastiera/AutoCorrectionCategoryScreen.kt
index c04ad3ac5..8875d7f7e 100644
--- a/app/src/main/java/it/palsoftware/pastiera/AutoCorrectionCategoryScreen.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/AutoCorrectionCategoryScreen.kt
@@ -125,7 +125,7 @@ fun AutoCorrectionCategoryScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(80.dp)
+ .heightIn(min = 80.dp)
.settingRow(SettingLinkIds.AUTO_CORRECTION_TEXT_REPLACEMENTS)
) {
Row(
@@ -170,7 +170,7 @@ fun AutoCorrectionCategoryScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(80.dp)
+ .heightIn(min = 80.dp)
.settingRow(SettingLinkIds.AUTO_CORRECTION_LANGUAGES) {
navigateTo(AutoCorrectionDestination.Settings)
}
@@ -215,7 +215,7 @@ fun AutoCorrectionCategoryScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(80.dp)
+ .heightIn(min = 80.dp)
.settingRow(SettingLinkIds.AUTO_CORRECTION_AUTO_REPLACE)
) {
Row(
@@ -314,7 +314,7 @@ fun AutoCorrectionCategoryScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(80.dp)
+ .heightIn(min = 80.dp)
.settingRow(SettingLinkIds.AUTO_CORRECTION_USER_DICTIONARY) {
navigateTo(AutoCorrectionDestination.UserDictionary)
}
@@ -358,7 +358,7 @@ fun AutoCorrectionCategoryScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(80.dp)
+ .heightIn(min = 80.dp)
.settingRow(SettingLinkIds.AUTO_CORRECTION_EXPERIMENTAL_SUGGESTIONS)
) {
Row(
@@ -482,13 +482,13 @@ fun AutoCorrectionCategoryScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
.settingRow(SettingLinkIds.AUTO_CORRECTION_KEYBOARD_PROXIMITY)
) {
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 16.dp),
+ .padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
@@ -526,7 +526,7 @@ fun AutoCorrectionCategoryScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
.settingRow(SettingLinkIds.AUTO_CORRECTION_EDIT_TYPE_RANKING)
) {
Row(
diff --git a/app/src/main/java/it/palsoftware/pastiera/CustomInputStylesScreen.kt b/app/src/main/java/it/palsoftware/pastiera/CustomInputStylesScreen.kt
index e528669f7..21c879013 100644
--- a/app/src/main/java/it/palsoftware/pastiera/CustomInputStylesScreen.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/CustomInputStylesScreen.kt
@@ -269,6 +269,7 @@ fun CustomInputStylesScreen(
layout,
additionalSuggestionLocales
)
+ AdditionalSubtypeUtils.registerAdditionalSubtypes(context.applicationContext)
inputStyles = loadCustomInputStyles(context)
showAddDialog = false
editStyle = null
@@ -276,7 +277,7 @@ fun CustomInputStylesScreen(
lastDialogLayout = null
lastDialogSuggestionLocales = null
coroutineScope.launch {
- snackbarHostState.showSnackbar(context.getString(R.string.custom_input_styles_layout_mapping_updated, getLocaleDisplayName(locale), layout))
+ snackbarHostState.showSnackbar(context.getString(R.string.custom_input_styles_layout_mapping_updated, getLocaleDisplayName(context, locale), layout))
}
null
} else {
@@ -305,9 +306,9 @@ fun CustomInputStylesScreen(
lastDialogLayout = null
lastDialogSuggestionLocales = null
val msg = if (targetOld != null) {
- context.getString(R.string.custom_input_styles_input_style_updated, getLocaleDisplayName(locale), layout)
+ context.getString(R.string.custom_input_styles_input_style_updated, getLocaleDisplayName(context, locale), layout)
} else {
- context.getString(R.string.custom_input_styles_input_style_added, getLocaleDisplayName(locale), layout)
+ context.getString(R.string.custom_input_styles_input_style_added, getLocaleDisplayName(context, locale), layout)
}
coroutineScope.launch {
snackbarHostState.showSnackbar(msg)
@@ -540,6 +541,7 @@ internal fun AppLanguageSelectorCard() {
"pl",
"ru",
"uk",
+ "ko",
"vi",
"hy"
)
@@ -747,10 +749,14 @@ private fun AddCustomInputStyleDialog(
}
}
}
-
- // Get available locales based on dictionary availability (no filtering of system locales)
- val availableLocales = remember {
- getLocalesWithDictionary(context).sorted()
+
+ val dictionaryLocales = remember(context) { getLocalesWithDictionary(context).sorted() }
+ val availableLocales = remember(dictionaryLocales) {
+ getAvailableInputLocales(dictionaryLocales)
+ }
+ val availableSuggestionLocales = selectedSuggestionLocales.filter { selected ->
+ dictionaryLocales.any { Locale.forLanguageTag(it.replace('_', '-')).language ==
+ Locale.forLanguageTag(selected.replace('_', '-')).language }
}
AlertDialog(
@@ -830,7 +836,7 @@ private fun AddCustomInputStyleDialog(
) {
availableLocales.forEach { locale ->
DropdownMenuItem(
- text = { Text(stringResource(R.string.custom_input_styles_locale_display, locale, getLocaleDisplayName(locale))) },
+ text = { Text(stringResource(R.string.custom_input_styles_locale_display, locale, getLocaleDisplayName(context, locale))) },
onClick = {
selectedLocale = locale
expandedLocale = false
@@ -942,8 +948,8 @@ private fun AddCustomInputStyleDialog(
SuggestionDictionariesSelector(
primaryLocale = selectedLocale!!,
- availableLocales = availableLocales,
- selectedLocales = selectedSuggestionLocales,
+ availableLocales = dictionaryLocales,
+ selectedLocales = availableSuggestionLocales,
onSelectedLocalesChanged = { selectedSuggestionLocales = it }
)
}
@@ -956,7 +962,7 @@ private fun AddCustomInputStyleDialog(
val locale = selectedLocale
val layout = selectedLayout
if (locale != null && layout != null) {
- saveError = onSave(locale, layout, selectedSuggestionLocales)
+ saveError = onSave(locale, layout, availableSuggestionLocales)
}
},
enabled = selectedLocale != null && selectedLayout != null
@@ -1053,6 +1059,7 @@ private fun SuggestionDictionariesSelector(
selectedLocales: List,
onSelectedLocalesChanged: (List) -> Unit
) {
+ val context = LocalContext.current
val primaryLanguage = normalizeLocaleTag(primaryLocale).substringBefore('-')
val candidates = availableLocales
.map { normalizeLocaleTag(it) }
@@ -1071,7 +1078,7 @@ private fun SuggestionDictionariesSelector(
Text(
text = stringResource(
R.string.custom_input_styles_primary_suggestion_dictionary,
- getLocaleDisplayName(primaryLocale)
+ getLocaleDisplayName(context, primaryLocale)
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -1098,7 +1105,7 @@ private fun SuggestionDictionariesSelector(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
- text = getLocaleDisplayName(locale),
+ text = getLocaleDisplayName(context, locale),
style = MaterialTheme.typography.bodyLarge
)
Text(
@@ -1175,7 +1182,7 @@ private fun loadCustomInputStyles(context: Context): List {
systemLocales.forEach { locale ->
val layout = AdditionalSubtypeUtils.getLayoutForLocale(context.assets, locale, context)
val isHidden = SettingsManager.isSystemInputStyleHidden(context, locale, layout)
- val displayName = "${getLocaleDisplayName(locale)} - $layout"
+ val displayName = "${getLocaleDisplayName(context, locale)} - $layout"
styles.add(
CustomInputStyle(
locale,
@@ -1198,7 +1205,7 @@ private fun loadCustomInputStyles(context: Context): List {
if (parts.size >= 2) {
val locale = parts[0]
val layout = parts[1]
- val displayName = "${getLocaleDisplayName(locale)} - $layout"
+ val displayName = "${getLocaleDisplayName(context, locale)} - $layout"
styles.add(
CustomInputStyle(
locale,
@@ -1425,10 +1432,10 @@ private fun removeCustomInputStyle(context: Context, locale: String, layout: Str
/**
* Gets display name for a locale.
*/
-private fun getLocaleDisplayName(locale: String): String {
+internal fun getLocaleDisplayName(context: Context, locale: String): String {
return try {
val localeObj = Locale.forLanguageTag(locale.replace('_', '-'))
- localeObj.getDisplayName(Locale.getDefault())
+ localeObj.getDisplayName(context.resources.configuration.locales[0])
} catch (e: Exception) {
locale
}
@@ -1438,9 +1445,17 @@ private fun normalizeLocaleTag(locale: String): String {
return locale.trim().replace('_', '-')
}
+// Match dictionary-backed picker entries: language codes, without country variants.
+// Saved and system locales retain their country to preserve settings and subtype IDs.
+private val BUILTIN_LOCALES_WITHOUT_DICTIONARY = setOf("ko")
+
+internal fun getAvailableInputLocales(dictionaryLocales: List): List =
+ (dictionaryLocales + BUILTIN_LOCALES_WITHOUT_DICTIONARY).distinct().sorted()
+
/**
* Gets list of locales that have dictionary files available.
- * Uses serialized (.dict) from assets and custom/imported folder.
+ * Uses serialized (.dict) from assets and the custom/imported folder only.
+ * Input languages without dictionaries are added separately by the style selector.
*/
private fun getLocalesWithDictionary(context: Context): List {
val localesWithDict = mutableSetOf()
@@ -1513,8 +1528,8 @@ private fun isValidLocaleCode(localeCode: String): Boolean {
private fun hasDictionaryForLocale(context: Context, locale: String): Boolean {
try {
val assets = context.assets
- val langCode = locale.split("_")[0].lowercase()
-
+ val langCode = Locale.forLanguageTag(normalizeLocaleTag(locale)).language
+
// Check serialized dictionaries from assets
try {
val serializedFiles = assets.list("common/dictionaries_serialized")
@@ -1589,8 +1604,11 @@ private fun updateLocaleLayoutMapping(context: Context, locale: String, layout:
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
val currentSubtype = imm?.currentInputMethodSubtype
val currentLocale = currentSubtype?.localeString()
-
- if (currentLocale == locale && SettingsManager.isKeyboardLayoutAutoByLocale(context)) {
+
+ if (currentLocale != null &&
+ normalizeLocaleTag(currentLocale).equals(normalizeLocaleTag(locale), ignoreCase = true) &&
+ SettingsManager.isKeyboardLayoutAutoByLocale(context)
+ ) {
// The locale being updated is currently active, immediately apply the layout change
android.util.Log.d("CustomInputStyles", "Locale $locale is currently active, applying layout change immediately")
SettingsManager.notifyKeyboardLayoutAutoMappingUpdated(context)
diff --git a/app/src/main/java/it/palsoftware/pastiera/CustomizationSettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/CustomizationSettingsScreen.kt
index cc9268195..4c81bbf6e 100644
--- a/app/src/main/java/it/palsoftware/pastiera/CustomizationSettingsScreen.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/CustomizationSettingsScreen.kt
@@ -324,7 +324,7 @@ fun CustomizationSettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
.settingRow("customization.sounds") { navigateTo(CustomizationDestination.Sounds) }
) {
Row(
@@ -968,13 +968,6 @@ private fun QuickLauncherDisplayedEntriesSection(
) {
val context = LocalContext.current
var showCustomizeDialog by remember { mutableStateOf(false) }
- val sourceLabels = mapOf(
- CommandSourceId.Apps.storageValue to "Apps",
- CommandSourceId.Pastiera.storageValue to "Pastiera actions",
- CommandSourceId.AppActions.storageValue to "App actions",
- CommandSourceId.DeviceControl.storageValue to "Device control",
- CommandSourceId.NavActions.storageValue to "Navigation actions"
- )
fun update(sourceId: String, quickLauncher: Boolean) {
onVisibilityChanged(
@@ -1010,12 +1003,12 @@ private fun QuickLauncherDisplayedEntriesSection(
)
Column(modifier = Modifier.weight(1f)) {
Text(
- text = stringResource(R.string.setting_link_quick_launcher_entries),
+ text = stringResource(R.string.quick_launcher_entries_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Text(
- text = "Choose which sources appear in Pastiera search.",
+ text = stringResource(R.string.quick_launcher_entries_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -1038,7 +1031,9 @@ private fun QuickLauncherDisplayedEntriesSection(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
- text = sourceLabels[item.sourceId] ?: item.sourceId,
+ text = CommandSourceId.fromStorageValue(item.sourceId)
+ ?.localizedDisplayLabel(context)
+ ?: item.sourceId,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium
)
@@ -1058,7 +1053,7 @@ private fun QuickLauncherDisplayedEntriesSection(
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(6.dp))
- Text("Customize entries")
+ Text(stringResource(R.string.quick_launcher_customize_entries))
}
}
}
@@ -1088,6 +1083,7 @@ private fun QuickLauncherCommandCustomizationDialog(
onCustomizationChanged: (SettingsManager.QuickLauncherCommandCustomization) -> Unit,
onDismiss: () -> Unit
) {
+ val context = LocalContext.current
var editingCommandId by remember { mutableStateOf(null) }
var query by remember { mutableStateOf("") }
var selectedSource by remember { mutableStateOf(null) }
@@ -1105,7 +1101,7 @@ private fun QuickLauncherCommandCustomizationDialog(
sourceMatches.filter { command ->
command.label.contains(normalizedQuery, ignoreCase = true) ||
command.subtitle?.contains(normalizedQuery, ignoreCase = true) == true ||
- command.source.displayLabel.contains(normalizedQuery, ignoreCase = true) ||
+ command.source.localizedDisplayLabel(context).contains(normalizedQuery, ignoreCase = true) ||
command.searchTokens.any { it.contains(normalizedQuery, ignoreCase = true) }
}
}
@@ -1123,7 +1119,7 @@ private fun QuickLauncherCommandCustomizationDialog(
filteredCommands
.groupBy { it.source }
.flatMap { (source, sourceCommands) ->
- listOf(CommandCustomizationEntry.Header(source.displayLabel)) +
+ listOf(CommandCustomizationEntry.Header(source.localizedDisplayLabel(context))) +
sourceCommands.map { CommandCustomizationEntry.Command(it) }
}
} else {
@@ -1155,12 +1151,12 @@ private fun QuickLauncherCommandCustomizationDialog(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
- text = "Customize entries",
+ text = stringResource(R.string.quick_launcher_customize_entries),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Text(
- text = "Favorites, hidden entries, and search aliases",
+ text = stringResource(R.string.quick_launcher_customize_entries_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -1175,7 +1171,7 @@ private fun QuickLauncherCommandCustomizationDialog(
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = MaterialTheme.shapes.extraLarge,
- placeholder = { Text("Search entries") },
+ placeholder = { Text(stringResource(R.string.quick_launcher_search_entries)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
@@ -1198,12 +1194,12 @@ private fun QuickLauncherCommandCustomizationDialog(
FilterChip(
selected = selectedSource == null,
onClick = { selectedSource = null },
- label = { Text("All") }
+ label = { Text(stringResource(R.string.quick_launcher_all)) }
)
FilterChip(
selected = favoritesOnly,
onClick = { favoritesOnly = !favoritesOnly },
- label = { Text("Favorites") },
+ label = { Text(stringResource(R.string.quick_launcher_favorites)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Star,
@@ -1216,7 +1212,7 @@ private fun QuickLauncherCommandCustomizationDialog(
FilterChip(
selected = selectedSource == source,
onClick = { selectedSource = source },
- label = { Text(source.displayLabel) }
+ label = { Text(source.localizedDisplayLabel(context)) }
)
}
}
@@ -1227,7 +1223,7 @@ private fun QuickLauncherCommandCustomizationDialog(
.weight(1f),
contentAlignment = Alignment.Center
) {
- Text("No commands available for the selected sources.")
+ Text(stringResource(R.string.quick_launcher_no_commands))
}
} else if (entries.isEmpty()) {
Box(
@@ -1236,7 +1232,7 @@ private fun QuickLauncherCommandCustomizationDialog(
.weight(1f),
contentAlignment = Alignment.Center
) {
- Text("No entries match \"$query\".")
+ Text(stringResource(R.string.quick_launcher_no_entries_match, query))
}
} else {
LazyVerticalGrid(
@@ -1402,7 +1398,7 @@ private fun CommandCustomizationRow(
fontWeight = FontWeight.Medium
)
Text(
- text = command.subtitle ?: command.source.displayLabel,
+ text = command.subtitle ?: command.source.localizedDisplayLabel(LocalContext.current),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -1459,7 +1455,7 @@ private fun CommandCustomizationRow(
onValueChange = onCustomSearchChanged,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
- label = { Text("Search alias") }
+ label = { Text(stringResource(R.string.quick_launcher_search_alias)) }
)
}
}
@@ -1576,7 +1572,7 @@ private fun QuickLauncherFavoriteAppearanceSection(
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
- text = "Entry appearance",
+ text = stringResource(R.string.quick_launcher_entry_appearance),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
@@ -1589,7 +1585,7 @@ private fun QuickLauncherFavoriteAppearanceSection(
it == SettingsManager.QUICK_LAUNCHER_DYNAMIC_FAVORITE_COLOR
}
Text(
- text = stringResource(R.string.setting_link_quick_launcher_highlight_favorites),
+ text = stringResource(R.string.quick_launcher_highlight_favorites),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium
)
@@ -1614,7 +1610,7 @@ private fun QuickLauncherFavoriteAppearanceSection(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
- text = stringResource(R.string.setting_link_quick_launcher_icon_colors),
+ text = stringResource(R.string.quick_launcher_tint_app_icon_colors),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium
)
@@ -1629,7 +1625,7 @@ private fun QuickLauncherFavoriteAppearanceSection(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
- text = stringResource(R.string.setting_link_quick_launcher_static_top_highlight),
+ text = stringResource(R.string.quick_launcher_static_top_highlight),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium
)
@@ -1650,7 +1646,7 @@ private fun QuickLauncherFavoriteAppearanceSection(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
- text = stringResource(R.string.setting_link_quick_launcher_alias_first),
+ text = stringResource(R.string.quick_launcher_show_alias_first),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium
)
@@ -1709,7 +1705,7 @@ private fun ColorSwatchButton(
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
DynamicColorWheelSwatch()
- Text("Dynamic")
+ Text(stringResource(R.string.quick_launcher_dynamic))
}
},
onClick = {
@@ -2013,11 +2009,12 @@ private fun SettingsRowKeyboardIcon() {
)
}
+@Composable
private fun keyLabel(keyCode: Int): String {
return when (keyCode) {
- KeyEvent.KEYCODE_SPACE -> "Space"
- KeyEvent.KEYCODE_ENTER -> "Enter"
- KeyEvent.KEYCODE_DEL -> "Backspace"
+ KeyEvent.KEYCODE_SPACE -> stringResource(R.string.software_keyboard_accessibility_space)
+ KeyEvent.KEYCODE_ENTER -> stringResource(R.string.software_keyboard_accessibility_enter)
+ KeyEvent.KEYCODE_DEL -> stringResource(R.string.software_keyboard_accessibility_backspace)
KeyEvent.KEYCODE_A -> "A"
KeyEvent.KEYCODE_B -> "B"
KeyEvent.KEYCODE_C -> "C"
diff --git a/app/src/main/java/it/palsoftware/pastiera/ImeTestScreen.kt b/app/src/main/java/it/palsoftware/pastiera/ImeTestScreen.kt
index bed0cd4b6..b1e91edb4 100644
--- a/app/src/main/java/it/palsoftware/pastiera/ImeTestScreen.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/ImeTestScreen.kt
@@ -10,7 +10,6 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@@ -36,8 +35,6 @@ fun ImeTestScreen(
modifier: Modifier = Modifier,
onBack: () -> Unit
) {
- val context = LocalContext.current
-
// State for all text fields
var textPlain by remember { mutableStateOf(TextFieldValue("")) }
var textCapCharacters by remember { mutableStateOf(TextFieldValue("")) }
@@ -100,7 +97,7 @@ fun ImeTestScreen(
modifier = Modifier.padding(start = 8.dp, end = 12.dp)
)
Text(
- text = "IME Test Screen",
+ text = stringResource(R.string.advanced_ime_test_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold
)
@@ -118,14 +115,14 @@ fun ImeTestScreen(
) {
// Text Input Types Section
Text(
- text = "Text Input Types",
+ text = stringResource(R.string.ime_test_text_input_types),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
InputField(
- label = "text (Plain Text)",
+ label = stringResource(R.string.ime_test_text_plain),
value = textPlain,
onValueChange = { textPlain = it },
keyboardType = KeyboardType.Text,
@@ -133,28 +130,28 @@ fun ImeTestScreen(
)
InputFieldWithInputType(
- label = "textCapCharacters (All Caps)",
+ label = stringResource(R.string.ime_test_text_cap_characters),
value = textCapCharacters,
onValueChange = { textCapCharacters = it },
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
)
InputFieldWithInputType(
- label = "textCapWords (Title Case)",
+ label = stringResource(R.string.ime_test_text_cap_words),
value = textCapWords,
onValueChange = { textCapWords = it },
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_WORDS
)
InputFieldWithInputType(
- label = "textCapSentences (Sentence Case)",
+ label = stringResource(R.string.ime_test_text_cap_sentences),
value = textCapSentences,
onValueChange = { textCapSentences = it },
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
)
InputField(
- label = "textEmailAddress",
+ label = stringResource(R.string.ime_test_text_email_address),
value = textEmail,
onValueChange = { textEmail = it },
keyboardType = KeyboardType.Email,
@@ -162,7 +159,7 @@ fun ImeTestScreen(
)
InputField(
- label = "textPassword",
+ label = stringResource(R.string.ime_test_text_password),
value = textPassword,
onValueChange = { textPassword = it },
keyboardType = KeyboardType.Password,
@@ -171,7 +168,7 @@ fun ImeTestScreen(
)
InputField(
- label = "textVisiblePassword",
+ label = stringResource(R.string.ime_test_text_visible_password),
value = textVisiblePassword,
onValueChange = { textVisiblePassword = it },
keyboardType = KeyboardType.Password,
@@ -179,7 +176,7 @@ fun ImeTestScreen(
)
InputField(
- label = "textWebPassword",
+ label = stringResource(R.string.ime_test_text_web_password),
value = textWebPassword,
onValueChange = { textWebPassword = it },
keyboardType = KeyboardType.Password,
@@ -188,7 +185,7 @@ fun ImeTestScreen(
)
InputField(
- label = "textUri",
+ label = stringResource(R.string.ime_test_text_uri),
value = textUri,
onValueChange = { textUri = it },
keyboardType = KeyboardType.Uri,
@@ -196,7 +193,7 @@ fun ImeTestScreen(
)
InputField(
- label = "textPersonName",
+ label = stringResource(R.string.ime_test_text_person_name),
value = textPersonName,
onValueChange = { textPersonName = it },
keyboardType = KeyboardType.Text,
@@ -204,7 +201,7 @@ fun ImeTestScreen(
)
InputField(
- label = "textPostalAddress",
+ label = stringResource(R.string.ime_test_text_postal_address),
value = textPostalAddress,
onValueChange = { textPostalAddress = it },
keyboardType = KeyboardType.Text,
@@ -212,7 +209,7 @@ fun ImeTestScreen(
)
InputField(
- label = "textMultiLine",
+ label = stringResource(R.string.ime_test_text_multiline),
value = textMultiLine,
onValueChange = { textMultiLine = it },
keyboardType = KeyboardType.Text,
@@ -221,7 +218,7 @@ fun ImeTestScreen(
)
InputField(
- label = "textNoSuggestions",
+ label = stringResource(R.string.ime_test_text_no_suggestions),
value = textNoSuggestions,
onValueChange = { textNoSuggestions = it },
keyboardType = KeyboardType.Text,
@@ -229,7 +226,7 @@ fun ImeTestScreen(
)
InputField(
- label = "textAutoComplete",
+ label = stringResource(R.string.ime_test_text_autocomplete),
value = textAutoComplete,
onValueChange = { textAutoComplete = it },
keyboardType = KeyboardType.Text,
@@ -237,7 +234,7 @@ fun ImeTestScreen(
)
InputField(
- label = "textAutoCorrect",
+ label = stringResource(R.string.ime_test_text_autocorrect),
value = textAutoCorrect,
onValueChange = { textAutoCorrect = it },
keyboardType = KeyboardType.Text,
@@ -248,14 +245,14 @@ fun ImeTestScreen(
// Numeric Input Types Section
Text(
- text = "Numeric Input Types",
+ text = stringResource(R.string.ime_test_numeric_input_types),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
InputField(
- label = "number",
+ label = stringResource(R.string.ime_test_number),
value = number,
onValueChange = { number = it },
keyboardType = KeyboardType.Number,
@@ -263,7 +260,7 @@ fun ImeTestScreen(
)
InputField(
- label = "numberSigned",
+ label = stringResource(R.string.ime_test_number_signed),
value = numberSigned,
onValueChange = { numberSigned = it },
keyboardType = KeyboardType.Number,
@@ -271,7 +268,7 @@ fun ImeTestScreen(
)
InputField(
- label = "numberDecimal",
+ label = stringResource(R.string.ime_test_number_decimal),
value = numberDecimal,
onValueChange = { numberDecimal = it },
keyboardType = KeyboardType.Decimal,
@@ -282,14 +279,14 @@ fun ImeTestScreen(
// Other Input Types Section
Text(
- text = "Other Input Types",
+ text = stringResource(R.string.ime_test_other_input_types),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
InputField(
- label = "phone",
+ label = stringResource(R.string.ime_test_phone),
value = phone,
onValueChange = { phone = it },
keyboardType = KeyboardType.Phone,
@@ -297,7 +294,7 @@ fun ImeTestScreen(
)
InputField(
- label = "datetime",
+ label = stringResource(R.string.ime_test_datetime),
value = datetime,
onValueChange = { datetime = it },
keyboardType = KeyboardType.Text,
@@ -305,7 +302,7 @@ fun ImeTestScreen(
)
InputField(
- label = "date",
+ label = stringResource(R.string.ime_test_date),
value = date,
onValueChange = { date = it },
keyboardType = KeyboardType.Text,
@@ -313,7 +310,7 @@ fun ImeTestScreen(
)
InputField(
- label = "time",
+ label = stringResource(R.string.ime_test_time),
value = time,
onValueChange = { time = it },
keyboardType = KeyboardType.Text,
@@ -324,14 +321,14 @@ fun ImeTestScreen(
// IME Actions Section
Text(
- text = "IME Actions",
+ text = stringResource(R.string.ime_test_actions),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
InputField(
- label = "actionNone",
+ label = stringResource(R.string.ime_test_action_none),
value = actionNone,
onValueChange = { actionNone = it },
keyboardType = KeyboardType.Text,
@@ -340,7 +337,7 @@ fun ImeTestScreen(
)
InputField(
- label = "actionGo",
+ label = stringResource(R.string.ime_test_action_go),
value = actionGo,
onValueChange = { actionGo = it },
keyboardType = KeyboardType.Text,
@@ -352,7 +349,7 @@ fun ImeTestScreen(
)
InputField(
- label = "actionSearch",
+ label = stringResource(R.string.ime_test_action_search),
value = actionSearch,
onValueChange = { actionSearch = it },
keyboardType = KeyboardType.Text,
@@ -364,7 +361,7 @@ fun ImeTestScreen(
)
InputField(
- label = "actionSend",
+ label = stringResource(R.string.ime_test_action_send),
value = actionSend,
onValueChange = { actionSend = it },
keyboardType = KeyboardType.Text,
@@ -376,7 +373,7 @@ fun ImeTestScreen(
)
InputField(
- label = "actionNext",
+ label = stringResource(R.string.ime_test_action_next),
value = actionNext,
onValueChange = { actionNext = it },
keyboardType = KeyboardType.Text,
@@ -388,7 +385,7 @@ fun ImeTestScreen(
)
InputField(
- label = "actionDone",
+ label = stringResource(R.string.ime_test_action_done),
value = actionDone,
onValueChange = { actionDone = it },
keyboardType = KeyboardType.Text,
@@ -400,7 +397,7 @@ fun ImeTestScreen(
)
InputField(
- label = "actionPrevious",
+ label = stringResource(R.string.ime_test_action_previous),
value = actionPrevious,
onValueChange = { actionPrevious = it },
keyboardType = KeyboardType.Text,
@@ -412,7 +409,7 @@ fun ImeTestScreen(
)
InputField(
- label = "actionUnspecified (Default)",
+ label = stringResource(R.string.ime_test_action_unspecified),
value = actionUnspecified,
onValueChange = { actionUnspecified = it },
keyboardType = KeyboardType.Text,
@@ -547,4 +544,3 @@ private fun InputFieldWithInputType(
}
}
}
-
diff --git a/app/src/main/java/it/palsoftware/pastiera/KeyboardLayoutSettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/KeyboardLayoutSettingsScreen.kt
index b3a056c80..b7feb0ee2 100644
--- a/app/src/main/java/it/palsoftware/pastiera/KeyboardLayoutSettingsScreen.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/KeyboardLayoutSettingsScreen.kt
@@ -37,7 +37,6 @@ import it.palsoftware.pastiera.layout.OnlineLayoutsActivity
import it.palsoftware.pastiera.inputmethod.subtype.AdditionalSubtypeUtils
import it.palsoftware.pastiera.R
import kotlinx.coroutines.launch
-import java.util.Locale
import android.content.res.AssetManager
import org.json.JSONObject
import java.nio.charset.StandardCharsets
@@ -205,7 +204,7 @@ fun KeyboardLayoutSettingsScreen(
)
}
Text(
- text = "${stringResource(R.string.keyboard_layout_title)} - ${getLocaleDisplayNameForTitle(locale)}",
+ text = "${stringResource(R.string.keyboard_layout_title)} - ${getLocaleDisplayName(context, locale)}",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
@@ -312,7 +311,7 @@ fun KeyboardLayoutSettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(72.dp)
+ .heightIn(min = 72.dp)
.clickable {
selectedLayout = "qwerty"
}
@@ -615,15 +614,3 @@ private fun hasLayoutMultiTap(assets: AssetManager, context: Context, layoutName
false
}
}
-
-/**
- * Gets display name for a locale (for title display).
- */
-private fun getLocaleDisplayNameForTitle(locale: String): String {
- return try {
- val localeObj = Locale.forLanguageTag(locale.replace('_', '-'))
- localeObj.getDisplayName(Locale.ENGLISH)
- } catch (e: Exception) {
- locale
- }
-}
diff --git a/app/src/main/java/it/palsoftware/pastiera/KeyboardThemeSettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/KeyboardThemeSettingsScreen.kt
index 6a49a92c7..52babe8e5 100644
--- a/app/src/main/java/it/palsoftware/pastiera/KeyboardThemeSettingsScreen.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/KeyboardThemeSettingsScreen.kt
@@ -644,7 +644,7 @@ fun KeyboardThemeScreen(
}
if (previewPagerState.currentPage == 1) {
KeyboardThemeSliderRow(
- label = stringResource(R.string.setting_link_theme_preview_viewport),
+ label = stringResource(R.string.keyboard_theme_preview_max_viewport),
linkId = "keyboard_theme.preview_viewport",
value = softwarePreviewViewportScale,
presetValue = SettingsManager.KEYBOARD_THEME_PREVIEW_VIEWPORT_SCALE_MIN,
@@ -889,8 +889,8 @@ fun KeyboardThemeScreen(
deleteThemeRequest?.let { name ->
AlertDialog(
onDismissRequest = { deleteThemeRequest = null },
- title = { Text("Delete theme?") },
- text = { Text("Delete ‘$name’? The currently applied colors will be kept.") },
+ title = { Text(stringResource(R.string.keyboard_theme_delete_title)) },
+ text = { Text(stringResource(R.string.keyboard_theme_delete_confirmation, name)) },
confirmButton = {
TextButton(
onClick = {
@@ -898,12 +898,12 @@ fun KeyboardThemeScreen(
deleteSavedTheme(name)
}
) {
- Text("Delete", color = MaterialTheme.colorScheme.error)
+ Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { deleteThemeRequest = null }) {
- Text("Cancel")
+ Text(stringResource(R.string.cancel))
}
}
)
@@ -1814,7 +1814,7 @@ private fun KeyboardThemeExportDialog(
}
AlertDialog(
onDismissRequest = onDismiss,
- title = { Text("Export theme") },
+ title = { Text(stringResource(R.string.keyboard_theme_export_title)) },
text = {
OutlinedTextField(
value = exportString,
@@ -1832,12 +1832,12 @@ private fun KeyboardThemeExportDialog(
onDismiss()
}
) {
- Text("Copy")
+ Text(stringResource(R.string.debug_recorder_copy))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
- Text("Close")
+ Text(stringResource(R.string.close))
}
}
)
@@ -2188,7 +2188,7 @@ private fun KeyboardThemeColorsEditor(
)
add(
KeyboardThemeColorEditorItem(
- label = "Suggestions",
+ label = stringResource(R.string.keyboard_theme_suggestions),
color = theme.suggestion,
presetColor = preset.suggestion,
onColorChanged = { onThemeChanged(theme.copy(suggestion = it)) }
@@ -2196,7 +2196,7 @@ private fun KeyboardThemeColorsEditor(
)
add(
KeyboardThemeColorEditorItem(
- label = "Status bar buttons",
+ label = stringResource(R.string.keyboard_theme_status_bar_buttons),
color = theme.statusBarButton,
presetColor = preset.statusBarButton,
onColorChanged = { onThemeChanged(theme.copy(statusBarButton = it)) }
@@ -2204,7 +2204,7 @@ private fun KeyboardThemeColorsEditor(
)
add(
KeyboardThemeColorEditorItem(
- label = "Cursor swipe",
+ label = stringResource(R.string.keyboard_theme_cursor_swipe),
color = theme.cursorSwipe,
presetColor = preset.cursorSwipe,
onColorChanged = { onThemeChanged(theme.copy(cursorSwipe = it)) }
@@ -2213,7 +2213,7 @@ private fun KeyboardThemeColorsEditor(
if (isSoftware) {
add(
KeyboardThemeColorEditorItem(
- label = "Key popup",
+ label = stringResource(R.string.keyboard_theme_key_popup),
color = theme.keyPopup,
presetColor = preset.keyPopup,
onColorChanged = { onThemeChanged(theme.copy(keyPopup = it)) }
@@ -2221,7 +2221,7 @@ private fun KeyboardThemeColorsEditor(
)
add(
KeyboardThemeColorEditorItem(
- label = "Selected popup key",
+ label = stringResource(R.string.keyboard_theme_key_popup_selected),
color = theme.keyPopupSelected,
presetColor = preset.keyPopupSelected,
onColorChanged = { onThemeChanged(theme.copy(keyPopupSelected = it)) }
@@ -2338,7 +2338,7 @@ private fun KeyboardThemeKeysEditor(
)
if (isSoftware) {
KeyboardThemeSwitchRow(
- label = "Show LEDs",
+ label = stringResource(R.string.keyboard_theme_show_leds),
checked = theme.showLeds,
presetChecked = preset.showLeds,
onCheckedChanged = { onThemeChanged(theme.copy(showLeds = it)) },
@@ -2377,42 +2377,42 @@ private fun KeyboardThemeKeysEditor(
onValueChanged = { onThemeChanged(theme.copy(rowGapScale = it)) }
)
KeyboardThemeSwitchRow(
- label = "Distribute spacing",
+ label = stringResource(R.string.keyboard_theme_distribute_spacing),
checked = theme.distributeHorizontalSpacing,
presetChecked = preset.distributeHorizontalSpacing,
onCheckedChanged = { onThemeChanged(theme.copy(distributeHorizontalSpacing = it)) },
linkId = SettingLinkIds.KEYBOARD_THEME_TOGGLE_DISTRIBUTE_SPACING
)
KeyboardThemeSwitchRow(
- label = "Ortholinear",
+ label = stringResource(R.string.keyboard_theme_ortholinear),
checked = theme.ortholinear,
presetChecked = preset.ortholinear,
onCheckedChanged = { onThemeChanged(theme.copy(ortholinear = it)) },
linkId = SettingLinkIds.KEYBOARD_THEME_TOGGLE_ORTHOLINEAR
)
KeyboardThemeSwitchRow(
- label = "Attach popup to key",
+ label = stringResource(R.string.keyboard_theme_attach_popup),
checked = theme.keyPopupAttached,
presetChecked = preset.keyPopupAttached,
onCheckedChanged = { onThemeChanged(theme.copy(keyPopupAttached = it)) },
linkId = SettingLinkIds.KEYBOARD_THEME_TOGGLE_ATTACH_POPUP
)
KeyboardThemeSwitchRow(
- label = "Popup tail connector",
+ label = stringResource(R.string.keyboard_theme_popup_tail),
checked = theme.keyPopupTailEnabled,
presetChecked = preset.keyPopupTailEnabled,
onCheckedChanged = { onThemeChanged(theme.copy(keyPopupTailEnabled = it)) },
linkId = SettingLinkIds.KEYBOARD_THEME_TOGGLE_POPUP_TAIL
)
KeyboardThemeSwitchRow(
- label = "Show key preview only on hold",
+ label = stringResource(R.string.keyboard_theme_preview_on_hold),
checked = theme.keyPreviewAfterLongPress,
presetChecked = preset.keyPreviewAfterLongPress,
onCheckedChanged = { onThemeChanged(theme.copy(keyPreviewAfterLongPress = it)) },
linkId = SettingLinkIds.KEYBOARD_THEME_TOGGLE_PREVIEW_ON_HOLD
)
KeyboardThemeSwitchRow(
- label = "Long-press character picker",
+ label = stringResource(R.string.keyboard_theme_character_picker),
checked = theme.keyAlternatesPopupEnabled,
presetChecked = preset.keyAlternatesPopupEnabled,
onCheckedChanged = { onThemeChanged(theme.copy(keyAlternatesPopupEnabled = it)) },
@@ -2760,7 +2760,7 @@ private fun KeyboardThemeColorPickerDialog(
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
- text = "Presets",
+ text = stringResource(R.string.keyboard_theme_presets),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
@@ -2812,7 +2812,7 @@ private fun KeyboardThemeColorPickerDialog(
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
- label = { Text("Hex color") },
+ label = { Text(stringResource(R.string.keyboard_theme_hex_color)) },
isError = hexValue.isNotEmpty() && parseKeyboardThemeHexColor(hexValue) == null
)
Row(
@@ -2823,7 +2823,7 @@ private fun KeyboardThemeColorPickerDialog(
enabled = parseKeyboardThemeHexColor(hexValue) != null,
onClick = { onColorSelected(color) }
) {
- Text("Apply")
+ Text(stringResource(R.string.clicks_apply))
}
}
}
diff --git a/app/src/main/java/it/palsoftware/pastiera/LanguagesScreen.kt b/app/src/main/java/it/palsoftware/pastiera/LanguagesScreen.kt
index 65be35761..9ba6b34d4 100644
--- a/app/src/main/java/it/palsoftware/pastiera/LanguagesScreen.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/LanguagesScreen.kt
@@ -106,7 +106,7 @@ fun LanguagesScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(56.dp)
+ .heightIn(min = 56.dp)
) {
Box(
modifier = Modifier
@@ -127,7 +127,7 @@ fun LanguagesScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
) {
Box(
modifier = Modifier
@@ -175,7 +175,7 @@ private fun LanguageSubtypeItem(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
) {
Row(
modifier = Modifier
@@ -414,7 +414,8 @@ private fun getLocaleTagForLanguage(languageCode: String): String {
"es" to "es_ES",
"pl" to "pl_PL",
"it" to "it_IT",
- "en" to "en_US"
+ "en" to "en_US",
+ "ko" to "ko_KR"
)
return localeMap[languageCode.lowercase()] ?: languageCode
}
diff --git a/app/src/main/java/it/palsoftware/pastiera/MainActivity.kt b/app/src/main/java/it/palsoftware/pastiera/MainActivity.kt
index cd8602255..dfd05a541 100644
--- a/app/src/main/java/it/palsoftware/pastiera/MainActivity.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/MainActivity.kt
@@ -810,12 +810,12 @@ fun KeyboardSetupScreen(
fontFamily = FontFamily.Monospace
)
Text(
- text = "Origin: ${event?.origin ?: "n/a"}",
+ text = stringResource(R.string.event_origin_label, event?.origin ?: "n/a"),
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace
)
Text(
- text = "Layout: ${event?.resolvedLayout ?: "n/a"}",
+ text = stringResource(R.string.event_layout_label, event?.resolvedLayout ?: "n/a"),
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace
)
diff --git a/app/src/main/java/it/palsoftware/pastiera/ModifierSettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/ModifierSettingsScreen.kt
index 97cefc361..52f9177f4 100644
--- a/app/src/main/java/it/palsoftware/pastiera/ModifierSettingsScreen.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/ModifierSettingsScreen.kt
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
@@ -428,7 +429,7 @@ private fun ModifierSwitchRow(
linkId: String? = null,
onCheckedChange: (Boolean) -> Unit
) {
- Surface(modifier = Modifier.fillMaxWidth().height(64.dp).settingRow(linkId)) {
+ Surface(modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).settingRow(linkId)) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = if (indent) 52.dp else 16.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically,
diff --git a/app/src/main/java/it/palsoftware/pastiera/NavModeSettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/NavModeSettingsScreen.kt
index 5104c6c39..c57249581 100644
--- a/app/src/main/java/it/palsoftware/pastiera/NavModeSettingsScreen.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/NavModeSettingsScreen.kt
@@ -1,6 +1,12 @@
package it.palsoftware.pastiera
import android.content.Context
+import android.content.BroadcastReceiver
+import android.content.Intent
+import android.content.IntentFilter
+import androidx.core.content.ContextCompat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import android.view.KeyEvent
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -45,10 +51,37 @@ import it.palsoftware.pastiera.R
import it.palsoftware.pastiera.commands.CommandRegistry
import it.palsoftware.pastiera.commands.CommandSurface
import it.palsoftware.pastiera.commands.CommandTarget
-import it.palsoftware.pastiera.data.layout.JsonLayoutLoader
import it.palsoftware.pastiera.data.mappings.KeyMappingLoader
import kotlin.math.min
+/** One off-main-thread load per screen/configuration, shared by every cell and dialog. */
+@Composable
+private fun rememberNavCommands(context: Context): List {
+ val localeTags = LocalConfiguration.current.locales.toLanguageTags()
+ var availabilityRevision by remember { mutableIntStateOf(0) }
+ DisposableEffect(context) {
+ val receiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) { availabilityRevision++ }
+ }
+ val filter = IntentFilter().apply {
+ addAction(Intent.ACTION_PACKAGE_ADDED)
+ addAction(Intent.ACTION_PACKAGE_REMOVED)
+ addAction(Intent.ACTION_PACKAGE_CHANGED)
+ addDataScheme("package")
+ }
+ ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
+ onDispose {
+ context.unregisterReceiver(receiver)
+ }
+ }
+ return produceState(emptyList(), context, localeTags, availabilityRevision) {
+ value = withContext(Dispatchers.IO) {
+ AppListHelper.invalidateInstalledApps()
+ CommandRegistry(context).getCommands(CommandSurface.NavMode)
+ }
+ }.value
+}
+
/**
* Nav Mode settings screen with keyboard visualization.
*/
@@ -59,6 +92,8 @@ fun NavModeSettingsScreen(
initialKeyCode: Int? = null
) {
val context = LocalContext.current
+ val commandTargets = rememberNavCommands(context)
+ val commandsById = remember(commandTargets) { commandTargets.associateBy { it.id } }
// Load nav mode enabled state
var navModeEnabled by remember {
@@ -85,10 +120,6 @@ fun NavModeSettingsScreen(
val defaultMappings = remember {
loadAllKeyMappings(context, useDefaults = true)
}
- val layoutHints = remember(keyMappings) {
- loadLayoutHints(context)
- }
-
// Handle system back button
Column(
@@ -379,9 +410,9 @@ fun NavModeSettingsScreen(
keySize = keySize,
keyHeight = keyHeight,
spacing = spacing,
+ commandsById = commandsById,
mappings = keyMappings,
defaultMappings = defaultMappings,
- layoutHints = layoutHints,
onKeyClick = { keyCode ->
selectedKeyCode = keyCode
}
@@ -417,6 +448,8 @@ fun NavModeSettingsScreen(
selectedKeyCode?.let { keyCode ->
KeyMappingDialog(
keyCode = keyCode,
+ commandTargets = commandTargets,
+ commandsById = commandsById,
currentMapping = keyMappings[keyCode],
defaultMapping = defaultMappings[keyCode],
onDismiss = { selectedKeyCode = null },
@@ -438,9 +471,9 @@ private fun KeyboardRow(
keySize: Dp,
keyHeight: Dp,
spacing: Dp,
+ commandsById: Map,
mappings: Map,
defaultMappings: Map,
- layoutHints: Map,
onKeyClick: (Int) -> Unit
) {
Row(
@@ -454,9 +487,9 @@ private fun KeyboardRow(
}
KeyButton(
keyCode = keyCode,
+ commandsById = commandsById,
mapping = mappings[keyCode],
hasDefault = defaultMappings.containsKey(keyCode),
- layoutHint = layoutHints[keyCode],
onClick = { onKeyClick(keyCode) },
modifier = Modifier
.width(keySize)
@@ -469,14 +502,14 @@ private fun KeyboardRow(
@Composable
private fun KeyButton(
keyCode: Int,
+ commandsById: Map,
mapping: KeyMappingLoader.CtrlMapping?,
hasDefault: Boolean,
- layoutHint: String?,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val keyLabel = getKeyLabel(keyCode)
- val mappingLabel = mapping?.let { getMappingLabelShort(it) }
+ val mappingLabel = mapping?.let { getMappingLabelShort(it, commandsById) }
val hasMapping = mapping != null && mapping.type != "none"
val backgroundColor = if (hasMapping) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
@@ -561,17 +594,6 @@ private fun KeyButton(
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center
)
- if (layoutHint != null) {
- Text(
- text = layoutHint,
- style = MaterialTheme.typography.labelSmall,
- fontSize = 8.sp,
- color = MaterialTheme.colorScheme.tertiary,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- textAlign = TextAlign.Center
- )
- }
if (mappingIcon != null) {
Icon(
imageVector = mappingIcon,
@@ -608,6 +630,8 @@ private fun KeyButton(
@Composable
private fun KeyMappingDialog(
keyCode: Int,
+ commandTargets: List,
+ commandsById: Map,
currentMapping: KeyMappingLoader.CtrlMapping?,
defaultMapping: KeyMappingLoader.CtrlMapping?,
onDismiss: () -> Unit,
@@ -617,8 +641,7 @@ private fun KeyMappingDialog(
val context = LocalContext.current
var selectedType by remember { mutableStateOf(currentMapping?.type) }
var selectedValue by remember { mutableStateOf(currentMapping?.value) }
- val commandTargets = remember { CommandRegistry(context).getCommands(CommandSurface.NavMode) }
- val defaultLabel = defaultMapping?.let { getMappingLabel(it) }
+ val defaultLabel = defaultMapping?.let { getMappingLabel(it, context, commandsById) }
val dialogMaxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.9f
val gridMaxHeight = dialogMaxHeight * 0.6f
@@ -694,7 +717,7 @@ private fun KeyMappingDialog(
selectedType = "command"
selectedValue = null
},
- label = { Text("Command") }
+ label = { Text(stringResource(R.string.nav_mode_command)) }
)
FilterChip(
selected = selectedType == "none",
@@ -872,19 +895,22 @@ private fun getKeyLabel(keyCode: Int): String {
}
}
-private fun getMappingLabel(mapping: KeyMappingLoader.CtrlMapping): String? {
+private fun getMappingLabel(mapping: KeyMappingLoader.CtrlMapping, context: Context, commandsById: Map): String? {
return when (mapping.type) {
"keycode" -> mapping.value
"action" -> mapping.value
- "command" -> mapping.value
+ "command" -> commandsById[mapping.value]?.let { command ->
+ "${command.source.localizedDisplayLabel(context)}: ${command.label}"
+ } ?: mapping.value
"native_ctrl" -> "Ctrl"
"none" -> null // Don't show label for "none"
else -> null
}
}
+@Composable
private fun commandLabel(command: CommandTarget): String {
- return "${command.source.displayLabel}: ${command.label}"
+ return "${command.source.localizedDisplayLabel(LocalContext.current)}: ${command.label}"
}
@Composable
@@ -910,7 +936,7 @@ private fun getActionLabel(action: String): String {
}
@Composable
-private fun getMappingLabelShort(mapping: KeyMappingLoader.CtrlMapping): String? {
+private fun getMappingLabelShort(mapping: KeyMappingLoader.CtrlMapping, commandsById: Map): String? {
return when (mapping.type) {
"keycode" -> when (mapping.value) {
"DPAD_UP" -> stringResource(R.string.nav_mode_keycode_up)
@@ -947,7 +973,9 @@ private fun getMappingLabelShort(mapping: KeyMappingLoader.CtrlMapping): String?
"media_next" -> stringResource(R.string.nav_mode_action_media_next)
else -> mapping.value
}
- "command" -> mapping.value.substringAfterLast('.').replace('_', ' ')
+ "command" -> commandsById[mapping.value]?.let { command ->
+ command.label
+ } ?: mapping.value.substringAfterLast('.').replace('_', ' ')
"native_ctrl" -> "Ctrl"
"none" -> null // Don't show label for "none"
else -> null
@@ -981,62 +1009,3 @@ private fun loadAllKeyMappings(context: Context, useDefaults: Boolean = false):
loadedMappings[keyCode] ?: KeyMappingLoader.CtrlMapping("none", "")
}
}
-
-private fun loadLayoutHints(context: Context): Map {
- val layoutName = SettingsManager.getKeyboardLayout(context)
- val layout = JsonLayoutLoader.loadLayout(context.assets, layoutName, context)
- ?: return emptyMap()
- val allAlphabeticKeys = listOf(
- KeyEvent.KEYCODE_Q, KeyEvent.KEYCODE_W, KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_R,
- KeyEvent.KEYCODE_T, KeyEvent.KEYCODE_Y, KeyEvent.KEYCODE_U, KeyEvent.KEYCODE_I,
- KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_S,
- KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_F, KeyEvent.KEYCODE_G, KeyEvent.KEYCODE_H,
- KeyEvent.KEYCODE_J, KeyEvent.KEYCODE_K, KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_Z,
- KeyEvent.KEYCODE_X, KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_B,
- KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_M
- )
-
- return allAlphabeticKeys.mapNotNull { keyCode ->
- val physicalLabel = keyCodeToLetter(keyCode) ?: return@mapNotNull null
- val layoutChar = layout[keyCode]?.lowercase?.firstOrNull()
- ?.uppercaseChar()
- ?: return@mapNotNull null
- if (layoutChar.toString() != physicalLabel) {
- keyCode to "→ $layoutChar"
- } else {
- null
- }
- }.toMap()
-}
-
-private fun keyCodeToLetter(keyCode: Int): String? {
- return when (keyCode) {
- KeyEvent.KEYCODE_Q -> "Q"
- KeyEvent.KEYCODE_W -> "W"
- KeyEvent.KEYCODE_E -> "E"
- KeyEvent.KEYCODE_R -> "R"
- KeyEvent.KEYCODE_T -> "T"
- KeyEvent.KEYCODE_Y -> "Y"
- KeyEvent.KEYCODE_U -> "U"
- KeyEvent.KEYCODE_I -> "I"
- KeyEvent.KEYCODE_O -> "O"
- KeyEvent.KEYCODE_P -> "P"
- KeyEvent.KEYCODE_A -> "A"
- KeyEvent.KEYCODE_S -> "S"
- KeyEvent.KEYCODE_D -> "D"
- KeyEvent.KEYCODE_F -> "F"
- KeyEvent.KEYCODE_G -> "G"
- KeyEvent.KEYCODE_H -> "H"
- KeyEvent.KEYCODE_J -> "J"
- KeyEvent.KEYCODE_K -> "K"
- KeyEvent.KEYCODE_L -> "L"
- KeyEvent.KEYCODE_Z -> "Z"
- KeyEvent.KEYCODE_X -> "X"
- KeyEvent.KEYCODE_C -> "C"
- KeyEvent.KEYCODE_V -> "V"
- KeyEvent.KEYCODE_B -> "B"
- KeyEvent.KEYCODE_N -> "N"
- KeyEvent.KEYCODE_M -> "M"
- else -> null
- }
-}
diff --git a/app/src/main/java/it/palsoftware/pastiera/PastieraApplication.kt b/app/src/main/java/it/palsoftware/pastiera/PastieraApplication.kt
index d94f4941b..283549af8 100644
--- a/app/src/main/java/it/palsoftware/pastiera/PastieraApplication.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/PastieraApplication.kt
@@ -6,9 +6,6 @@ import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager
import android.graphics.drawable.Icon
import android.os.Build
-import android.os.Handler
-import android.os.Looper
-import it.palsoftware.pastiera.inputmethod.subtype.AdditionalSubtypeUtils
class PastieraApplication : Application() {
override fun onCreate() {
@@ -17,9 +14,6 @@ class PastieraApplication : Application() {
AppPackageChangeMonitor.register(this)
ClicksPowerKeyboardController.initialize(this)
publishSoftwareKeyboardModeShortcut()
- Handler(Looper.getMainLooper()).post {
- AdditionalSubtypeUtils.registerAdditionalSubtypes(this)
- }
}
private fun publishSoftwareKeyboardModeShortcut() {
diff --git a/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt b/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt
index dc5f391dc..b11007661 100644
--- a/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/SettingsManager.kt
@@ -3555,8 +3555,8 @@ object SettingsManager {
commandId = PastieraCommandSource.COMMAND_QUICK_LAUNCHER,
source = CommandSourceId.Pastiera.storageValue,
kind = "PastieraAction",
- title = "Pastiera QuickLauncher",
- subtitle = "Open Pastiera search",
+ title = context.getString(R.string.command_pastiera_quick_launcher),
+ subtitle = context.getString(R.string.command_pastiera_quick_launcher_description),
launch = CommandLaunchSpec.InternalAction(PastieraCommandSource.ACTION_OPEN_QUICK_LAUNCHER)
)
getPreferences(context).edit()
diff --git a/app/src/main/java/it/palsoftware/pastiera/SoftwareKeyboardModeActionActivity.kt b/app/src/main/java/it/palsoftware/pastiera/SoftwareKeyboardModeActionActivity.kt
index c238f30e2..8915c7c56 100644
--- a/app/src/main/java/it/palsoftware/pastiera/SoftwareKeyboardModeActionActivity.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/SoftwareKeyboardModeActionActivity.kt
@@ -1,17 +1,16 @@
package it.palsoftware.pastiera
-import android.app.Activity
import android.os.Bundle
import android.widget.Toast
-class SoftwareKeyboardModeActionActivity : Activity() {
+class SoftwareKeyboardModeActionActivity : LocalizedComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIntent()
finish()
}
- override fun onNewIntent(intent: android.content.Intent?) {
+ override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIntent()
diff --git a/app/src/main/java/it/palsoftware/pastiera/SymCustomizationScreen.kt b/app/src/main/java/it/palsoftware/pastiera/SymCustomizationScreen.kt
index 73267fdb2..acb5e14b3 100644
--- a/app/src/main/java/it/palsoftware/pastiera/SymCustomizationScreen.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/SymCustomizationScreen.kt
@@ -519,7 +519,7 @@ fun SymCustomizationScreen(
Surface(
modifier = Modifier.settingRow("sym.auto_close")
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
) {
Row(
modifier = Modifier
@@ -561,7 +561,7 @@ fun SymCustomizationScreen(
Surface(
modifier = Modifier.settingRow("sym.auto_close_touch")
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
) {
Row(
modifier = Modifier
@@ -675,7 +675,7 @@ fun SymCustomizationScreen(
Surface(
modifier = Modifier.settingRow("sym.emoji_height")
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
) {
Row(
modifier = Modifier
diff --git a/app/src/main/java/it/palsoftware/pastiera/TrackpadDebugActivity.kt b/app/src/main/java/it/palsoftware/pastiera/TrackpadDebugActivity.kt
index ad2ce7956..5bcb116b3 100644
--- a/app/src/main/java/it/palsoftware/pastiera/TrackpadDebugActivity.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/TrackpadDebugActivity.kt
@@ -24,6 +24,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -189,6 +190,7 @@ fun TrackpadDebugScreen(
axisState: TrackpadDebugAxisState,
onBackPressed: () -> Unit
) {
+ val context = LocalContext.current
val listState = rememberLazyListState()
var showYAxis by rememberSaveable { mutableStateOf(false) }
@@ -217,7 +219,7 @@ fun TrackpadDebugScreen(
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
- text = "Trackpad Debug",
+ text = stringResource(R.string.trackpad_debug_title),
style = MaterialTheme.typography.titleLarge,
color = androidx.compose.ui.graphics.Color.Green
)
@@ -241,7 +243,7 @@ fun TrackpadDebugScreen(
// Event counter
Text(
- text = "Events captured: ${events.size}",
+ text = stringResource(R.string.trackpad_debug_events_captured, events.size),
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.bodyMedium,
color = androidx.compose.ui.graphics.Color.Green
@@ -257,7 +259,7 @@ fun TrackpadDebugScreen(
// Events list
if (events.isEmpty()) {
Text(
- text = "Waiting for trackpad events...\nSwipe on the trackpad to see events here.",
+ text = stringResource(R.string.trackpad_debug_waiting),
style = MaterialTheme.typography.bodyMedium,
color = androidx.compose.ui.graphics.Color.Green
)
@@ -291,24 +293,35 @@ fun TrackpadDebugScreen(
if (pointerEvent != null) {
val eventType = when (event.type) {
- PointerEventType.Press -> "Press"
- PointerEventType.Release -> "Release"
- PointerEventType.Move -> "Move"
- PointerEventType.Enter -> "Enter"
- PointerEventType.Exit -> "Exit"
- PointerEventType.Scroll -> "Scroll"
- else -> "Unknown(${event.type})"
+ PointerEventType.Press -> context.getString(R.string.trackpad_debug_event_press)
+ PointerEventType.Release -> context.getString(R.string.trackpad_debug_event_release)
+ PointerEventType.Move -> context.getString(R.string.trackpad_debug_event_move)
+ PointerEventType.Enter -> context.getString(R.string.trackpad_debug_event_enter)
+ PointerEventType.Exit -> context.getString(R.string.trackpad_debug_event_exit)
+ PointerEventType.Scroll -> context.getString(R.string.trackpad_debug_event_scroll)
+ else -> context.getString(R.string.trackpad_debug_event_unknown, event.type)
}
val logLines = mutableListOf()
logLines.add("[$eventType]")
- logLines.add(" Position: X=${"%.2f".format(pointerEvent.position.x)} Y=${"%.2f".format(pointerEvent.position.y)}")
- logLines.add(" Pressed: ${pointerEvent.pressed}")
- logLines.add(" Pressure: ${"%.3f".format(pointerEvent.pressure)}")
- logLines.add(" Time: ${pointerEvent.uptimeMillis}")
+ logLines.add(
+ context.getString(
+ R.string.trackpad_debug_position,
+ pointerEvent.position.x,
+ pointerEvent.position.y
+ )
+ )
+ logLines.add(context.getString(R.string.trackpad_debug_pressed, pointerEvent.pressed))
+ logLines.add(context.getString(R.string.trackpad_debug_pressure, pointerEvent.pressure))
+ logLines.add(context.getString(R.string.trackpad_debug_time, pointerEvent.uptimeMillis))
if (event.type == PointerEventType.Scroll) {
- logLines.add(" Scroll delta: ${event.changes.first().scrollDelta}")
+ logLines.add(
+ context.getString(
+ R.string.trackpad_debug_scroll_delta,
+ event.changes.first().scrollDelta
+ )
+ )
}
logLines.add("") // Empty line
diff --git a/app/src/main/java/it/palsoftware/pastiera/TrackpadDebugOverlayService.kt b/app/src/main/java/it/palsoftware/pastiera/TrackpadDebugOverlayService.kt
index 12dab1caf..789ea7b62 100644
--- a/app/src/main/java/it/palsoftware/pastiera/TrackpadDebugOverlayService.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/TrackpadDebugOverlayService.kt
@@ -17,6 +17,10 @@ class TrackpadDebugOverlayService : Service() {
private var debugTextView: TextView? = null
private val events = mutableListOf()
+ override fun attachBaseContext(base: android.content.Context) {
+ super.attachBaseContext(AppLocaleManager.wrapContext(base))
+ }
+
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
@@ -36,7 +40,8 @@ class TrackpadDebugOverlayService : Service() {
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
debugTextView = TextView(this).apply {
- text = "Trackpad Debug\nWaiting for events...\n\n"
+ text = getString(R.string.trackpad_debug_title) + "\n" +
+ getString(R.string.trackpad_debug_waiting) + "\n\n"
textSize = 10f
typeface = android.graphics.Typeface.MONOSPACE
setTextColor(0xFF00FF00.toInt())
@@ -107,7 +112,8 @@ class TrackpadDebugOverlayService : Service() {
}
debugTextView?.post {
- debugTextView?.text = "Trackpad Debug (${events.size} events)\n\n" + events.joinToString("")
+ debugTextView?.text = getString(R.string.trackpad_debug_overlay_events, events.size) +
+ events.joinToString("")
}
}
diff --git a/app/src/main/java/it/palsoftware/pastiera/TrackpadGestureSettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/TrackpadGestureSettingsScreen.kt
index d1dd4bc5c..481157ef6 100644
--- a/app/src/main/java/it/palsoftware/pastiera/TrackpadGestureSettingsScreen.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/TrackpadGestureSettingsScreen.kt
@@ -181,7 +181,7 @@ fun TrackpadGestureSettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
.settingRow(SettingLinkIds.TRACKPAD_GESTURES_ENABLED)
) {
Row(
@@ -225,7 +225,7 @@ fun TrackpadGestureSettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
.settingRow("trackpad.add_word")
) {
Row(
@@ -264,7 +264,7 @@ fun TrackpadGestureSettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
.settingRow("trackpad.add_word_full_width")
) {
Row(
@@ -534,7 +534,7 @@ fun TrackpadGestureSettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(72.dp)
+ .heightIn(min = 72.dp)
.settingRow("trackpad.swipe_to_delete")
) {
Row(
@@ -577,7 +577,7 @@ fun TrackpadGestureSettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(72.dp)
+ .heightIn(min = 72.dp)
.settingRow(SettingLinkIds.TRACKPAD_SENSITIVITY) {
openSettingsChild(context, "trackpad", "sensitivity")
}
@@ -625,7 +625,7 @@ fun TrackpadGestureSettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(72.dp)
+ .heightIn(min = 72.dp)
.settingRow(SettingLinkIds.TRACKPAD_DEBUG) {
context.startActivity(Intent(context, TrackpadDebugActivity::class.java))
}
@@ -669,7 +669,7 @@ fun TrackpadGestureSettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
.clickable { showTutorialDialog = true }
) {
Row(
@@ -711,7 +711,7 @@ fun TrackpadGestureSettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
.clickable {
val url = context.getString(R.string.trackpad_gestures_shizuku_url)
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
diff --git a/app/src/main/java/it/palsoftware/pastiera/TutorialActivity.kt b/app/src/main/java/it/palsoftware/pastiera/TutorialActivity.kt
index 392a499ea..e661f8f11 100644
--- a/app/src/main/java/it/palsoftware/pastiera/TutorialActivity.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/TutorialActivity.kt
@@ -1977,7 +1977,7 @@ fun TutorialCustomizationPageContent(
val typingSoundPlayer = remember { TypingSoundPlayer(context).apply { reload() } }
val languageOptions = remember {
- listOf(null, "en", "it", "de", "es", "fr", "pl", "ru", "uk", "vi", "hy")
+ listOf(null, "en", "it", "de", "es", "fr", "pl", "ru", "uk", "ko", "vi", "hy")
}
DisposableEffect(typingSoundPlayer) {
diff --git a/app/src/main/java/it/palsoftware/pastiera/TypingSoundSettingsRow.kt b/app/src/main/java/it/palsoftware/pastiera/TypingSoundSettingsRow.kt
index 6a63afdb9..98ae76875 100644
--- a/app/src/main/java/it/palsoftware/pastiera/TypingSoundSettingsRow.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/TypingSoundSettingsRow.kt
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.automirrored.filled.ArrowForward
@@ -79,7 +80,7 @@ fun TypingSoundSettingsRow() {
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
.settingRow("sounds.typing_mode") { showTypingSoundMenu = true }
) {
Row(
@@ -167,7 +168,7 @@ fun TypingSoundSettingsRow() {
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(88.dp)
+ .heightIn(min = 88.dp)
.settingRow("sounds.output") { showOutputMenu = true }
) {
Row(
@@ -247,7 +248,7 @@ fun TypingSoundSettingsRow() {
Surface(
modifier = Modifier
.fillMaxWidth().settingRow("sounds.system_haptics")
- .height(72.dp)
+ .heightIn(min = 72.dp)
) {
Row(
modifier = Modifier
@@ -290,7 +291,7 @@ fun TypingSoundSettingsRow() {
Surface(
modifier = Modifier
.fillMaxWidth().settingRow("sounds.haptic_duration")
- .height(104.dp)
+ .heightIn(min = 104.dp)
) {
Column(
modifier = Modifier
@@ -332,7 +333,7 @@ fun TypingSoundSettingsRow() {
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(64.dp)
+ .heightIn(min = 64.dp)
.clickable {
val intent = Intent(
Intent.ACTION_VIEW,
diff --git a/app/src/main/java/it/palsoftware/pastiera/VariationCustomizationScreen.kt b/app/src/main/java/it/palsoftware/pastiera/VariationCustomizationScreen.kt
index 85661a367..bec4ab1fa 100644
--- a/app/src/main/java/it/palsoftware/pastiera/VariationCustomizationScreen.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/VariationCustomizationScreen.kt
@@ -317,7 +317,7 @@ fun VariationCustomizationScreen(
Surface(
modifier = Modifier
.fillMaxWidth().settingRow("variations.sticky_layer")
- .height(64.dp)
+ .heightIn(min = 64.dp)
) {
Row(
modifier = Modifier
@@ -918,7 +918,7 @@ private fun VariationRow(
) {
Icon(
imageVector = Icons.Filled.Add,
- contentDescription = "Add slot"
+ contentDescription = stringResource(R.string.variation_add_slot)
)
}
IconButton(
@@ -928,7 +928,7 @@ private fun VariationRow(
) {
Icon(
imageVector = Icons.Filled.DeleteOutline,
- contentDescription = "Remove rightmost slot"
+ contentDescription = stringResource(R.string.variation_remove_rightmost_slot)
)
}
}
diff --git a/app/src/main/java/it/palsoftware/pastiera/VirtualKeyboardBehaviorSettingsScreen.kt b/app/src/main/java/it/palsoftware/pastiera/VirtualKeyboardBehaviorSettingsScreen.kt
index b733e6375..67a196bb5 100644
--- a/app/src/main/java/it/palsoftware/pastiera/VirtualKeyboardBehaviorSettingsScreen.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/VirtualKeyboardBehaviorSettingsScreen.kt
@@ -132,7 +132,7 @@ internal fun VirtualKeyboardBehaviorSettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(82.dp)
+ .heightIn(min = 82.dp)
.settingRow("on_screen.layout_style") { showSoftwareKeyboardLayoutStyleMenu = true }
) {
Row(
@@ -288,7 +288,7 @@ internal fun VirtualKeyboardBehaviorSettingsScreen(
Surface(
modifier = Modifier
.fillMaxWidth()
- .height(74.dp)
+ .heightIn(min = 74.dp)
.settingRow("on_screen.theme", onOpenKeyboardTheme)
) {
Row(
@@ -330,7 +330,7 @@ private fun SoftwareKeyboardModifierSelection(
var expanded by remember { mutableStateOf(false) }
Surface(
modifier = modifier
- .height(82.dp)
+ .heightIn(min = 82.dp)
.settingRow(linkId) { expanded = true },
shape = MaterialTheme.shapes.medium,
tonalElevation = 1.dp
@@ -413,7 +413,7 @@ private fun ModifierTapLatchRow(
Surface(
modifier = Modifier.settingRow(linkId)
.fillMaxWidth()
- .height(74.dp)
+ .heightIn(min = 74.dp)
) {
Row(
modifier = Modifier
diff --git a/app/src/main/java/it/palsoftware/pastiera/clipboard/ClipboardHistoryManager.kt b/app/src/main/java/it/palsoftware/pastiera/clipboard/ClipboardHistoryManager.kt
index 7e930a732..5ec2b0415 100644
--- a/app/src/main/java/it/palsoftware/pastiera/clipboard/ClipboardHistoryManager.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/clipboard/ClipboardHistoryManager.kt
@@ -16,10 +16,14 @@ import it.palsoftware.pastiera.SettingsManager
*/
class ClipboardHistoryManager internal constructor(
private val context: Context,
- private val accessPolicy: ClipboardHistoryAccessPolicy
+ private val accessPolicy: ClipboardHistoryAccessPolicy,
+ private val beforeTextCommit: (android.view.inputmethod.InputConnection?) -> Unit = {}
) : ClipboardManager.OnPrimaryClipChangedListener {
- constructor(context: Context) : this(context, SystemClipboardHistoryAccessPolicy(context))
+ constructor(
+ context: Context,
+ beforeTextCommit: (android.view.inputmethod.InputConnection?) -> Unit = {}
+ ) : this(context, SystemClipboardHistoryAccessPolicy(context), beforeTextCommit)
private lateinit var clipboardManager: ClipboardManager
private var clipboardDao: ClipboardDao? = null
@@ -165,6 +169,7 @@ class ClipboardHistoryManager internal constructor(
*/
fun pasteText(text: String, inputConnection: android.view.inputmethod.InputConnection?) {
if (!isHistoryAccessible()) return
+ beforeTextCommit(inputConnection)
inputConnection?.commitText(text, 1)
}
diff --git a/app/src/main/java/it/palsoftware/pastiera/commands/AppActionCommandSource.kt b/app/src/main/java/it/palsoftware/pastiera/commands/AppActionCommandSource.kt
index ee9c96922..2991c834c 100644
--- a/app/src/main/java/it/palsoftware/pastiera/commands/AppActionCommandSource.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/commands/AppActionCommandSource.kt
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
+import it.palsoftware.pastiera.R
class AppActionCommandSource : CommandSource {
override val id = CommandSourceId.AppActions
@@ -12,7 +13,7 @@ class AppActionCommandSource : CommandSource {
return listOf(
context.appIntentCommand(
id = COMMAND_SEARCH,
- label = "Niagara Search",
+ label = context.getString(R.string.command_app_niagara_search),
subtitle = "Niagara Launcher",
uri = "niagara://search",
packageName = NIAGARA_PACKAGE,
@@ -20,7 +21,7 @@ class AppActionCommandSource : CommandSource {
),
context.appIntentCommand(
id = COMMAND_AGENDA,
- label = "Niagara Agenda",
+ label = context.getString(R.string.command_app_niagara_agenda),
subtitle = "Niagara Launcher",
uri = "niagara://agenda",
packageName = NIAGARA_PACKAGE,
@@ -28,7 +29,7 @@ class AppActionCommandSource : CommandSource {
),
context.appIntentCommand(
id = "tasker.select_task",
- label = "Tasker Select Task",
+ label = context.getString(R.string.command_app_tasker_select_task),
subtitle = "Tasker",
action = "net.dinglisch.android.tasker.ACTION_TASK_SELECT",
packageName = TASKER_PACKAGE,
@@ -36,7 +37,7 @@ class AppActionCommandSource : CommandSource {
),
context.appIntentCommand(
id = "tasker.preferences",
- label = "Tasker Preferences",
+ label = context.getString(R.string.command_app_tasker_preferences),
subtitle = "Tasker",
action = "net.dinglisch.android.tasker.ACTION_OPEN_PREFS",
packageName = TASKER_PACKAGE,
@@ -44,7 +45,7 @@ class AppActionCommandSource : CommandSource {
),
context.appIntentCommand(
id = "tasker.create_shortcut",
- label = "Tasker Create Shortcut",
+ label = context.getString(R.string.command_app_tasker_create_shortcut),
subtitle = "Tasker",
action = Intent.ACTION_CREATE_SHORTCUT,
packageName = TASKER_PACKAGE,
@@ -52,7 +53,7 @@ class AppActionCommandSource : CommandSource {
),
context.appIntentCommand(
id = "homeassistant.navigate",
- label = "Home Assistant Navigate",
+ label = context.getString(R.string.command_app_home_assistant_navigate),
subtitle = "Home Assistant",
uri = "homeassistant://navigate",
packageName = HOME_ASSISTANT_PACKAGE,
@@ -60,7 +61,7 @@ class AppActionCommandSource : CommandSource {
),
context.appIntentCommand(
id = "homeassistant.assist",
- label = "Home Assistant Assist",
+ label = context.getString(R.string.command_app_home_assistant_assist),
subtitle = "Home Assistant",
action = Intent.ACTION_ASSIST,
packageName = HOME_ASSISTANT_PACKAGE,
@@ -68,7 +69,7 @@ class AppActionCommandSource : CommandSource {
),
context.appIntentCommand(
id = "homeassistant.voice_command",
- label = "Home Assistant Voice Command",
+ label = context.getString(R.string.command_app_home_assistant_voice_command),
subtitle = "Home Assistant",
action = Intent.ACTION_VOICE_COMMAND,
packageName = HOME_ASSISTANT_PACKAGE,
diff --git a/app/src/main/java/it/palsoftware/pastiera/commands/CommandExecutor.kt b/app/src/main/java/it/palsoftware/pastiera/commands/CommandExecutor.kt
index 0cb412479..0571060d4 100644
--- a/app/src/main/java/it/palsoftware/pastiera/commands/CommandExecutor.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/commands/CommandExecutor.kt
@@ -41,13 +41,13 @@ class CommandExecutor(
private fun launchPackage(packageName: String): CommandExecutionResult {
return try {
val intent = context.packageManager.getLaunchIntentForPackage(packageName)
- ?: return fail("Package not available")
+ ?: return fail(context.getString(R.string.command_error_package_unavailable))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
CommandExecutionResult.Success
} catch (error: Exception) {
Log.e(TAG, "Failed to launch package $packageName", error)
- fail("Could not open app")
+ fail(context.getString(R.string.command_error_could_not_open_app))
}
}
@@ -66,16 +66,16 @@ class CommandExecutor(
.forEach { (key, value) -> putExtra(key, value) }
}
if (intent.resolveActivity(context.packageManager) == null) {
- return fail("Command not available")
+ return fail(context.getString(R.string.command_error_not_available))
}
context.startActivity(intent)
CommandExecutionResult.Success
} catch (error: SecurityException) {
Log.e(TAG, "Security error starting command intent", error)
- fail("Command blocked")
+ fail(context.getString(R.string.command_error_blocked))
} catch (error: Exception) {
Log.e(TAG, "Failed to start command intent", error)
- fail("Command failed")
+ fail(context.getString(R.string.command_error_failed))
}
}
@@ -88,7 +88,7 @@ class CommandExecutor(
CommandExecutionResult.Success
} catch (error: Exception) {
Log.e(TAG, "Failed to open QuickLauncher", error)
- fail("Could not open QuickLauncher")
+ fail(context.getString(R.string.command_error_could_not_open_quick_launcher))
}
}
PastieraCommandSource.ACTION_OPEN_MAIN_ACTIVITY -> {
@@ -100,7 +100,7 @@ class CommandExecutor(
CommandExecutionResult.Success
} catch (error: Exception) {
Log.e(TAG, "Failed to open Pastiera", error)
- fail("Could not open Pastiera")
+ fail(context.getString(R.string.command_error_could_not_open_pastiera))
}
}
PastieraCommandSource.ACTION_TOGGLE_SOFTWARE_KEYBOARD_MODE -> toggleSoftwareKeyboardMode()
@@ -113,7 +113,7 @@ class CommandExecutor(
DeviceControlCommandSource.ACTION_VOLUME_MUTE -> adjustVolume(AudioManager.ADJUST_TOGGLE_MUTE)
DeviceControlCommandSource.ACTION_BRIGHTNESS_UP -> sendShellKeyEvent(KeyEvent.KEYCODE_BRIGHTNESS_UP)
DeviceControlCommandSource.ACTION_BRIGHTNESS_DOWN -> sendShellKeyEvent(KeyEvent.KEYCODE_BRIGHTNESS_DOWN)
- else -> fail("Unknown action")
+ else -> fail(context.getString(R.string.command_error_unknown_action))
}
}
@@ -127,7 +127,7 @@ class CommandExecutor(
CommandExecutionResult.Success
} catch (error: Exception) {
Log.e(TAG, "Failed to go home", error)
- fail("Could not go home")
+ fail(context.getString(R.string.command_error_could_not_go_home))
}
}
@@ -149,7 +149,7 @@ class CommandExecutor(
private fun dispatchMediaKey(keyCode: Int): CommandExecutionResult {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
- ?: return fail("Audio unavailable")
+ ?: return fail(context.getString(R.string.command_error_audio_unavailable))
val eventTime = SystemClock.uptimeMillis()
audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, keyCode, 0))
audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, keyCode, 0))
@@ -158,7 +158,7 @@ class CommandExecutor(
private fun adjustVolume(direction: Int): CommandExecutionResult {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
- ?: return fail("Audio unavailable")
+ ?: return fail(context.getString(R.string.command_error_audio_unavailable))
audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, direction, AudioManager.FLAG_SHOW_UI)
return CommandExecutionResult.Success
}
@@ -168,7 +168,7 @@ class CommandExecutor(
val shizukuAvailable = Shizuku.pingBinder() &&
Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
if (!shizukuAvailable) {
- return fail("Shizuku required")
+ return fail(context.getString(R.string.command_error_shizuku_required))
}
val newProcessMethod = Shizuku::class.java.getDeclaredMethod(
"newProcess",
@@ -187,21 +187,23 @@ class CommandExecutor(
if (exitCode == 0) {
CommandExecutionResult.Success
} else {
- fail("Command failed")
+ fail(context.getString(R.string.command_error_failed))
}
} catch (error: Exception) {
Log.e(TAG, "Failed to send shell keyevent $keyCode", error)
- fail("Command failed")
+ fail(context.getString(R.string.command_error_failed))
}
}
private fun executeNavAction(launch: CommandLaunchSpec.NavAction): CommandExecutionResult {
- val controller = navModeController ?: return fail("Nav mode unavailable")
- val inputConnection = inputConnectionProvider?.invoke() ?: return fail("No input context")
+ val controller = navModeController
+ ?: return fail(context.getString(R.string.command_error_nav_mode_unavailable))
+ val inputConnection = inputConnectionProvider?.invoke()
+ ?: return fail(context.getString(R.string.command_error_no_input_context))
return if (controller.executeMapping(launch.mappingType, launch.value, null, inputConnection)) {
CommandExecutionResult.Success
} else {
- fail("Nav action failed")
+ fail(context.getString(R.string.command_error_nav_action_failed))
}
}
diff --git a/app/src/main/java/it/palsoftware/pastiera/commands/CommandModel.kt b/app/src/main/java/it/palsoftware/pastiera/commands/CommandModel.kt
index 84c7e08b8..7b64397f3 100644
--- a/app/src/main/java/it/palsoftware/pastiera/commands/CommandModel.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/commands/CommandModel.kt
@@ -1,6 +1,8 @@
package it.palsoftware.pastiera.commands
+import android.content.Context
import android.graphics.drawable.Drawable
+import it.palsoftware.pastiera.R
data class CommandTarget(
val id: String,
@@ -27,6 +29,18 @@ enum class CommandSourceId(val storageValue: String, val displayLabel: String) {
return entries.firstOrNull { it.storageValue == value }
}
}
+
+ fun localizedDisplayLabel(context: Context): String {
+ return context.getString(
+ when (this) {
+ Apps -> R.string.command_source_apps
+ Pastiera -> R.string.command_source_pastiera
+ AppActions -> R.string.command_source_app_actions
+ DeviceControl -> R.string.command_source_device_control
+ NavActions -> R.string.command_source_navigation
+ }
+ )
+ }
}
enum class CommandKind {
diff --git a/app/src/main/java/it/palsoftware/pastiera/commands/CommandRegistry.kt b/app/src/main/java/it/palsoftware/pastiera/commands/CommandRegistry.kt
index a5e1c4c12..b4564f6ee 100644
--- a/app/src/main/java/it/palsoftware/pastiera/commands/CommandRegistry.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/commands/CommandRegistry.kt
@@ -27,7 +27,7 @@ class CommandRegistry(
add(command.label)
command.subtitle?.let(::add)
add(command.kind.name)
- add(command.source.displayLabel)
+ add(command.source.localizedDisplayLabel(context))
addAll(command.searchTokens)
}.joinToString(" ").lowercase()
if (haystack.contains(normalized)) command else null
diff --git a/app/src/main/java/it/palsoftware/pastiera/commands/DeviceControlCommandSource.kt b/app/src/main/java/it/palsoftware/pastiera/commands/DeviceControlCommandSource.kt
index 00b18c352..29ca030da 100644
--- a/app/src/main/java/it/palsoftware/pastiera/commands/DeviceControlCommandSource.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/commands/DeviceControlCommandSource.kt
@@ -5,42 +5,44 @@ import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
+import it.palsoftware.pastiera.R
class DeviceControlCommandSource : CommandSource {
override val id = CommandSourceId.DeviceControl
override fun getCommands(context: Context): List {
return buildList {
- add(deviceAction("device.home", "Home screen", "System", ACTION_HOME_SCREEN))
- add(deviceAction("device.media.play_pause", "Play / pause", "Media", ACTION_MEDIA_PLAY_PAUSE))
- add(deviceAction("device.media.previous", "Previous track", "Media", ACTION_MEDIA_PREVIOUS))
- add(deviceAction("device.media.next", "Next track", "Media", ACTION_MEDIA_NEXT))
- add(deviceAction("device.volume.up", "Volume up", "Audio", ACTION_VOLUME_UP))
- add(deviceAction("device.volume.down", "Volume down", "Audio", ACTION_VOLUME_DOWN))
- add(deviceAction("device.volume.mute", "Mute volume", "Audio", ACTION_VOLUME_MUTE))
- add(deviceAction("device.brightness.up", "Brightness up", "Display", ACTION_BRIGHTNESS_UP))
- add(deviceAction("device.brightness.down", "Brightness down", "Display", ACTION_BRIGHTNESS_DOWN))
- add(settingsCommand("settings.android.main", "Settings", Settings.ACTION_SETTINGS))
- add(settingsCommand("settings.android.apps", "Apps", Settings.ACTION_APPLICATION_SETTINGS))
- add(settingsCommand("settings.android.default_apps", "Default apps", Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS))
- add(settingsCommand("settings.android.input_method", "Keyboard settings", Settings.ACTION_INPUT_METHOD_SETTINGS))
- add(settingsCommand("settings.android.accessibility", "Accessibility", Settings.ACTION_ACCESSIBILITY_SETTINGS))
- add(settingsCommand("settings.android.language_input", "Language & input", Settings.ACTION_LOCALE_SETTINGS))
- add(settingsCommand("settings.android.bluetooth", "Bluetooth", Settings.ACTION_BLUETOOTH_SETTINGS))
- add(settingsCommand("settings.android.wifi", "Wi-Fi", Settings.ACTION_WIFI_SETTINGS))
+ add(deviceAction("device.home", context.getString(R.string.command_device_home_screen), context.getString(R.string.command_group_system), ACTION_HOME_SCREEN))
+ add(deviceAction("device.media.play_pause", context.getString(R.string.command_device_media_play_pause), context.getString(R.string.command_group_media), ACTION_MEDIA_PLAY_PAUSE))
+ add(deviceAction("device.media.previous", context.getString(R.string.command_device_media_previous), context.getString(R.string.command_group_media), ACTION_MEDIA_PREVIOUS))
+ add(deviceAction("device.media.next", context.getString(R.string.command_device_media_next), context.getString(R.string.command_group_media), ACTION_MEDIA_NEXT))
+ add(deviceAction("device.volume.up", context.getString(R.string.command_device_volume_up), context.getString(R.string.command_group_audio), ACTION_VOLUME_UP))
+ add(deviceAction("device.volume.down", context.getString(R.string.command_device_volume_down), context.getString(R.string.command_group_audio), ACTION_VOLUME_DOWN))
+ add(deviceAction("device.volume.mute", context.getString(R.string.command_device_volume_mute), context.getString(R.string.command_group_audio), ACTION_VOLUME_MUTE))
+ add(deviceAction("device.brightness.up", context.getString(R.string.command_device_brightness_up), context.getString(R.string.command_group_display), ACTION_BRIGHTNESS_UP))
+ add(deviceAction("device.brightness.down", context.getString(R.string.command_device_brightness_down), context.getString(R.string.command_group_display), ACTION_BRIGHTNESS_DOWN))
+ add(settingsCommand(context, "settings.android.main", context.getString(R.string.command_device_settings), Settings.ACTION_SETTINGS))
+ add(settingsCommand(context, "settings.android.apps", context.getString(R.string.command_device_apps), Settings.ACTION_APPLICATION_SETTINGS))
+ add(settingsCommand(context, "settings.android.default_apps", context.getString(R.string.command_device_default_apps), Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS))
+ add(settingsCommand(context, "settings.android.input_method", context.getString(R.string.command_device_keyboard_settings), Settings.ACTION_INPUT_METHOD_SETTINGS))
+ add(settingsCommand(context, "settings.android.accessibility", context.getString(R.string.command_device_accessibility), Settings.ACTION_ACCESSIBILITY_SETTINGS))
+ add(settingsCommand(context, "settings.android.language_input", context.getString(R.string.command_device_language_input), Settings.ACTION_LOCALE_SETTINGS))
+ add(settingsCommand(context, "settings.android.bluetooth", context.getString(R.string.command_device_bluetooth), Settings.ACTION_BLUETOOTH_SETTINGS))
+ add(settingsCommand(context, "settings.android.wifi", context.getString(R.string.command_device_wifi), Settings.ACTION_WIFI_SETTINGS))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- add(settingsCommand("settings.android.internet_panel", "Internet", Settings.Panel.ACTION_INTERNET_CONNECTIVITY, opensPanel = true))
+ add(settingsCommand(context, "settings.android.internet_panel", context.getString(R.string.command_device_internet), Settings.Panel.ACTION_INTERNET_CONNECTIVITY, opensPanel = true))
}
- add(settingsCommand("settings.android.display", "Display / brightness", Settings.ACTION_DISPLAY_SETTINGS))
- add(settingsCommand("settings.android.sound", "Sound & vibration", Settings.ACTION_SOUND_SETTINGS))
- add(settingsCommand("settings.android.nfc", "NFC", Settings.ACTION_NFC_SETTINGS))
- add(settingsCommand("settings.android.battery", "Battery", Settings.ACTION_BATTERY_SAVER_SETTINGS))
- add(settingsCommand("settings.android.notifications", "Notifications", ACTION_NOTIFICATION_SETTINGS))
+ add(settingsCommand(context, "settings.android.display", context.getString(R.string.command_device_display), Settings.ACTION_DISPLAY_SETTINGS))
+ add(settingsCommand(context, "settings.android.sound", context.getString(R.string.command_device_sound), Settings.ACTION_SOUND_SETTINGS))
+ add(settingsCommand(context, "settings.android.nfc", context.getString(R.string.command_device_nfc), Settings.ACTION_NFC_SETTINGS))
+ add(settingsCommand(context, "settings.android.battery", context.getString(R.string.command_device_battery), Settings.ACTION_BATTERY_SAVER_SETTINGS))
+ add(settingsCommand(context, "settings.android.notifications", context.getString(R.string.command_device_notifications), ACTION_NOTIFICATION_SETTINGS))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
add(
settingsCommand(
+ context = context,
id = "settings.android.pastiera_notifications",
- label = "Pastiera notifications",
+ label = context.getString(R.string.command_device_pastiera_notifications),
action = Settings.ACTION_APP_NOTIFICATION_SETTINGS,
data = null,
extras = mapOf(Settings.EXTRA_APP_PACKAGE to context.packageName)
@@ -71,6 +73,7 @@ class DeviceControlCommandSource : CommandSource {
}
private fun settingsCommand(
+ context: Context,
id: String,
label: String,
action: String,
@@ -83,7 +86,11 @@ class DeviceControlCommandSource : CommandSource {
source = this.id,
kind = CommandKind.DeviceControl,
label = label,
- subtitle = if (opensPanel) "System panel" else "Settings",
+ subtitle = if (opensPanel) {
+ context.getString(R.string.command_group_system_panel)
+ } else {
+ context.getString(R.string.command_group_settings)
+ },
icon = CommandIcon.Settings,
launch = SettingsIntentSpec(action, data, extras),
capabilities = buildSet {
diff --git a/app/src/main/java/it/palsoftware/pastiera/commands/NavCommandSource.kt b/app/src/main/java/it/palsoftware/pastiera/commands/NavCommandSource.kt
index 934923bd0..25590a2cb 100644
--- a/app/src/main/java/it/palsoftware/pastiera/commands/NavCommandSource.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/commands/NavCommandSource.kt
@@ -1,54 +1,67 @@
package it.palsoftware.pastiera.commands
import android.content.Context
+import it.palsoftware.pastiera.R
class NavCommandSource : CommandSource {
override val id = CommandSourceId.NavActions
override fun getCommands(context: Context): List {
- return keycodeCommands() + actionCommands()
+ return keycodeCommands(context) + actionCommands(context)
}
- private fun keycodeCommands(): List {
+ private fun keycodeCommands(context: Context): List {
return listOf(
- "DPAD_UP" to "Up",
- "DPAD_DOWN" to "Down",
- "DPAD_LEFT" to "Left",
- "DPAD_RIGHT" to "Right",
- "TAB" to "Tab",
- "MOVE_HOME" to "Home",
- "MOVE_END" to "End",
- "PAGE_UP" to "Page up",
- "PAGE_DOWN" to "Page down",
- "ESCAPE" to "Escape",
- "DPAD_CENTER" to "Center",
- "FORWARD_DEL" to "Forward delete"
+ "DPAD_UP" to context.getString(R.string.command_nav_keycode_up),
+ "DPAD_DOWN" to context.getString(R.string.command_nav_keycode_down),
+ "DPAD_LEFT" to context.getString(R.string.command_nav_keycode_left),
+ "DPAD_RIGHT" to context.getString(R.string.command_nav_keycode_right),
+ "TAB" to context.getString(R.string.command_nav_keycode_tab),
+ "MOVE_HOME" to context.getString(R.string.command_nav_keycode_home),
+ "MOVE_END" to context.getString(R.string.command_nav_keycode_end),
+ "PAGE_UP" to context.getString(R.string.command_nav_keycode_page_up),
+ "PAGE_DOWN" to context.getString(R.string.command_nav_keycode_page_down),
+ "ESCAPE" to context.getString(R.string.command_nav_keycode_escape),
+ "DPAD_CENTER" to context.getString(R.string.command_nav_keycode_center),
+ "FORWARD_DEL" to context.getString(R.string.command_nav_keycode_forward_delete)
).map { (value, label) ->
- navTarget("nav.keycode.$value", label, "Navigation", "keycode", value)
+ navTarget(
+ "nav.keycode.$value",
+ label,
+ context.getString(R.string.command_nav_navigation),
+ "keycode",
+ value
+ )
}
}
- private fun actionCommands(): List {
+ private fun actionCommands(context: Context): List {
return listOf(
- "copy" to "Copy",
- "paste" to "Paste",
- "cut" to "Cut",
- "undo" to "Undo",
- "select_all" to "Select all",
- "expand_selection_left" to "Select left",
- "expand_selection_right" to "Select right",
- "move_word_left" to "Word left",
- "move_word_right" to "Word right",
- "expand_selection_word_left" to "Select word left",
- "expand_selection_word_right" to "Select word right",
- "page_start" to "Page start",
- "page_end" to "Page end",
- "toggle_minimal_ui" to "Pastierina",
- "media_play_pause" to "Play/Pause",
- "media_previous" to "Media previous",
- "media_next" to "Media next"
+ "copy" to context.getString(R.string.command_nav_action_copy),
+ "paste" to context.getString(R.string.command_nav_action_paste),
+ "cut" to context.getString(R.string.command_nav_action_cut),
+ "undo" to context.getString(R.string.command_nav_action_undo),
+ "select_all" to context.getString(R.string.command_nav_action_select_all),
+ "expand_selection_left" to context.getString(R.string.command_nav_action_select_left),
+ "expand_selection_right" to context.getString(R.string.command_nav_action_select_right),
+ "move_word_left" to context.getString(R.string.command_nav_action_word_left),
+ "move_word_right" to context.getString(R.string.command_nav_action_word_right),
+ "expand_selection_word_left" to context.getString(R.string.command_nav_action_select_word_left),
+ "expand_selection_word_right" to context.getString(R.string.command_nav_action_select_word_right),
+ "page_start" to context.getString(R.string.command_nav_action_page_start),
+ "page_end" to context.getString(R.string.command_nav_action_page_end),
+ "toggle_minimal_ui" to context.getString(R.string.command_nav_action_pastierina),
+ "media_play_pause" to context.getString(R.string.command_nav_action_media_play_pause),
+ "media_previous" to context.getString(R.string.command_nav_action_media_previous),
+ "media_next" to context.getString(R.string.command_nav_action_media_next)
).map { (value, label) ->
- navTarget("nav.action.$value", label, "Action", "action", value)
+ navTarget(
+ "nav.action.$value",
+ label,
+ context.getString(R.string.command_nav_action_group),
+ "action",
+ value
+ )
}
}
diff --git a/app/src/main/java/it/palsoftware/pastiera/commands/PastieraCommandSource.kt b/app/src/main/java/it/palsoftware/pastiera/commands/PastieraCommandSource.kt
index 0a324ff01..ea7808fb4 100644
--- a/app/src/main/java/it/palsoftware/pastiera/commands/PastieraCommandSource.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/commands/PastieraCommandSource.kt
@@ -13,8 +13,8 @@ class PastieraCommandSource : CommandSource {
id = COMMAND_QUICK_LAUNCHER,
source = id,
kind = CommandKind.PastieraAction,
- label = "Pastiera QuickLauncher",
- subtitle = "Open Pastiera search",
+ label = context.getString(R.string.command_pastiera_quick_launcher),
+ subtitle = context.getString(R.string.command_pastiera_quick_launcher_description),
icon = CommandIcon.Search,
launch = CommandLaunchSpec.InternalAction(ACTION_OPEN_QUICK_LAUNCHER),
capabilities = setOf(CommandCapability.LaunchesActivity),
@@ -25,8 +25,8 @@ class PastieraCommandSource : CommandSource {
id = COMMAND_MAIN_ACTIVITY,
source = id,
kind = CommandKind.PastieraAction,
- label = "Pastiera",
- subtitle = "Open app settings",
+ label = context.getString(R.string.command_pastiera_open_settings),
+ subtitle = context.getString(R.string.command_pastiera_open_settings_description),
icon = CommandIcon.Settings,
launch = CommandLaunchSpec.InternalAction(ACTION_OPEN_MAIN_ACTIVITY),
capabilities = setOf(CommandCapability.LaunchesActivity),
@@ -37,8 +37,8 @@ class PastieraCommandSource : CommandSource {
id = COMMAND_TOGGLE_SOFTWARE_KEYBOARD_MODE,
source = id,
kind = CommandKind.PastieraAction,
- label = "Toggle Keyboard Mode",
- subtitle = "Switch Virtual / Hardware",
+ label = context.getString(R.string.command_pastiera_toggle_keyboard_mode),
+ subtitle = context.getString(R.string.command_pastiera_toggle_keyboard_mode_description),
icon = CommandIcon.DrawableIcon(ContextCompat.getDrawable(context, R.drawable.expansion_panels_24)),
launch = CommandLaunchSpec.InternalAction(ACTION_TOGGLE_SOFTWARE_KEYBOARD_MODE),
capabilities = setOf(CommandCapability.AdjustsDeviceState),
diff --git a/app/src/main/java/it/palsoftware/pastiera/core/composition/CompositionModels.kt b/app/src/main/java/it/palsoftware/pastiera/core/composition/CompositionModels.kt
new file mode 100644
index 000000000..5537ceb83
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/core/composition/CompositionModels.kt
@@ -0,0 +1,34 @@
+package it.palsoftware.pastiera.core.composition
+
+internal enum class CompositionInputSource { PHYSICAL, SOFTWARE }
+
+internal data class NormalizedCompositionStroke(
+ val jamo: Char,
+ val physicalKeyCode: Int? = null,
+ val source: CompositionInputSource = CompositionInputSource.PHYSICAL,
+ val shiftedByUser: Boolean = false
+)
+
+internal data class CompositionSnapshot(
+ val sessionId: Long,
+ val revision: Long,
+ val layoutId: String,
+ val renderedText: String,
+ val strokeCount: Int
+)
+
+internal sealed interface ComposerUpdate {
+ data object NotHandled : ComposerUpdate
+ data class Replace(val snapshot: CompositionSnapshot) : ComposerUpdate
+}
+
+internal interface TextComposer {
+ val isActive: Boolean
+ val snapshot: CompositionSnapshot?
+ val strokeCount: Int
+ val needsInternalSplit: Boolean
+
+ fun accept(stroke: NormalizedCompositionStroke): ComposerUpdate
+ fun backspace(): ComposerUpdate
+ fun reset()
+}
diff --git a/app/src/main/java/it/palsoftware/pastiera/core/composition/HangulComposer.kt b/app/src/main/java/it/palsoftware/pastiera/core/composition/HangulComposer.kt
new file mode 100644
index 000000000..96cb65c48
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/core/composition/HangulComposer.kt
@@ -0,0 +1,138 @@
+package it.palsoftware.pastiera.core.composition
+
+/** Pure Kotlin, bounded full-run two-set Hangul composer. */
+internal class HangulComposer(
+ private val layoutId: String = "korean_2set",
+ private val maxStrokes: Int = 128,
+ sessionId: Long = 1L
+) : TextComposer {
+ private var session = sessionId
+ private var revision = 0L
+ private val strokes = mutableListOf()
+ private var currentSnapshot: CompositionSnapshot? = null
+
+ override val isActive: Boolean get() = strokes.isNotEmpty()
+ override val snapshot: CompositionSnapshot? get() = currentSnapshot
+ override val strokeCount: Int get() = strokes.size
+ override val needsInternalSplit: Boolean get() = strokes.size >= maxStrokes
+
+ override fun accept(stroke: NormalizedCompositionStroke): ComposerUpdate {
+ if (!HangulJamo.isSupported(stroke.jamo) || needsInternalSplit) return ComposerUpdate.NotHandled
+ strokes += stroke
+ return replaceSnapshot()
+ }
+
+ override fun backspace(): ComposerUpdate {
+ if (strokes.isEmpty()) return ComposerUpdate.NotHandled
+ strokes.removeAt(strokes.lastIndex)
+ if (strokes.isEmpty()) {
+ revision++
+ currentSnapshot = null
+ return ComposerUpdate.Replace(CompositionSnapshot(session, revision, layoutId, "", 0))
+ }
+ return replaceSnapshot()
+ }
+
+ override fun reset() {
+ strokes.clear()
+ currentSnapshot = null
+ session++
+ revision = 0L
+ }
+
+ /** Retain enough original strokes to include the complete open syllable.
+ * A split is allowed only where independently replaying both parts is identical.
+ * Confirmed prefix deletion subsequently uses the editor's ordinary DEL policy.
+ */
+ fun internalSplit(): InternalSplit? {
+ val fullText = currentSnapshot?.renderedText ?: return null
+ for (cut in (strokes.size - 8) downTo 1) {
+ val prefix = render(strokes.take(cut).map { it.jamo })
+ val suffix = render(strokes.drop(cut).map { it.jamo })
+ if (prefix + suffix == fullText) {
+ strokes.subList(0, cut).clear()
+ replaceSnapshot()
+ return InternalSplit(prefix, fullText)
+ }
+ }
+ return null
+ }
+
+ data class InternalSplit(val prefix: String, val fullText: String)
+
+ fun beginNewRun() {
+ strokes.clear()
+ currentSnapshot = null
+ session++
+ revision = 0L
+ }
+
+ private fun replaceSnapshot(): ComposerUpdate.Replace {
+ revision++
+ val rendered = render(strokes.map { it.jamo })
+ val snapshot = CompositionSnapshot(session, revision, layoutId, rendered, strokes.size)
+ currentSnapshot = snapshot
+ return ComposerUpdate.Replace(snapshot)
+ }
+
+ private sealed interface Unit {
+ data class C(val value: Char) : Unit
+ data class V(val value: Char) : Unit
+ data class S(val initial: Char, val medial: Char, val final: Char? = null) : Unit
+ }
+
+ private fun render(input: List): String {
+ val output = mutableListOf()
+ var current: Unit? = null
+
+ fun flush() {
+ current?.let {
+ output += when (it) {
+ is Unit.C -> it.value.toString()
+ is Unit.V -> it.value.toString()
+ is Unit.S -> HangulJamo.compose(it.initial, it.medial, it.final).toString()
+ }
+ }
+ current = null
+ }
+
+ input.forEach { jamo ->
+ if (HangulJamo.isConsonant(jamo)) {
+ when (val state = current) {
+ null -> current = Unit.C(jamo)
+ is Unit.C -> { flush(); current = Unit.C(jamo) }
+ is Unit.V -> { flush(); current = Unit.C(jamo) }
+ is Unit.S -> if (state.final == null) {
+ val final = HangulJamo.finalIndex[jamo]
+ if (final != null && final > 0) current = state.copy(final = jamo) else { flush(); current = Unit.C(jamo) }
+ } else {
+ val combined = HangulJamo.combineFinal(state.final, jamo)
+ if (combined != null) current = state.copy(final = combined) else { flush(); current = Unit.C(jamo) }
+ }
+ }
+ } else {
+ when (val state = current) {
+ null -> current = Unit.V(jamo)
+ is Unit.C -> if (HangulJamo.initialIndex[state.value] != null) current = Unit.S(state.value, jamo) else { flush(); current = Unit.V(jamo) }
+ is Unit.V -> HangulJamo.combineMedial(state.value, jamo)?.let { current = Unit.V(it) } ?: run { flush(); current = Unit.V(jamo) }
+ is Unit.S -> if (state.final == null) {
+ HangulJamo.combineMedial(state.medial, jamo)?.let { current = state.copy(medial = it) } ?: run { flush(); current = Unit.V(jamo) }
+ } else {
+ val split = HangulJamo.splitFinal(state.final)
+ if (split != null) {
+ flushUnit(output, state.copy(final = split.first)); current = Unit.S(split.second, jamo)
+ } else {
+ flushUnit(output, state.copy(final = null)); current = Unit.S(state.final, jamo)
+ }
+ }
+ }
+ }
+ }
+ flush()
+ return output.joinToString("")
+ }
+
+ private fun flushUnit(output: MutableList, unit: Unit.S) {
+ output += HangulJamo.compose(unit.initial, unit.medial, unit.final).toString()
+ }
+}
diff --git a/app/src/main/java/it/palsoftware/pastiera/core/composition/HangulJamo.kt b/app/src/main/java/it/palsoftware/pastiera/core/composition/HangulJamo.kt
new file mode 100644
index 000000000..6b2f3c80b
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/core/composition/HangulJamo.kt
@@ -0,0 +1,58 @@
+package it.palsoftware.pastiera.core.composition
+
+internal object HangulJamo {
+ val consonants = setOf(
+ 'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
+ )
+ val vowels = setOf('ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅛ', 'ㅜ', 'ㅠ', 'ㅡ', 'ㅣ')
+ val allSupported = consonants + vowels
+
+ val choseong = listOf('ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ')
+ val jungseong = listOf('ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ', 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ')
+ val jongseong = listOf(null, 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ')
+
+ val medialCombinations = mapOf(
+ 'ㅗ' to mapOf('ㅏ' to 'ㅘ', 'ㅐ' to 'ㅙ', 'ㅣ' to 'ㅚ'),
+ 'ㅜ' to mapOf('ㅓ' to 'ㅝ', 'ㅔ' to 'ㅞ', 'ㅣ' to 'ㅟ'),
+ 'ㅡ' to mapOf('ㅣ' to 'ㅢ')
+ )
+
+ val finalCombinations = mapOf(
+ 'ㄱ' to mapOf('ㅅ' to 'ㄳ'),
+ 'ㄴ' to mapOf('ㅈ' to 'ㄵ', 'ㅎ' to 'ㄶ'),
+ 'ㄹ' to mapOf('ㄱ' to 'ㄺ', 'ㅁ' to 'ㄻ', 'ㅂ' to 'ㄼ', 'ㅅ' to 'ㄽ', 'ㅌ' to 'ㄾ', 'ㅍ' to 'ㄿ', 'ㅎ' to 'ㅀ'),
+ 'ㅂ' to mapOf('ㅅ' to 'ㅄ')
+ )
+
+ val initialIndex = choseong.withIndex().associate { it.value to it.index }
+ val medialIndex = jungseong.withIndex().associate { it.value to it.index }
+ val finalIndex = jongseong.withIndex().associate { it.value to it.index }
+
+ fun isConsonant(c: Char) = c in consonants
+ fun isVowel(c: Char) = c in vowels
+ fun isSupported(c: Char) = c in allSupported
+ fun combineMedial(a: Char, b: Char): Char? = medialCombinations[a]?.get(b)
+ fun combineFinal(a: Char, b: Char): Char? = finalCombinations[a]?.get(b)
+
+ fun splitFinal(final: Char): Pair? = when (final) {
+ 'ㄳ' -> 'ㄱ' to 'ㅅ'
+ 'ㄵ' -> 'ㄴ' to 'ㅈ'
+ 'ㄶ' -> 'ㄴ' to 'ㅎ'
+ 'ㄺ' -> 'ㄹ' to 'ㄱ'
+ 'ㄻ' -> 'ㄹ' to 'ㅁ'
+ 'ㄼ' -> 'ㄹ' to 'ㅂ'
+ 'ㄽ' -> 'ㄹ' to 'ㅅ'
+ 'ㄾ' -> 'ㄹ' to 'ㅌ'
+ 'ㄿ' -> 'ㄹ' to 'ㅍ'
+ 'ㅀ' -> 'ㄹ' to 'ㅎ'
+ 'ㅄ' -> 'ㅂ' to 'ㅅ'
+ else -> null
+ }
+
+ fun compose(initial: Char, medial: Char, final: Char? = null): Char {
+ val l = initialIndex[initial] ?: return initial
+ val v = medialIndex[medial] ?: return medial
+ val t = final?.let { finalIndex[it] } ?: 0
+ return (0xAC00 + ((l * 21) + v) * 28 + t).toChar()
+ }
+}
diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/CandidatesBarController.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/CandidatesBarController.kt
index 156f99012..12f2c8939 100644
--- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/CandidatesBarController.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/CandidatesBarController.kt
@@ -65,6 +65,13 @@ class CandidatesBarController(
candidatesStatusBar.onSuggestionCommitted = value
}
+ var onBeforeSuggestionCommitted: (() -> Unit)? = null
+ set(value) {
+ field = value
+ inputStatusBar.onBeforeSuggestionCommitted = value
+ candidatesStatusBar.onBeforeSuggestionCommitted = value
+ }
+
var onHideSuggestion: ((String) -> Unit)? = null
set(value) {
field = value
@@ -212,6 +219,13 @@ class CandidatesBarController(
candidatesStatusBar.onSoftwareKeyboardBoundaryTextInput = value
}
+ var onSoftwareKeyboardBackspace: ((InputConnection?) -> Boolean)? = null
+ set(value) {
+ field = value
+ inputStatusBar.onSoftwareKeyboardBackspace = value
+ candidatesStatusBar.onSoftwareKeyboardBackspace = value
+ }
+
var onMinimalUiToggleRequested: (() -> Unit)? = null
set(value) {
field = value
diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/LauncherShortcutAssignmentActivity.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/LauncherShortcutAssignmentActivity.kt
index b8f4fb970..e346923f5 100644
--- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/LauncherShortcutAssignmentActivity.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/LauncherShortcutAssignmentActivity.kt
@@ -255,7 +255,7 @@ private fun LauncherShortcutAssignmentBottomSheet(
filteredCommands
.groupBy { it.source }
.flatMap { (source, sourceCommands) ->
- listOf(CommandPickerEntry.Header(source.displayLabel)) +
+ listOf(CommandPickerEntry.Header(source.localizedDisplayLabel(context))) +
sourceCommands.map { CommandPickerEntry.Command(it) }
}
}
@@ -392,13 +392,13 @@ private fun LauncherShortcutAssignmentBottomSheet(
FilterChip(
selected = selectedSource == null,
onClick = { selectedSource = null },
- label = { Text("All") }
+ label = { Text(stringResource(R.string.quick_launcher_all)) }
)
availableSources.forEach { source ->
FilterChip(
selected = selectedSource == source,
onClick = { selectedSource = source },
- label = { Text(source.displayLabel) }
+ label = { Text(source.localizedDisplayLabel(context)) }
)
}
}
diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodService.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodService.kt
index 19cd0b5f6..e46172dc9 100644
--- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodService.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodService.kt
@@ -7,6 +7,7 @@ import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.res.Configuration
import it.palsoftware.pastiera.AppBroadcastActions
+import it.palsoftware.pastiera.AppLocaleManager
import it.palsoftware.pastiera.ClicksPowerKeyboardController
import it.palsoftware.pastiera.SettingsManager
import it.palsoftware.pastiera.SoftwareKeyboardModeActions
@@ -60,12 +61,18 @@ import it.palsoftware.pastiera.inputmethod.aospkeyboard.AospKeyboardView
import it.palsoftware.pastiera.inputmethod.aospkeyboard.SoftwareKeyboardLayoutTemplates
import it.palsoftware.pastiera.inputmethod.aospkeyboard.SoftwareKeyboardSymLabels
import it.palsoftware.pastiera.inputmethod.subtype.AdditionalSubtypeUtils.localeString
-import it.palsoftware.pastiera.inputmethod.subtype.AdditionalSubtypeUtils.setAdditionalInputMethodSubtypesCompat
import it.palsoftware.pastiera.inputmethod.telex.VietnameseTelexProcessor
import it.palsoftware.pastiera.inputmethod.trackpad.TrackpadEventDeviceResolver
import it.palsoftware.pastiera.inputmethod.trackpad.TrackpadGestureDetector
import it.palsoftware.pastiera.inputmethod.trackpad.TrackpadAxisRange
import it.palsoftware.pastiera.inputmethod.trackpad.TrackpadCoordinateMapper
+import it.palsoftware.pastiera.inputmethod.composition.CompositionFinishReason
+import it.palsoftware.pastiera.inputmethod.composition.ImeCompositionCoordinator
+import it.palsoftware.pastiera.inputmethod.composition.LayoutCapabilitiesRegistry
+import it.palsoftware.pastiera.inputmethod.composition.ComposerKind
+import it.palsoftware.pastiera.core.composition.NormalizedCompositionStroke
+import it.palsoftware.pastiera.core.composition.CompositionInputSource
+import it.palsoftware.pastiera.core.composition.HangulJamo
import it.palsoftware.pastiera.inputmethod.expansion.ExpansionRuntimeConfig
import it.palsoftware.pastiera.inputmethod.expansion.ExpansionTriggerKind
import it.palsoftware.pastiera.inputmethod.expansion.SnippetExpansionSource
@@ -85,6 +92,10 @@ import rikka.shizuku.Shizuku
*/
class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibilityKeyBridge.Target {
+ override fun attachBaseContext(base: Context) {
+ super.attachBaseContext(AppLocaleManager.wrapContext(base))
+ }
+
companion object {
private const val TAG = "PastieraInputMethod"
private const val TRACKPAD_DEBUG_TAG = "TrackpadDebug"
@@ -163,6 +174,12 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
private var activeKeyboardLayoutName: String = "qwerty"
private var consumeAltEnterUntilKeyUp: Boolean = false
private var dispatchingSoftwareKeyboardKey: Boolean = false
+ private val compositionCoordinator = ImeCompositionCoordinator(
+ onCompositionStarted = {
+ if (::suggestionController.isInitialized) suggestionController.onContextReset()
+ if (::textExpansionController.isInitialized) textExpansionController.clear()
+ }
+ )
// Aggiungi per Power Shortcuts
private var powerShortcutToast: android.widget.Toast? = null
@@ -810,6 +827,7 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
private fun sendCtrlShortcut(keyCode: Int, shift: Boolean = false): Boolean {
val ic = currentInputConnection ?: return false
+ compositionCoordinator.finish(ic, CompositionFinishReason.COMMAND)
val now = System.currentTimeMillis()
val metaState = KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON or
if (shift) {
@@ -1340,9 +1358,41 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
}
private fun requestAutoCapShiftOneShot(): Boolean {
+ if (!LayoutCapabilitiesRegistry.forLayout(activeKeyboardLayoutName).supportsAutoCapitalization) return false
if (isAutoCapSuppressedAtCursor()) return false
return modifierStateController.requestShiftOneShotFromAutoCap()
}
+
+ private fun isHangulLayoutActive(): Boolean =
+ LayoutCapabilitiesRegistry.forLayout(activeKeyboardLayoutName).composerKind == ComposerKind.HANGUL
+
+ private fun resolveKoreanStroke(keyCode: Int, event: KeyEvent?): NormalizedCompositionStroke? {
+ if (!isHangulLayoutActive()) return null
+ val mapping = LayoutMappingRepository.getMapping(keyCode) ?: return null
+ val userShift = event?.isShiftPressed == true || shiftPhysicallyPressed || shiftOneShot
+ val text = LayoutMappingRepository.resolveText(mapping, userShift) ?: return null
+ if (text.length != 1 || !HangulJamo.isSupported(text[0])) return null
+ return NormalizedCompositionStroke(
+ jamo = text[0],
+ physicalKeyCode = keyCode,
+ source = if (dispatchingSoftwareKeyboardKey) CompositionInputSource.SOFTWARE else CompositionInputSource.PHYSICAL,
+ shiftedByUser = userShift
+ )
+ }
+
+ private fun shouldKeepHangulForKey(keyCode: Int, event: KeyEvent?): Boolean {
+ if (!isHangulLayoutActive()) return false
+ if (keyCode == KeyEvent.KEYCODE_DEL) {
+ return !(event?.isCtrlPressed == true || event?.isAltPressed == true || event?.isMetaPressed == true)
+ }
+ if (isPureModifierKey(keyCode)) {
+ return keyCode == KeyEvent.KEYCODE_SHIFT_LEFT || keyCode == KeyEvent.KEYCODE_SHIFT_RIGHT
+ }
+ val stroke = resolveKoreanStroke(keyCode, event)
+ return stroke != null && !isCtrlModifierActive(event) &&
+ !(event?.isAltPressed == true || altPressed || altPhysicallyPressed || altLatchActive || altOneShot) &&
+ !symTogglePendingOnKeyUp && !navModeController.isNavModeActive()
+ }
@@ -1423,6 +1473,7 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
}
activeKeyboardLayoutName = layoutName
val layout = LayoutMappingRepository.loadLayout(assets, layoutName, this)
+ compositionCoordinator.selectLayout(layoutName)
Log.d(TAG, "Keyboard layout loaded: $layoutName")
}
@@ -1455,8 +1506,17 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
}
private fun switchToLayout(layoutName: String, showToast: Boolean) {
+ compositionCoordinator.finish(currentInputConnection, CompositionFinishReason.LAYOUT_SWITCH)
activeKeyboardLayoutName = layoutName
+ // A Latin field may already have requested an auto-cap one-shot before a subtype
+ // switch. Never carry that stale shift into Korean, where it would turn the first
+ // consonant into a tense consonant. A manual Shift pressed after the switch still
+ // works normally.
+ if (isHangulLayoutActive() && shiftOneShot) {
+ modifierStateController.consumeShiftOneShot()
+ }
LayoutMappingRepository.loadLayout(assets, layoutName, this)
+ compositionCoordinator.selectLayout(layoutName)
variationStateController = VariationStateController(
VariationRepository.loadVariations(assets, this, activeKeyboardLayoutName)
)
@@ -1706,7 +1766,9 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
suggestionController.preloadDictionary()
// Initialize clipboard history manager first (needed by candidatesBarController)
- clipboardHistoryManager = ClipboardHistoryManager(this)
+ clipboardHistoryManager = ClipboardHistoryManager(this) { inputConnection ->
+ compositionCoordinator.finish(inputConnection, CompositionFinishReason.BOUNDARY)
+ }
clipboardHistoryManager.onCreate()
candidatesBarController = CandidatesBarController(this, clipboardHistoryManager, assets, PhysicalKeyboardInputMethodService::class.java)
@@ -1792,6 +1854,9 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
suggestionController.readInitialContext(currentInputConnection)
updateStatusBarText()
}
+ candidatesBarController.onBeforeSuggestionCommitted = {
+ compositionCoordinator.finish(currentInputConnection, CompositionFinishReason.SUGGESTION_COMMIT)
+ }
candidatesBarController.onHideSuggestion = { suggestion ->
suggestionController.dismissSuggestion(suggestion, hardDeleteUserWord = false)
updateStatusBarText()
@@ -1965,18 +2030,23 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
}
candidatesBarController.onSoftwareKeyboardTextInput = { text, inputConnection, snapshot ->
val ic = inputConnection ?: currentInputConnection
- val consumedShiftOneShot = text.length == 1 &&
- text[0].isLetter() &&
- modifierStateController.consumeShiftOneShot()
+ val hadShiftOneShot = shiftOneShot
val handled = handleSoftwareKeyboardTextInput(text, ic, snapshot)
- if (consumedShiftOneShot) {
- updateStatusBarText()
+ if (handled && text.length == 1 && text[0].isLetter()) {
+ modifierStateController.consumeShiftOneShot()
}
+ if (hadShiftOneShot && !shiftOneShot) updateStatusBarText()
handled
}
candidatesBarController.onSoftwareKeyboardBoundaryTextInput = { text, inputConnection ->
+ if (compositionCoordinator.isActive) {
+ compositionCoordinator.finish(inputConnection ?: currentInputConnection, CompositionFinishReason.BOUNDARY)
+ }
handleBoundaryTextBeforeCommit(text, inputConnection)
}
+ candidatesBarController.onSoftwareKeyboardBackspace = { inputConnection ->
+ compositionCoordinator.handleBackspace(inputConnection ?: currentInputConnection)
+ }
candidatesBarController.onMinimalUiToggleRequested = {
keyboardVisibilityController.togglePastierinaMode()
}
@@ -2118,7 +2188,7 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
AutoCorrector.loadCorrections(assets, this)
// Register additional subtypes (custom input styles)
- registerAdditionalSubtypes()
+ AdditionalSubtypeUtils.registerAdditionalSubtypes(this)
// Trackpad gestures detector (instantiated early to avoid late-init issues in listener)
Log.d(TRACKPAD_DEBUG_TAG, "onCreate: Building initial trackpad gesture detector...")
@@ -2213,7 +2283,7 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
switchToLayout(activeKeyboardLayoutName, showToast = false)
} else if (key == AdditionalSubtypeUtils.PREF_CUSTOM_INPUT_STYLES) {
Log.d(TAG, "Custom input styles changed, re-registering subtypes...")
- registerAdditionalSubtypes()
+ AdditionalSubtypeUtils.registerAdditionalSubtypes(this)
} else if (key == "trackpad_gestures_enabled") {
val newValue = SettingsManager.getTrackpadGesturesEnabled(this)
Log.d(TRACKPAD_DEBUG_TAG, "SharedPrefs listener: trackpad_gestures_enabled changed to $newValue")
@@ -2348,6 +2418,7 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
fun tryInsertText() {
val inputConnection = currentInputConnection
if (inputConnection != null) {
+ compositionCoordinator.finish(inputConnection, CompositionFinishReason.BOUNDARY)
inputConnection.commitText(text, 1)
Log.d(TAG, "Speech text inserted successfully: $text")
} else {
@@ -2469,6 +2540,20 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
): Boolean {
val ic = inputConnection ?: return false
+ if (isHangulLayoutActive()) {
+ val jamo = text.singleOrNull()?.takeIf { HangulJamo.isSupported(it) }
+ if (jamo != null) {
+ return compositionCoordinator.handleMappedStroke(
+ ic,
+ NormalizedCompositionStroke(jamo, source = CompositionInputSource.SOFTWARE),
+ consumeShiftOneShot = { if (shiftOneShot) modifierStateController.consumeShiftOneShot() }
+ )
+ }
+ if (compositionCoordinator.isActive) {
+ compositionCoordinator.finish(ic, if (text == " ") CompositionFinishReason.BOUNDARY else CompositionFinishReason.PUNCTUATION)
+ }
+ }
+
if (text == " ") {
if (::textExpansionController.isInitialized &&
textExpansionController.handleKeyDown(KeyEvent.KEYCODE_SPACE)
@@ -2526,6 +2611,7 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
shouldDisableAutoCorrect: Boolean = inputContextState.shouldDisableAutoCorrect
): Boolean {
val ic = inputConnection ?: return false
+ compositionCoordinator.finish(ic, CompositionFinishReason.BOUNDARY)
if (text.length != 1) return false
val boundary = it.palsoftware.pastiera.core.Punctuation.normalizeApostrophe(text[0])
if (boundary == '\'' || boundary !in it.palsoftware.pastiera.core.Punctuation.BOUNDARY) {
@@ -2745,6 +2831,8 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
}
override fun onDestroy() {
+ compositionCoordinator.finish(currentInputConnection, CompositionFinishReason.WINDOW_HIDDEN)
+ compositionCoordinator.resetWithoutEditorMutation()
ClicksAccessibilityKeyBridge.unregister(this)
clicksPowerShiftTapFilter.reset()
accidentalKeyPressFilter.reset()
@@ -3350,6 +3438,8 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
pendingKeyboardSurfaceTransition?.let(uiHandler::removeCallbacks)
pendingKeyboardSurfaceTransition = null
super.onStartInput(info, restarting)
+ compositionCoordinator.startEditorSession()
+ compositionCoordinator.selectLayout(activeKeyboardLayoutName)
if (::textExpansionController.isInitialized) textExpansionController.clear()
if (
!restarting ||
@@ -3516,6 +3606,7 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
}
override fun onFinishInput() {
+ compositionCoordinator.finish(currentInputConnection, CompositionFinishReason.INPUT_FINISH)
pendingKeyboardSurfaceTransition?.let(uiHandler::removeCallbacks)
pendingKeyboardSurfaceTransition = null
super.onFinishInput()
@@ -3541,6 +3632,7 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
}
override fun onFinishInputView(finishingInput: Boolean) {
+ if (finishingInput) compositionCoordinator.finish(currentInputConnection, CompositionFinishReason.INPUT_FINISH)
super.onFinishInputView(finishingInput)
if (::textExpansionController.isInitialized) textExpansionController.clear()
// Finishing a view does not finish the editor session (Back and backend transitions).
@@ -3620,342 +3712,6 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
)
}
- /**
- * Registers additional subtypes (custom input styles) with the system.
- * Called on startup and when custom input styles are modified.
- */
- private fun registerAdditionalSubtypes() {
- try {
- val imm = getSystemService(InputMethodManager::class.java)
-
- // Get IME ID - try both formats
- val componentName = android.content.ComponentName(this, PhysicalKeyboardInputMethodService::class.java)
- val imeIdShort = componentName.flattenToShortString()
- val imeIdFull = componentName.flattenToString()
-
- // Find the actual IME in the system list to get the correct ID format
- val inputMethodInfo = imm.getInputMethodList().firstOrNull { info ->
- info.packageName == packageName &&
- info.serviceName == PhysicalKeyboardInputMethodService::class.java.name
- }
-
- val imeId = inputMethodInfo?.id ?: imeIdFull
-
- Log.d(TAG, "Registering additional subtypes")
- Log.d(TAG, "Component: $componentName")
- Log.d(TAG, "IME ID (short): $imeIdShort")
- Log.d(TAG, "IME ID (full): $imeIdFull")
- Log.d(TAG, "IME ID (from system): ${inputMethodInfo?.id}")
- Log.d(TAG, "Using IME ID: $imeId")
- Log.d(TAG, "IME found in system: ${inputMethodInfo != null}")
-
- val prefString = SettingsManager.getCustomInputStyles(this)
- Log.d(TAG, "Custom input styles pref string: $prefString")
-
- val subtypes = AdditionalSubtypeUtils.createAdditionalSubtypesArray(
- prefString,
- assets,
- this
- )
-
- Log.d(TAG, "Created ${subtypes.size} additional subtypes")
- subtypes.forEachIndexed { index, subtype ->
- Log.d(TAG, "Subtype $index: locale=${subtype.localeString()}, nameResId=${subtype.nameResId}, extraValue=${subtype.extraValue}")
- }
-
- if (subtypes.isNotEmpty() && inputMethodInfo != null) {
- // Note: setAdditionalInputMethodSubtypes is deprecated but still works on most Android versions
- // The subtypes will appear in the IME picker but may need to be enabled manually by the user
- setAdditionalInputMethodSubtypesCompat(imm, imeId, subtypes)
- Log.d(TAG, "Successfully called setAdditionalInputMethodSubtypes with ${subtypes.size} subtypes")
-
- // Send broadcast to notify system of IME subtype changes (if supported)
- try {
- val intent = Intent("android.view.InputMethod.SUBTYPE_CHANGED").apply {
- setPackage("android")
- putExtra("imeId", imeId)
- }
- sendBroadcast(intent)
- Log.d(TAG, "Sent SUBTYPE_CHANGED broadcast")
- } catch (e: Exception) {
- Log.w(TAG, "Could not send SUBTYPE_CHANGED broadcast", e)
- }
-
- // Try to explicitly enable the additional subtypes after a delay
- // This ensures the system has processed the registration first
- Handler(Looper.getMainLooper()).postDelayed({
- try {
- // Re-fetch InputMethodInfo to get updated subtype list
- val updatedInfo = imm.getInputMethodList().firstOrNull {
- it.packageName == packageName &&
- it.serviceName == PhysicalKeyboardInputMethodService::class.java.name
- }
-
- if (updatedInfo != null) {
- // Get all subtypes from InputMethodInfo (including base from method.xml and additional)
- val allSubtypes = mutableListOf()
- for (i in 0 until updatedInfo.subtypeCount) {
- allSubtypes.add(updatedInfo.getSubtypeAt(i))
- }
-
- // Get current system locales to filter out removed ones
- val currentSystemLocales = getSystemEnabledLocales()
- val systemLanguageCodes = currentSystemLocales.map { locale ->
- locale.split("_").first().lowercase()
- }.toSet()
-
- // Filter ALL subtypes (base + additional) to keep only visible, valid input styles.
- val validSubtypes = allSubtypes.filter { subtype ->
- AdditionalSubtypeUtils.shouldKeepSubtype(
- this,
- assets,
- subtype,
- currentSystemLocales,
- systemLanguageCodes
- )
- }
-
- // Convert to hash codes for setExplicitlyEnabledInputMethodSubtypes
- val validEnabledHashCodes = validSubtypes.map { it.hashCode() }.toIntArray()
-
- // Always update enabled subtypes, even if empty (to disable removed ones)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
- imm.setExplicitlyEnabledInputMethodSubtypes(
- updatedInfo.id,
- validEnabledHashCodes
- )
- val removedBase = allSubtypes.count { !AdditionalSubtypeUtils.isAdditionalSubtype(it) } -
- validSubtypes.count { !AdditionalSubtypeUtils.isAdditionalSubtype(it) }
- val removedAdditional = subtypes.size - validSubtypes.count { AdditionalSubtypeUtils.isAdditionalSubtype(it) }
- Log.d(TAG, "Updated enabled subtypes: ${validEnabledHashCodes.size} valid (removed ${removedBase} base, ${removedAdditional} additional)")
- } else {
- Log.d(TAG, "Skipping explicit subtype enable: requires Android 14+")
- }
- }
- } catch (e: Exception) {
- Log.w(TAG, "Could not explicitly enable subtypes", e)
- e.printStackTrace()
- }
- }, 500) // Wait 500ms for system to process registration
- } else {
- // Even when there are no additional subtypes, we should still filter enabled subtypes
- // to remove base subtypes corresponding to removed system locales
- if (inputMethodInfo != null) {
- Handler(Looper.getMainLooper()).postDelayed({
- try {
- val updatedInfo = imm.getInputMethodList().firstOrNull {
- it.packageName == packageName &&
- it.serviceName == PhysicalKeyboardInputMethodService::class.java.name
- }
-
- if (updatedInfo != null) {
- // Get all subtypes from InputMethodInfo (base subtypes from method.xml)
- val allSubtypes = mutableListOf()
- for (i in 0 until updatedInfo.subtypeCount) {
- allSubtypes.add(updatedInfo.getSubtypeAt(i))
- }
-
- val currentSystemLocales = getSystemEnabledLocales()
- val systemLanguageCodes = currentSystemLocales.map { locale ->
- locale.split("_").first().lowercase()
- }.toSet()
-
- // Filter to keep only visible subtypes with valid system locales.
- val validSubtypes = allSubtypes.filter { subtype ->
- AdditionalSubtypeUtils.shouldKeepSubtype(
- this,
- assets,
- subtype,
- currentSystemLocales,
- systemLanguageCodes
- )
- }
-
- val validEnabledHashCodes = validSubtypes.map { it.hashCode() }.toIntArray()
-
- // Always update to disable removed subtypes
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
- imm.setExplicitlyEnabledInputMethodSubtypes(
- updatedInfo.id,
- validEnabledHashCodes
- )
- Log.d(TAG, "Filtered base subtypes: kept ${validEnabledHashCodes.size}, removed ${allSubtypes.size - validSubtypes.size}")
- } else {
- Log.d(TAG, "Skipping base subtype filter update: requires Android 14+")
- }
- }
- } catch (e: Exception) {
- Log.w(TAG, "Could not filter enabled subtypes", e)
- }
- }, 500)
- }
-
- if (subtypes.isEmpty()) {
- Log.d(TAG, "No subtypes to register")
- } else {
- Log.w(TAG, "Cannot register subtypes: InputMethodInfo not found")
- }
- }
-
- // Refresh subtype caches if needed
- refreshSubtypeCaches()
-
- // Force a small delay to ensure system processes the registration
- Handler(Looper.getMainLooper()).postDelayed({
- try {
- val verifyInfo = imm.getInputMethodList().firstOrNull {
- it.packageName == packageName &&
- it.serviceName == PhysicalKeyboardInputMethodService::class.java.name
- }
- if (verifyInfo != null) {
- // Check all subtypes (enabled and disabled)
- val allSubtypes = imm.getEnabledInputMethodSubtypeList(verifyInfo, true)
- Log.d(TAG, "Verification: ${allSubtypes.size} total subtypes found after registration")
- allSubtypes.forEachIndexed { index, subtype ->
- val isAdditional = AdditionalSubtypeUtils.isAdditionalSubtype(subtype)
- Log.d(TAG, "Subtype $index: locale=${subtype.localeString()}, isAdditional=$isAdditional, extraValue=${subtype.extraValue}")
- }
-
- // Also try to get subtypes directly from InputMethodInfo
- try {
- val subtypeCount = verifyInfo.subtypeCount
- Log.d(TAG, "InputMethodInfo reports $subtypeCount subtypes")
- for (i in 0 until subtypeCount) {
- val subtype = verifyInfo.getSubtypeAt(i)
- val isAdditional = AdditionalSubtypeUtils.isAdditionalSubtype(subtype)
- Log.d(TAG, "InputMethodInfo subtype $i: locale=${subtype.localeString()}, isAdditional=$isAdditional, extraValue=${subtype.extraValue}")
- }
- } catch (e: Exception) {
- Log.w(TAG, "Error getting subtypes from InputMethodInfo", e)
- }
- } else {
- Log.w(TAG, "IME not found in system list for verification")
- }
- } catch (e: Exception) {
- Log.e(TAG, "Error verifying subtype registration", e)
- }
- }, 1000)
- } catch (e: Exception) {
- Log.e(TAG, "Error registering additional subtypes", e)
- e.printStackTrace()
- }
- }
-
- /**
- * Refreshes subtype caches after registration.
- * This ensures getEnabledInputMethodSubtypeList reflects the new subtypes.
- */
- private fun refreshSubtypeCaches() {
- try {
- val imm = getSystemService(InputMethodManager::class.java)
- // Force refresh by getting the enabled subtypes list
- val inputMethodInfo = imm.getInputMethodList().firstOrNull {
- it.id == packageName + "/" + PhysicalKeyboardInputMethodService::class.java.name
- }
- if (inputMethodInfo != null) {
- val enabledSubtypes = imm.getEnabledInputMethodSubtypeList(inputMethodInfo, true)
- Log.d(TAG, "Refreshed subtype caches, ${enabledSubtypes.size} enabled subtypes")
- }
- } catch (e: Exception) {
- Log.e(TAG, "Error refreshing subtype caches", e)
- }
- }
-
- /**
- * Finds a subtype by locale.
- */
- private fun findSubtypeByLocale(locale: String): android.view.inputmethod.InputMethodSubtype? {
- return try {
- val imm = getSystemService(InputMethodManager::class.java)
- val inputMethodInfo = imm.getInputMethodList().firstOrNull {
- it.id == packageName + "/" + PhysicalKeyboardInputMethodService::class.java.name
- }
- if (inputMethodInfo != null) {
- val enabledSubtypes = imm.getEnabledInputMethodSubtypeList(inputMethodInfo, true)
- AdditionalSubtypeUtils.findSubtypeByLocale(enabledSubtypes.toTypedArray(), locale)
- } else {
- null
- }
- } catch (e: Exception) {
- Log.e(TAG, "Error finding subtype by locale: $locale", e)
- null
- }
- }
-
- /**
- * Finds a subtype by locale and keyboard layout set.
- */
- private fun findSubtypeByLocaleAndKeyboardLayoutSet(
- locale: String,
- layoutName: String
- ): android.view.inputmethod.InputMethodSubtype? {
- return try {
- val imm = getSystemService(InputMethodManager::class.java)
- val inputMethodInfo = imm.getInputMethodList().firstOrNull {
- it.id == packageName + "/" + PhysicalKeyboardInputMethodService::class.java.name
- }
- if (inputMethodInfo != null) {
- val enabledSubtypes = imm.getEnabledInputMethodSubtypeList(inputMethodInfo, true)
- AdditionalSubtypeUtils.findSubtypeByLocaleAndKeyboardLayoutSet(
- enabledSubtypes.toTypedArray(),
- locale,
- layoutName
- )
- } else {
- null
- }
- } catch (e: Exception) {
- Log.e(TAG, "Error finding subtype by locale and layout: $locale:$layoutName", e)
- null
- }
- }
-
- /**
- * Gets the list of system-enabled locales.
- * Returns locales in format "en_US", "it_IT", etc.
- */
- private fun getSystemEnabledLocales(): Set {
- val locales = mutableSetOf()
- try {
- val config = resources.configuration
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- // Android N+ (API 24+)
- val localeList = config.locales
- for (i in 0 until localeList.size()) {
- val locale = localeList[i]
- val localeStr = formatLocaleStringForSystem(locale)
- if (localeStr.isNotEmpty()) {
- locales.add(localeStr)
- }
- }
- } else {
- // Pre-Android N
- @Suppress("DEPRECATION")
- val locale = config.locale
- val localeStr = formatLocaleStringForSystem(locale)
- if (localeStr.isNotEmpty()) {
- locales.add(localeStr)
- }
- }
- } catch (e: Exception) {
- Log.w(TAG, "Error getting system locales", e)
- }
- return locales
- }
-
- /**
- * Formats a Locale object to "en_US" format.
- */
- private fun formatLocaleStringForSystem(locale: Locale): String {
- val language = locale.language
- val country = locale.country
- return if (country.isNotEmpty()) {
- "${language}_$country"
- } else {
- language
- }
- }
-
/**
* Gets the locale from an IME subtype.
* Falls back to the current subtype, then Italian if no subtype is available.
@@ -4006,6 +3762,7 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
null
}
}
+ .filter { locale -> it.palsoftware.pastiera.core.suggestions.AndroidDictionaryRepository.hasDictionaryForLocale(this, locale) }
}
/**
@@ -4013,6 +3770,7 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
* Reloads the dictionary for the new language and switches to the layout specified in the subtype or JSON mapping.
*/
override fun onCurrentInputMethodSubtypeChanged(newSubtype: android.view.inputmethod.InputMethodSubtype) {
+ compositionCoordinator.finish(currentInputConnection, CompositionFinishReason.SUBTYPE_SWITCH)
super.onCurrentInputMethodSubtypeChanged(newSubtype)
if (::suggestionController.isInitialized) {
@@ -4038,6 +3796,7 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
}
override fun onWindowHidden() {
+ compositionCoordinator.finish(currentInputConnection, CompositionFinishReason.WINDOW_HIDDEN)
pendingKeyboardSurfaceTransition?.let(uiHandler::removeCallbacks)
pendingKeyboardSurfaceTransition = null
super.onWindowHidden()
@@ -4074,8 +3833,7 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
// (only when configuration changes, not when manually adding styles)
AdditionalSubtypeUtils.removeSystemLocalesWithoutDictionary(this)
// Then, auto-add new system locales without dictionary
- AdditionalSubtypeUtils.autoAddSystemLocalesWithoutDictionary(this)
- registerAdditionalSubtypes()
+ AdditionalSubtypeUtils.registerAdditionalSubtypes(this)
}, 500) // Small delay to ensure system has processed locale changes
}
@@ -4092,6 +3850,19 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
) {
val perfStart = ImePerfLogger.mark()
super.onUpdateSelection(oldSelStart, oldSelEnd, newSelStart, newSelEnd, candidatesStart, candidatesEnd)
+
+ when (compositionCoordinator.onUpdateSelection(
+ oldSelStart, oldSelEnd, newSelStart, newSelEnd, candidatesStart, candidatesEnd
+ )) {
+ it.palsoftware.pastiera.inputmethod.composition.SelectionUpdateDisposition.OWN_COMPOSING_UPDATE -> {
+ editorHasActiveSelection = false
+ return
+ }
+ it.palsoftware.pastiera.inputmethod.composition.SelectionUpdateDisposition.EXTERNAL_CHANGE -> {
+ try { currentInputConnection?.finishComposingText() } catch (_: Exception) { }
+ }
+ else -> Unit
+ }
val state = inputContextState
val cursorPositionChanged = (oldSelStart != newSelStart) || (oldSelEnd != newSelEnd)
@@ -4840,6 +4611,42 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
val ic = currentInputConnection
val state = inputContextState
val isAutoCorrectEnabled = SettingsManager.getAutoCorrectEnabled(this) && !state.shouldDisableAutoCorrect
+
+ // Hangul owns mapped jamo and plain DEL before generic text/autocorrect handling.
+ // This prevents the editor's Latin unicodeChar fallback from touching deferred-space state.
+ if (isHangulLayoutActive() && ic != null) {
+ val useGenericRouting = isNumericField || symLayoutController.isSymActive() || isCtrlModifierActive(event) ||
+ event?.isAltPressed == true || altPressed || altPhysicallyPressed || altLatchActive || altOneShot ||
+ event?.isMetaPressed == true || symTogglePendingOnKeyUp || navModeController.isNavModeActive()
+ val plainDelete = keyCode == KeyEvent.KEYCODE_DEL &&
+ !editorHasActiveSelection &&
+ !(event?.isShiftPressed == true || shiftPressed || shiftPhysicallyPressed || shiftOneShot || shiftLayerLatched) &&
+ !useGenericRouting
+ if (plainDelete) {
+ if (compositionCoordinator.handleBackspace(ic)) return true
+ }
+ if (!useGenericRouting) {
+ val stroke = resolveKoreanStroke(keyCode, event)
+ if (stroke != null) {
+ DeferredPunctuationSpaceTracker.prepareForTextCommit(this, ic, stroke.jamo.toString())
+ if (compositionCoordinator.handleMappedStroke(
+ ic,
+ stroke,
+ consumeShiftOneShot = {
+ if (shiftOneShot) modifierStateController.consumeShiftOneShot()
+ }
+ )) return true
+ }
+ }
+ if (compositionCoordinator.isActive && (useGenericRouting || !shouldKeepHangulForKey(keyCode, event))) {
+ compositionCoordinator.finish(ic, when (keyCode) {
+ KeyEvent.KEYCODE_ENTER -> CompositionFinishReason.ENTER
+ KeyEvent.KEYCODE_SPACE -> CompositionFinishReason.BOUNDARY
+ KeyEvent.KEYCODE_DEL -> CompositionFinishReason.COMMAND
+ else -> CompositionFinishReason.COMMAND
+ })
+ }
+ }
if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_DEL) {
DeferredPunctuationSpaceTracker.clear()
}
@@ -5312,96 +5119,9 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
alternateCharacterManager.removeAltKeyMapping(keyCode)
}
- /**
- * Updates additional IME subtypes from SharedPreferences.
- * This must be called from within the IME service process.
- */
private fun updateAdditionalSubtypes() {
- try {
- val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
- val packageName = packageName
- val serviceName = PhysicalKeyboardInputMethodService::class.java.name
-
- val imeInfo = imm.enabledInputMethodList.find {
- it.packageName == packageName &&
- it.serviceName == serviceName
- } ?: run {
- Log.w(TAG, "IME not found, cannot update additional subtypes")
- return
- }
-
- val imeId = imeInfo.id
- val additionalSubtypes = SettingsManager.getAdditionalImeSubtypes(this)
-
- Log.d(TAG, "Updating additional subtypes from IME service: ${additionalSubtypes.joinToString(", ")}")
-
- if (additionalSubtypes.isEmpty()) {
- // Clear additional subtypes
- setAdditionalInputMethodSubtypesCompat(imm, imeId, emptyArray())
- Log.d(TAG, "Cleared additional subtypes")
- return
- }
-
- // Build subtypes
- val subtypes = additionalSubtypes.map { langCode ->
- val localeTag = getLocaleTagForLanguage(langCode)
- val nameResId = getSubtypeNameResourceId(langCode)
- InputMethodSubtype.InputMethodSubtypeBuilder()
- .setSubtypeNameResId(nameResId)
- .setSubtypeLocale(localeTag)
- .setSubtypeMode("keyboard")
- .setSubtypeExtraValue("noSuggestions=true")
- .build()
- }
-
- setAdditionalInputMethodSubtypesCompat(imm, imeId, subtypes.toTypedArray())
- Log.d(TAG, "Updated ${subtypes.size} additional subtypes from IME service")
-
- // Verify
- val verifySubtypes = imm.getEnabledInputMethodSubtypeList(imeInfo, true)
- Log.d(TAG, "Verification: Android reports ${verifySubtypes.size} enabled subtypes after update")
- verifySubtypes.forEach { subtype ->
- val name = try {
- if (subtype.nameResId != 0) {
- getString(subtype.nameResId)
- } else {
- "N/A"
- }
- } catch (e: Exception) {
- "Error: ${e.message}"
- }
- Log.d(TAG, " - locale: ${subtype.localeString()}, name: $name")
- }
-
- } catch (e: Exception) {
- Log.e(TAG, "Error updating additional subtypes from IME service", e)
- }
- }
-
- private fun getLocaleTagForLanguage(languageCode: String): String {
- val localeMap = mapOf(
- "ru" to "ru_RU",
- "pt" to "pt_PT",
- "de" to "de_DE",
- "da" to "da_DK",
- "no" to "no_NO",
- "nb" to "nb_NO",
- "nn" to "nn_NO",
- "fr" to "fr_FR",
- "es" to "es_ES",
- "pl" to "pl_PL",
- "it" to "it_IT",
- "en" to "en_US"
- )
- return localeMap[languageCode.lowercase()] ?: languageCode
+ AdditionalSubtypeUtils.registerAdditionalSubtypes(this)
}
-
- private fun getSubtypeNameResourceId(languageCode: String): Int {
- val resourceName = "input_method_name_$languageCode"
- return resources.getIdentifier(resourceName, "string", packageName)
- .takeIf { it != 0 } ?: R.string.input_method_name
- }
-
private fun handleNativeImeTrackpadMotion(event: MotionEvent, origin: String): Boolean {
if (!isNativeImeTrackpadProviderActive()) {
return false
@@ -5694,6 +5414,10 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
Log.w(TRACKPAD_DEBUG_TAG, "Native swipe-to-delete ignored: no InputConnection")
return
}
+ if (compositionCoordinator.isActive) {
+ compositionCoordinator.clearCurrentRun(ic)
+ return
+ }
if (TextSelectionHelper.deleteLastWord(ic)) {
Log.d(TRACKPAD_DEBUG_TAG, "Native swipe-to-delete deleted previous word")
} else {
@@ -5702,6 +5426,7 @@ class PhysicalKeyboardInputMethodService : InputMethodService(), ClicksAccessibi
}
private fun acceptSuggestionAtIndex(third: Int) {
+ compositionCoordinator.finish(currentInputConnection, CompositionFinishReason.TRACKPAD_SUGGESTION_COMMIT)
val visibleSuggestions = visibleSuggestionStrings()
// Clear latched UI layers when selecting a suggestion via trackpad.
diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/QuickLauncherActivity.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/QuickLauncherActivity.kt
index 79a1b77d9..a8277dfc4 100644
--- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/QuickLauncherActivity.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/QuickLauncherActivity.kt
@@ -72,6 +72,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -535,6 +536,7 @@ private fun QuickLauncherSheet(
onDismiss: () -> Unit,
onDismissAnimationFinished: () -> Unit
) {
+ val context = LocalContext.current
val maxSheetHeight = LocalConfiguration.current.screenHeightDp.dp * 0.78f
val visible = remember { MutableTransitionState(false) }
LaunchedEffect(Unit) {
@@ -559,7 +561,7 @@ private fun QuickLauncherSheet(
if (commands.size <= 4) {
sourceCommands.map { QuickLauncherEntry.Command(it) }
} else {
- listOf(QuickLauncherEntry.Header(source.displayLabel)) +
+ listOf(QuickLauncherEntry.Header(source.localizedDisplayLabel(context))) +
sourceCommands.map { QuickLauncherEntry.Command(it) }
}
}
@@ -792,6 +794,7 @@ private fun QuickLauncherCommandRow(
onCustomizationsReloadRequested: () -> Unit,
onMoveFavorite: (String, Int) -> Unit
) {
+ val context = LocalContext.current
val customColor = customization?.color
val isFavorite = customization?.favorite == true
var menuExpanded by remember { mutableStateOf(false) }
@@ -857,7 +860,7 @@ private fun QuickLauncherCommandRow(
modifier = Modifier.basicMarquee()
)
Text(
- text = command.subtitle ?: command.source.displayLabel,
+ text = command.subtitle ?: command.source.localizedDisplayLabel(context),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -933,7 +936,7 @@ private fun QuickLauncherEntryContextMenu(
value = aliasText,
onValueChange = { aliasText = it },
singleLine = true,
- label = { Text("Search alias") },
+ label = { Text(stringResource(R.string.quick_launcher_search_alias)) },
modifier = Modifier.fillMaxWidth()
)
Row(
@@ -946,19 +949,27 @@ private fun QuickLauncherEntryContextMenu(
onCustomizationChanged(customization.copy(customSearch = ""))
}
) {
- Text("Clear")
+ Text(stringResource(R.string.clear))
}
TextButton(
onClick = {
onCustomizationChanged(customization.copy(customSearch = aliasText))
}
) {
- Text("Save")
+ Text(stringResource(R.string.save))
}
}
}
DropdownMenuItem(
- text = { Text(if (customization.favorite) "Unfavorite" else "Favorite") },
+ text = {
+ Text(
+ if (customization.favorite) {
+ stringResource(R.string.quick_launcher_unfavorite)
+ } else {
+ stringResource(R.string.quick_launcher_favorite)
+ }
+ )
+ },
leadingIcon = {
Icon(
imageVector = if (customization.favorite) Icons.Filled.StarBorder else Icons.Filled.Star,
@@ -980,7 +991,7 @@ private fun QuickLauncherEntryContextMenu(
)
if (customization.favorite) {
DropdownMenuItem(
- text = { Text("Move up") },
+ text = { Text(stringResource(R.string.quick_launcher_move_up)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.KeyboardArrowUp,
@@ -990,7 +1001,7 @@ private fun QuickLauncherEntryContextMenu(
onClick = { onMoveFavorite(-1) }
)
DropdownMenuItem(
- text = { Text("Move down") },
+ text = { Text(stringResource(R.string.quick_launcher_move_down)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
@@ -1001,7 +1012,7 @@ private fun QuickLauncherEntryContextMenu(
)
}
DropdownMenuItem(
- text = { Text("Hide") },
+ text = { Text(stringResource(R.string.quick_launcher_hide)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.VisibilityOff,
@@ -1013,13 +1024,13 @@ private fun QuickLauncherEntryContextMenu(
}
)
DropdownMenuItem(
- text = { Text("Dynamic entry color") },
+ text = { Text(stringResource(R.string.quick_launcher_dynamic_entry_color)) },
onClick = {
onCustomizationChanged(customization.copy(color = null))
}
)
Text(
- text = "Entry color",
+ text = stringResource(R.string.quick_launcher_entry_color),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)
diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt
index f27446bb8..5a938d2d6 100644
--- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/StatusBarController.kt
@@ -117,6 +117,7 @@ class StatusBarController(
}
var onSuggestionCommitted: (() -> Unit)? = null
+ var onBeforeSuggestionCommitted: (() -> Unit)? = null
var onHideSuggestion: ((String) -> Unit)? = null
@@ -188,6 +189,7 @@ class StatusBarController(
var onSoftwareKeyboardTextInput: ((String, android.view.inputmethod.InputConnection?, StatusSnapshot) -> Boolean)? = null
var onSoftwareKeyboardBoundaryTextInput: ((String, android.view.inputmethod.InputConnection?) -> Boolean)? = null
+ var onSoftwareKeyboardBackspace: ((android.view.inputmethod.InputConnection?) -> Boolean)? = null
var onHamburgerMenuRequested: (() -> Unit)? = null
set(value) {
@@ -1187,6 +1189,10 @@ class StatusBarController(
// Reuse the same view to avoid flicker caused by removeAllViews()/recreate on each status update.
val view = emojiPickerView ?: EmojiPickerView(context) {
onSymCloseRequested?.invoke()
+ }.apply {
+ onBeforeTextCommit = { text, connection ->
+ onSoftwareKeyboardBoundaryTextInput?.invoke(text, connection)
+ }
}.also { emojiPickerView = it }
view.onSearchPanelVisibilityChanged = { visible ->
onEmojiPickerSearchPanelToggled?.invoke(visible)
@@ -1629,6 +1635,7 @@ class StatusBarController(
if (searchTarget != null && searchTarget.handleSearchBackspace()) {
return
}
+ if (onSoftwareKeyboardBackspace?.invoke(inputConnection) == true) return
inputConnection?.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL))
inputConnection?.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL))
}
@@ -1640,6 +1647,7 @@ class StatusBarController(
searchTarget.commitTopSearchResultAndClose()
return
}
+ onSoftwareKeyboardBoundaryTextInput?.invoke("\n", inputConnection)
inputConnection?.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER))
inputConnection?.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER))
}
@@ -1962,8 +1970,10 @@ class StatusBarController(
}
if (rowIndex == 2) {
rowLayout.addView(createSoftwareSymbolControl("", keyHeight, fixedKeyWidth, iconRes = R.drawable.backspace_24) {
- inputConnection?.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL))
- inputConnection?.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL))
+ if (onSoftwareKeyboardBackspace?.invoke(inputConnection) != true) {
+ inputConnection?.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL))
+ inputConnection?.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL))
+ }
}, LinearLayout.LayoutParams(fixedKeyWidth, keyHeight).apply { marginStart = keySpacing })
}
container.addView(rowLayout)
@@ -1982,16 +1992,17 @@ class StatusBarController(
sendSoftwareCtrlTap(inputConnection)
}, LinearLayout.LayoutParams(fixedKeyWidth, keyHeight).apply { marginEnd = keySpacing })
row4.addView(createSoftwareSymbolControl(",", keyHeight, fixedKeyWidth) {
- inputConnection?.commitText(",", 1)
+ commitSoftwareSymbolText(inputConnection, ",")
}, LinearLayout.LayoutParams(fixedKeyWidth, keyHeight).apply { marginEnd = keySpacing })
row4.addView(createSoftwareSymbolSpaceControl(buildSoftwareKeyboardSpacebarLabel(snapshot), keyHeight, inputConnection), LinearLayout.LayoutParams(fixedKeyWidth * 4, keyHeight).apply { marginEnd = keySpacing })
row4.addView(createSoftwareSymbolControl(".", keyHeight, fixedKeyWidth) {
- inputConnection?.commitText(".", 1)
+ commitSoftwareSymbolText(inputConnection, ".")
}, LinearLayout.LayoutParams(fixedKeyWidth, keyHeight).apply { marginEnd = keySpacing })
row4.addView(createSoftwareSymbolControl("", keyHeight, fixedKeyWidth, iconRes = R.drawable.keyboard_control_key_24) {
sendSoftwareCtrlTap(inputConnection)
}, LinearLayout.LayoutParams(fixedKeyWidth, keyHeight).apply { marginEnd = keySpacing })
row4.addView(createSoftwareSymbolControl("", keyHeight, fixedKeyWidth, iconRes = R.drawable.keyboard_return_24) {
+ onSoftwareKeyboardBoundaryTextInput?.invoke("\n", inputConnection)
inputConnection?.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER))
inputConnection?.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER))
}, LinearLayout.LayoutParams(fixedKeyWidth, keyHeight))
@@ -2044,13 +2055,22 @@ class StatusBarController(
}
}
+ private fun commitSoftwareSymbolText(
+ inputConnection: android.view.inputmethod.InputConnection?,
+ text: String
+ ) {
+ if (onSoftwareKeyboardBoundaryTextInput?.invoke(text, inputConnection) != true) {
+ inputConnection?.commitText(text, 1)
+ }
+ }
+
private fun createSoftwareSymbolSpaceControl(
label: String,
height: Int,
inputConnection: android.view.inputmethod.InputConnection?
): TextView {
val view = createSoftwareSymbolControl(label, height, 0, onClick = {
- inputConnection?.commitText(" ", 1)
+ commitSoftwareSymbolText(inputConnection, " ")
})
var downX = 0f
var lastX = 0f
@@ -2096,7 +2116,7 @@ class StatusBarController(
android.view.MotionEvent.ACTION_UP -> {
handler.removeCallbacks(longPressRunnable)
if (!moved && !longPressTriggered && kotlin.math.abs(event.x - downX) < step) {
- inputConnection?.commitText(" ", 1)
+ commitSoftwareSymbolText(inputConnection, " ")
}
true
}
@@ -3131,6 +3151,7 @@ class StatusBarController(
onAddUserWord,
onAddUserWordSubstitutionRequested,
onSuggestionCommitted,
+ onBeforeSuggestionCommitted,
onHideSuggestion,
onDeleteUserSuggestion,
canDeleteUserSuggestion,
@@ -3381,9 +3402,9 @@ class StatusBarController(
val appInfo = context.packageManager.getApplicationInfo(context.packageName, 0)
subtype.getDisplayName(context, context.packageName, appInfo)?.toString()
?.takeIf { it.isNotBlank() }
- ?: subtype.localeString().ifBlank { "Unknown" }
+ ?: subtype.localeString().ifBlank { context.getString(R.string.status_bar_language_unknown) }
} else {
- "Unknown"
+ context.getString(R.string.status_bar_language_unknown)
}
val layoutName = subtype
@@ -3405,7 +3426,7 @@ class StatusBarController(
Log.w(TAG, "Failed to update accessibility state description", e)
context.getString(
R.string.status_bar_button_language_state_description,
- "Unknown",
+ context.getString(R.string.status_bar_language_unknown),
"qwerty"
)
}
diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/VariationButtonHandler.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/VariationButtonHandler.kt
index 51f57e6a2..caf0ecc8e 100644
--- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/VariationButtonHandler.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/VariationButtonHandler.kt
@@ -35,12 +35,9 @@ object VariationButtonHandler {
inputConnection: InputConnection,
listener: OnVariationSelectedListener?
): Boolean {
- if (variation.length != 1) return false
- val boundary = it.palsoftware.pastiera.core.Punctuation.normalizeApostrophe(variation[0])
- if (boundary == '\'' || boundary !in it.palsoftware.pastiera.core.Punctuation.BOUNDARY) {
- return false
- }
- return listener?.onBoundaryTextRequested(boundary.toString(), inputConnection) == true
+ // The service must see every external insertion, including brackets and
+ // multi-character variations, before commitText can replace its composing run.
+ return listener?.onBoundaryTextRequested(variation, inputConnection) == true
}
/**
diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/composition/CompositionFinishReason.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/composition/CompositionFinishReason.kt
new file mode 100644
index 000000000..12b32477f
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/composition/CompositionFinishReason.kt
@@ -0,0 +1,10 @@
+package it.palsoftware.pastiera.inputmethod.composition
+
+internal enum class CompositionFinishReason {
+ BOUNDARY, ENTER, PUNCTUATION, COMMAND, CURSOR_MOVE, SELECTION_CHANGE,
+ LAYOUT_SWITCH, SUBTYPE_SWITCH, LANGUAGE_SWITCH, SUGGESTION_COMMIT,
+ TRACKPAD_SUGGESTION_COMMIT, TRACKPAD_DELETE_WORD, CLIPBOARD, EMOJI,
+ UNICODE_PICKER, VOICE_INPUT, TEXT_EXPANSION, VARIATION, SYMBOL,
+ INTERNAL_INPUT_CAPTURE, IME_ACTION, INPUT_FINISH, WINDOW_HIDDEN,
+ MAX_LENGTH_INTERNAL_SPLIT
+}
diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/composition/ImeCompositionCoordinator.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/composition/ImeCompositionCoordinator.kt
new file mode 100644
index 000000000..1d8c7f885
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/composition/ImeCompositionCoordinator.kt
@@ -0,0 +1,255 @@
+package it.palsoftware.pastiera.inputmethod.composition
+
+import android.view.inputmethod.ExtractedTextRequest
+import android.view.inputmethod.InputConnection
+import it.palsoftware.pastiera.core.composition.ComposerUpdate
+import it.palsoftware.pastiera.core.composition.HangulComposer
+import it.palsoftware.pastiera.core.composition.HangulJamo
+import it.palsoftware.pastiera.core.composition.NormalizedCompositionStroke
+import it.palsoftware.pastiera.core.composition.CompositionSnapshot
+
+/** Android adapter around the pure composer; it never rewrites surrounding editor text. */
+internal class ImeCompositionCoordinator(
+ private val onCompositionStarted: () -> Unit = {},
+ private val onCompositionFinished: (CompositionFinishReason) -> Unit = {}
+) {
+ private var layoutId: String? = null
+ private var composer: HangulComposer? = null
+ private var editorSessionId = 0L
+ private var editGeneration = 0L
+ private val expectations = ArrayDeque()
+ private val acknowledgedExpectations = ArrayDeque()
+ private var composingStart: Int? = null
+ private var nativeRangeObserved = false
+ private var lastAcknowledgedLength: Int? = null
+ private var commitOnly = false
+
+ val isActive: Boolean get() = composer?.isActive == true
+ val snapshot: CompositionSnapshot? get() = composer?.snapshot
+
+ fun startEditorSession() {
+ editorSessionId++
+ editGeneration = 0L
+ commitOnly = false
+ resetWithoutEditorMutation()
+ composer = null
+ }
+
+ fun selectLayout(layout: String?) {
+ if (layout == layoutId && composer != null) return
+ resetWithoutEditorMutation()
+ layoutId = layout
+ composer = if (LayoutCapabilitiesRegistry.forLayout(layout).composerKind == ComposerKind.HANGUL) {
+ HangulComposer(layoutId = layout ?: "korean_2set", sessionId = editorSessionId)
+ } else null
+ }
+
+ fun handleMappedStroke(
+ inputConnection: InputConnection?,
+ stroke: NormalizedCompositionStroke,
+ consumeShiftOneShot: () -> Unit = {}
+ ): Boolean {
+ val activeComposer = composer ?: return false
+ val ic = inputConnection ?: return false
+ if (!HangulJamo.isSupported(stroke.jamo)) return false
+ if (commitOnly) {
+ val success = safely { ic.commitText(stroke.jamo.toString(), 1) }
+ if (success) consumeShiftOneShot()
+ return success
+ }
+ if (activeComposer.needsInternalSplit && !splitRun(ic, activeComposer)) return false
+ val wasInactive = !activeComposer.isActive
+ if (wasInactive) composingStart = readSelectionStart(ic)
+ return when (val update = activeComposer.accept(stroke)) {
+ ComposerUpdate.NotHandled -> false
+ is ComposerUpdate.Replace -> {
+ val success = applySnapshot(ic, update.snapshot)
+ if (success) {
+ if (wasInactive) onCompositionStarted()
+ consumeShiftOneShot()
+ } else {
+ // A rejected first write can degrade to plain jamo for this editor.
+ // A rejected replacement must never commit the stale full run.
+ val finished = wasInactive || safely { ic.finishComposingText() }
+ resetWithoutEditorMutation()
+ if (!wasInactive) onCompositionFinished(CompositionFinishReason.BOUNDARY)
+ if (!finished) return false
+ commitOnly = true
+ val committed = safely { ic.commitText(stroke.jamo.toString(), 1) }
+ if (committed) consumeShiftOneShot()
+ return committed
+ }
+ success
+ }
+ }
+ }
+
+ private fun splitRun(ic: InputConnection, activeComposer: HangulComposer): Boolean {
+ // Avoid exposing the temporary finished region to selection callbacks.
+ safely { ic.beginBatchEdit() }
+ try {
+ // Keep the open syllable (and its original keystrokes) editable. The editor
+ // already contains the entire run: finish it, then reclaim only the suffix.
+ val split = activeComposer.internalSplit() ?: return false
+ val start = composingStart ?: readSelectionStart(ic)?.minus(split.fullText.length)
+ if (start != null) {
+ if (!safely { ic.finishComposingText() } ||
+ !safely { ic.setComposingRegion(start + split.prefix.length, start + split.fullText.length) }) {
+ resetWithoutEditorMutation()
+ onCompositionFinished(CompositionFinishReason.MAX_LENGTH_INTERNAL_SPLIT)
+ return false
+ }
+ clearTracking()
+ composingStart = start + split.prefix.length
+ nativeRangeObserved = true
+ expectations.addLast(SelectionExpectation(editorSessionId, ++editGeneration,
+ activeComposer.snapshot!!.renderedText.length))
+ } else {
+ // Some editors implement composing but no extracted selection. Replace
+ // the owned range with its stable prefix, then restore the editable tail.
+ val suffix = activeComposer.snapshot!!
+ if (!safely { ic.commitText(split.prefix, 1) }) {
+ safely { ic.finishComposingText() }
+ resetWithoutEditorMutation()
+ onCompositionFinished(CompositionFinishReason.MAX_LENGTH_INTERNAL_SPLIT)
+ return false
+ }
+ clearTracking()
+ if (!applySnapshot(ic, suffix)) {
+ safely { ic.commitText(suffix.renderedText, 1) }
+ resetWithoutEditorMutation()
+ onCompositionFinished(CompositionFinishReason.MAX_LENGTH_INTERNAL_SPLIT)
+ return false
+ }
+ }
+ return true
+ } finally {
+ safely { ic.endBatchEdit() }
+ }
+ }
+
+ fun handleBackspace(inputConnection: InputConnection?): Boolean {
+ val activeComposer = composer ?: return false
+ if (!activeComposer.isActive || inputConnection == null) return false
+ return when (val update = activeComposer.backspace()) {
+ ComposerUpdate.NotHandled -> false
+ is ComposerUpdate.Replace -> {
+ val success = applySnapshot(inputConnection, update.snapshot)
+ if (!success || update.snapshot.renderedText.isEmpty()) {
+ safely { inputConnection.finishComposingText() }
+ resetWithoutEditorMutation()
+ onCompositionFinished(CompositionFinishReason.BOUNDARY)
+ }
+ success
+ }
+ }
+ }
+
+ fun clearCurrentRun(inputConnection: InputConnection?): Boolean {
+ if (!isActive) return false
+ val cleared = safely { inputConnection?.setComposingText("", 1) ?: false }
+ safely { inputConnection?.finishComposingText() ?: false }
+ resetWithoutEditorMutation()
+ onCompositionFinished(CompositionFinishReason.TRACKPAD_DELETE_WORD)
+ return cleared
+ }
+
+ fun finish(inputConnection: InputConnection?, reason: CompositionFinishReason): Boolean {
+ if (!isActive) return false
+ val result = safely { inputConnection?.finishComposingText() ?: false }
+ resetWithoutEditorMutation()
+ onCompositionFinished(reason)
+ return result
+ }
+
+ private fun clearTracking() {
+ expectations.clear()
+ acknowledgedExpectations.clear()
+ composingStart = null
+ nativeRangeObserved = false
+ lastAcknowledgedLength = null
+ }
+
+ fun resetWithoutEditorMutation() {
+ composer?.reset()
+ clearTracking()
+ }
+
+ fun onUpdateSelection(
+ oldSelStart: Int, oldSelEnd: Int,
+ newSelStart: Int, newSelEnd: Int,
+ candidatesStart: Int, candidatesEnd: Int
+ ): SelectionUpdateDisposition {
+ if (!isActive) return SelectionUpdateDisposition.NOT_TRACKED
+ val hasRange = candidatesStart >= 0 && candidatesEnd >= candidatesStart
+ if (newSelStart != newSelEnd || (!hasRange && nativeRangeObserved)) {
+ return externalChange()
+ }
+ if (!hasRange && composingStart == null && oldSelStart == newSelStart && oldSelEnd == newSelEnd) {
+ return externalChange()
+ }
+ val start = composingStart ?: if (hasRange) candidatesStart else minOf(oldSelStart, oldSelEnd)
+ // Match final positions, not each individual write's relative cursor delta.
+ // The newest matching generation acknowledges all coalesced earlier writes.
+ val match = expectations.lastOrNull {
+ it.editorSessionId == editorSessionId &&
+ newSelStart == start + it.composingLength &&
+ (!hasRange || (candidatesStart == start && candidatesEnd == newSelStart))
+ }
+ if (match != null) {
+ composingStart = start
+ nativeRangeObserved = nativeRangeObserved || hasRange
+ lastAcknowledgedLength = match.composingLength
+ while (expectations.firstOrNull()?.let { it.editGeneration <= match.editGeneration } == true) {
+ acknowledgedExpectations.addLast(expectations.removeFirst())
+ while (acknowledgedExpectations.size > 16) acknowledgedExpectations.removeFirst()
+ }
+ return SelectionUpdateDisposition.OWN_COMPOSING_UPDATE
+ }
+ if (hasRange && candidatesStart == start && candidatesEnd == newSelStart &&
+ (newSelStart == start + (lastAcknowledgedLength ?: -1) ||
+ acknowledgedExpectations.any { start + it.composingLength == newSelStart })) {
+ return SelectionUpdateDisposition.OWN_COMPOSING_UPDATE
+ }
+ if (hasRange || oldSelStart != newSelStart || oldSelEnd != newSelEnd) {
+ return externalChange()
+ }
+ return SelectionUpdateDisposition.NOT_TRACKED
+ }
+
+ private fun externalChange(): SelectionUpdateDisposition {
+ resetWithoutEditorMutation()
+ onCompositionFinished(CompositionFinishReason.SELECTION_CHANGE)
+ return SelectionUpdateDisposition.EXTERNAL_CHANGE
+ }
+
+ private fun applySnapshot(ic: InputConnection, snapshot: CompositionSnapshot): Boolean {
+ val expectation = SelectionExpectation(editorSessionId, ++editGeneration, snapshot.renderedText.length)
+ expectations.addLast(expectation)
+ while (expectations.size > 128) expectations.removeFirst()
+ val success = try {
+ ic.beginBatchEdit()
+ ic.setComposingText(snapshot.renderedText, 1)
+ } catch (_: Exception) {
+ false
+ } finally {
+ safely { ic.endBatchEdit() }
+ }
+ if (!success) expectations.remove(expectation)
+ return success
+ }
+
+ private fun readSelectionStart(ic: InputConnection): Int? = try {
+ ic.getExtractedText(ExtractedTextRequest(), 0)?.let {
+ minOf(it.selectionStart, it.selectionEnd).takeIf { position -> position >= 0 }?.plus(it.startOffset)
+ }
+ } catch (_: Exception) { null }
+
+ private inline fun safely(action: () -> Boolean): Boolean = try { action() } catch (_: Exception) { false }
+}
+
+internal data class SelectionExpectation(
+ val editorSessionId: Long,
+ val editGeneration: Long,
+ val composingLength: Int
+)
diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/composition/LayoutCapabilitiesRegistry.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/composition/LayoutCapabilitiesRegistry.kt
new file mode 100644
index 000000000..607ec75a7
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/composition/LayoutCapabilitiesRegistry.kt
@@ -0,0 +1,16 @@
+package it.palsoftware.pastiera.inputmethod.composition
+
+internal enum class ComposerKind { NONE, HANGUL }
+
+internal data class BuiltInLayoutCapabilities(
+ val composerKind: ComposerKind,
+ val supportsAutoCapitalization: Boolean,
+ val capsLockAffectsOutput: Boolean
+)
+
+internal object LayoutCapabilitiesRegistry {
+ fun forLayout(layoutId: String?): BuiltInLayoutCapabilities = when (layoutId) {
+ "korean_2set" -> BuiltInLayoutCapabilities(ComposerKind.HANGUL, false, false)
+ else -> BuiltInLayoutCapabilities(ComposerKind.NONE, true, true)
+ }
+}
diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/composition/SelectionUpdateDisposition.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/composition/SelectionUpdateDisposition.kt
new file mode 100644
index 000000000..f7aeb63be
--- /dev/null
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/composition/SelectionUpdateDisposition.kt
@@ -0,0 +1,5 @@
+package it.palsoftware.pastiera.inputmethod.composition
+
+internal enum class SelectionUpdateDisposition {
+ NOT_TRACKED, OWN_COMPOSING_UPDATE, EXTERNAL_CHANGE
+}
diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/statusbar/button/LanguageButtonFactory.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/statusbar/button/LanguageButtonFactory.kt
index c1db1854c..4d0c1919f 100644
--- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/statusbar/button/LanguageButtonFactory.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/statusbar/button/LanguageButtonFactory.kt
@@ -203,15 +203,15 @@ class LanguageButtonFactory : StatusBarButtonFactory {
}
private fun getLanguageLabel(context: Context, subtype: InputMethodSubtype?): String {
- if (subtype == null) return "Unknown"
+ if (subtype == null) return context.getString(R.string.status_bar_language_unknown)
return try {
val appInfo = context.packageManager.getApplicationInfo(context.packageName, 0)
subtype.getDisplayName(context, context.packageName, appInfo)?.toString()
?.takeIf { it.isNotBlank() }
- ?: subtype.localeString().ifBlank { "Unknown" }
+ ?: subtype.localeString().ifBlank { context.getString(R.string.status_bar_language_unknown) }
} catch (e: Exception) {
Log.w(TAG, "Failed to resolve subtype display name for accessibility", e)
- subtype.localeString().ifBlank { "Unknown" }
+ subtype.localeString().ifBlank { context.getString(R.string.status_bar_language_unknown) }
}
}
diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/subtype/AdditionalSubtypeUtils.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/subtype/AdditionalSubtypeUtils.kt
index 0e2aeeb04..324c60122 100644
--- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/subtype/AdditionalSubtypeUtils.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/subtype/AdditionalSubtypeUtils.kt
@@ -3,8 +3,6 @@ package it.palsoftware.pastiera.inputmethod.subtype
import android.content.Context
import android.content.res.AssetManager
import android.os.Build
-import android.os.Handler
-import android.os.Looper
import android.util.Log
import android.view.inputmethod.InputMethodManager
import android.view.inputmethod.InputMethodSubtype
@@ -212,6 +210,7 @@ object AdditionalSubtypeUtils {
.setSubtypeExtraValue(extraValue)
.setSubtypeId(stableSubtypeId(localeStr, layoutName))
.setIsAuxiliary(false)
+ .setIsAsciiCapable(true)
.setOverridesImplicitlyEnabledSubtype(false)
if (supportsNameOverride) {
@@ -284,7 +283,7 @@ object AdditionalSubtypeUtils {
* Returns 0 for custom locales - Android will auto-generate the name from the locale.
*/
private fun getLocaleNameResId(localeStr: String): Int {
- val langCode = localeStr.split("_")[0].lowercase()
+ val langCode = localeFromSubtypeString(localeStr).language.lowercase()
return when (langCode) {
"en" -> R.string.input_method_name_en
"it" -> R.string.input_method_name_it
@@ -294,6 +293,7 @@ object AdditionalSubtypeUtils {
"es" -> R.string.input_method_name_es
"pt" -> R.string.input_method_name_pt
"ru" -> R.string.input_method_name_ru
+ "ko" -> R.string.input_method_name_ko
else -> 0 // Use 0 for custom locales - Android will auto-generate name from locale
}
}
@@ -433,6 +433,14 @@ object AdditionalSubtypeUtils {
*/
fun getLayoutForLocale(assets: AssetManager, locale: String, context: Context? = null): String {
val languageOnly = locale.substringBefore("_").substringBefore("-")
+ // Settings use underscores while modern Android subtypes expose language tags.
+ // Resolve both spellings before falling back to a language-wide mapping.
+ val localeKeys = listOf(locale, locale.replace('_', '-'), locale.replace('-', '_'), languageOnly)
+ .filter { it.isNotEmpty() }.distinct()
+
+ fun JSONObject.mappedLayout(): String? = localeKeys.firstNotNullOfOrNull { key ->
+ optString(key, "").takeIf { it.isNotEmpty() }
+ }
// First, try custom file if context is provided
if (context != null) {
@@ -441,18 +449,7 @@ object AdditionalSubtypeUtils {
if (customMappingFile.exists() && customMappingFile.canRead()) {
val jsonString = customMappingFile.readText()
val json = JSONObject(jsonString)
- if (json.has(locale)) {
- val layout = json.getString(locale)
- if (layout.isNotEmpty()) {
- return layout
- }
- }
- if (languageOnly.isNotEmpty() && json.has(languageOnly)) {
- val layout = json.getString(languageOnly)
- if (layout.isNotEmpty()) {
- return layout
- }
- }
+ json.mappedLayout()?.let { return it }
}
} catch (e: Exception) {
Log.w(TAG, "Error reading custom locale-layout mapping, falling back to assets", e)
@@ -464,12 +461,7 @@ object AdditionalSubtypeUtils {
assets.open("common/locale_layout_mapping.json").use { input ->
val jsonString = input.bufferedReader().use { it.readText() }
val json = JSONObject(jsonString)
- val exact = json.optString(locale, "")
- when {
- exact.isNotEmpty() -> exact
- languageOnly.isNotEmpty() -> json.optString(languageOnly, "qwerty")
- else -> "qwerty"
- }
+ json.mappedLayout() ?: "qwerty"
}
} catch (e: Exception) {
Log.w(TAG, "Error loading layout for locale $locale, defaulting to qwerty", e)
@@ -478,21 +470,36 @@ object AdditionalSubtypeUtils {
}
/**
- * Loads the set of system locales that were auto-added (no dictionary).
+ * Loads exact auto-added input-style entries.
+ *
+ * Older releases stored only locale names. Resolve those markers against the
+ * current entries once so future cleanup never removes an unrelated user style.
*/
- private fun loadAutoAddedLocales(context: Context): Set {
- return SettingsManager.getPreferences(context)
+ private fun loadAutoAddedStyles(context: Context, currentEntries: List): Set {
+ val stored = SettingsManager.getPreferences(context)
.getStringSet(PREF_AUTO_ADDED_SYSTEM_LOCALES, emptySet())
- ?: emptySet()
+ .orEmpty()
+
+ return stored.mapNotNullTo(mutableSetOf()) { marker ->
+ if (marker.contains(":")) {
+ marker
+ } else {
+ val matchingEntries = currentEntries.filter { it.substringBefore(":") == marker }
+ when {
+ matchingEntries.size == 1 -> matchingEntries.single()
+ else -> matchingEntries.firstOrNull {
+ it.substringAfter(":").substringBefore(":") ==
+ getLayoutForLocale(context.assets, marker, context)
+ }
+ }
+ }
+ }
}
-
- /**
- * Saves the set of system locales that were auto-added.
- */
- private fun saveAutoAddedLocales(context: Context, locales: Set) {
+
+ private fun saveAutoAddedStyles(context: Context, styles: Set) {
SettingsManager.getPreferences(context)
.edit()
- .putStringSet(PREF_AUTO_ADDED_SYSTEM_LOCALES, locales)
+ .putStringSet(PREF_AUTO_ADDED_SYSTEM_LOCALES, styles)
.apply()
}
@@ -503,28 +510,22 @@ object AdditionalSubtypeUtils {
fun removeSystemLocalesWithoutDictionary(context: Context) {
try {
val currentSystemLocales = getSystemEnabledLocales(context).toSet()
- val trackedAutoAdded = loadAutoAddedLocales(context)
-
- // Locales that were auto-added but are no longer in system
- val toRemove = trackedAutoAdded.filterNot { currentSystemLocales.contains(it) }.toSet()
- if (toRemove.isEmpty()) return
-
val currentStyles = SettingsManager.getCustomInputStyles(context)
- if (currentStyles.isBlank()) return
-
val entries = currentStyles.split(";").map { it.trim() }.filter { it.isNotEmpty() }
- val kept = entries.filterNot { entry ->
- val locale = entry.substringBefore(":")
- toRemove.contains(locale)
+ val trackedAutoAdded = loadAutoAddedStyles(context, entries)
+
+ val toRemove = trackedAutoAdded.filter { entry ->
+ entry.substringBefore(":") !in currentSystemLocales
}
-
- SettingsManager.setCustomInputStyles(context, kept.joinToString(";"))
-
- // Update tracking: keep only those still in system
- val updatedTracked = trackedAutoAdded.intersect(currentSystemLocales)
- saveAutoAddedLocales(context, updatedTracked)
-
- Log.d(TAG, "Removed ${entries.size - kept.size} auto-added system locales without dictionary (no longer in system)")
+ if (toRemove.isNotEmpty()) {
+ SettingsManager.setCustomInputStyles(
+ context,
+ entries.filterNot(toRemove::contains).joinToString(";")
+ )
+ }
+
+ saveAutoAddedStyles(context, trackedAutoAdded - toRemove.toSet())
+ Log.d(TAG, "Removed ${toRemove.size} auto-added system input styles no longer in system locales")
} catch (e: Exception) {
Log.e(TAG, "Error removing system locales without dictionary", e)
}
@@ -581,17 +582,30 @@ object AdditionalSubtypeUtils {
val localesWithDict = getLocalesWithDictionary(context)
val baseSubtypesInMethodXml = BASE_SUBTYPE_LOCALES
- // Get current custom input styles
val currentStyles = SettingsManager.getCustomInputStyles(context)
- val existingLocales = mutableSetOf()
- if (currentStyles.isNotBlank()) {
- currentStyles.split(";").forEach { entry ->
- val parts = entry.split(":").map { it.trim() }
- if (parts.isNotEmpty()) {
- existingLocales.add(parts[0])
- }
+ val entries = currentStyles.split(";")
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .toMutableList()
+ val trackedAutoAdded = loadAutoAddedStyles(context, entries).toMutableSet()
+
+ // A system style's layout can be edited in Pastiera. Keep the exact
+ // auto-generated entry and its provenance marker in sync with that mapping.
+ trackedAutoAdded.groupBy { it.substringBefore(":") }.forEach { (locale, tracked) ->
+ if (locale !in systemLocales) return@forEach
+ val hasUserStyleForLocale = entries.any { entry ->
+ entry.substringBefore(":") == locale && entry !in trackedAutoAdded
}
+ if (hasUserStyleForLocale) return@forEach
+
+ val desiredEntry = "$locale:${getLayoutForLocale(context.assets, locale, context)}"
+ entries.removeAll(tracked.toSet())
+ if (desiredEntry !in entries) entries.add(desiredEntry)
+ trackedAutoAdded.removeAll(tracked.toSet())
+ trackedAutoAdded.add(desiredEntry)
}
+
+ val existingLocales = entries.mapTo(mutableSetOf()) { it.substringBefore(":") }
// Find system locales that need to be added
val localesToAdd = systemLocales.filter { locale ->
@@ -604,6 +618,11 @@ object AdditionalSubtypeUtils {
}
if (localesToAdd.isEmpty()) {
+ val updatedStyles = entries.joinToString(";")
+ if (updatedStyles != currentStyles) {
+ SettingsManager.setCustomInputStyles(context, updatedStyles)
+ }
+ saveAutoAddedStyles(context, trackedAutoAdded)
Log.d(TAG, "No system locales without dictionary to add")
return
}
@@ -617,18 +636,13 @@ object AdditionalSubtypeUtils {
}
// Merge with existing styles
- val updatedStyles = if (currentStyles.isBlank()) {
- newEntries.joinToString(";")
- } else {
- "$currentStyles;${newEntries.joinToString(";")}"
- }
+ val updatedStyles = (entries + newEntries).joinToString(";")
// Save updated styles
SettingsManager.setCustomInputStyles(context, updatedStyles)
// Track auto-added locales for future cleanup
- val tracked = loadAutoAddedLocales(context).toMutableSet()
- tracked.addAll(localesToAdd)
- saveAutoAddedLocales(context, tracked)
+ trackedAutoAdded.addAll(newEntries)
+ saveAutoAddedStyles(context, trackedAutoAdded)
Log.d(TAG, "Auto-added ${localesToAdd.size} system locales without dictionary to custom input styles")
} catch (e: Exception) {
Log.e(TAG, "Error auto-adding system locales without dictionary", e)
@@ -751,169 +765,144 @@ object AdditionalSubtypeUtils {
*/
fun registerAdditionalSubtypes(context: Context) {
try {
- // Auto-add system locales without dictionary first
autoAddSystemLocalesWithoutDictionary(context)
-
+
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
?: run {
Log.e(TAG, "InputMethodManager not available")
return
}
-
- // Get IME ID
- val componentName = android.content.ComponentName(
- context,
- PhysicalKeyboardInputMethodService::class.java
- )
-
- // Find the actual IME in the system list to get the correct ID format
+
val inputMethodInfo = imm.inputMethodList.firstOrNull { info ->
- info.packageName == context.packageName &&
- info.serviceName == PhysicalKeyboardInputMethodService::class.java.name
+ info.packageName == context.packageName &&
+ info.serviceName == PhysicalKeyboardInputMethodService::class.java.name
}
-
+
if (inputMethodInfo == null) {
Log.d(TAG, "IME not found in system list, will retry when IME is enabled")
return
}
-
+
val imeId = inputMethodInfo.id
- Log.d(TAG, "Registering additional subtypes for IME: $imeId")
-
- val prefString = SettingsManager.getCustomInputStyles(context)
+ val customPrefString = SettingsManager.getCustomInputStyles(context)
+ val legacyEntries = SettingsManager.getAdditionalImeSubtypes(context).map { languageCode ->
+ val locale = legacyLocaleForLanguage(languageCode)
+ "$locale:${getLayoutForLocale(context.assets, locale, context)}"
+ }
+ val prefString = (
+ customPrefString.split(";").map { it.trim() }.filter { it.isNotEmpty() } +
+ legacyEntries
+ ).distinct().joinToString(";")
val subtypes = createAdditionalSubtypesArray(
prefString,
context.assets,
context
)
-
- Log.d(TAG, "Created ${subtypes.size} additional subtypes")
-
- // Always call setAdditionalInputMethodSubtypes, even with empty array to remove old subtypes
+
setAdditionalInputMethodSubtypesCompat(imm, imeId, subtypes)
- Log.d(TAG, "Successfully called setAdditionalInputMethodSubtypes with ${subtypes.size} subtypes")
-
- if (subtypes.isNotEmpty()) {
- // Try to explicitly enable the additional subtypes after a delay
- Handler(Looper.getMainLooper()).postDelayed({
- try {
- // Re-fetch InputMethodInfo to get updated subtype list
- val updatedInfo = imm.inputMethodList.firstOrNull {
- it.packageName == context.packageName &&
- it.serviceName == PhysicalKeyboardInputMethodService::class.java.name
- }
-
- if (updatedInfo != null) {
- // Get all subtypes from InputMethodInfo (including the newly added ones)
- val allSubtypes = mutableListOf()
- for (i in 0 until updatedInfo.subtypeCount) {
- allSubtypes.add(updatedInfo.getSubtypeAt(i))
- }
-
- // Get currently enabled subtypes (include implicit ones from method.xml)
- val currentlyEnabled = imm.getEnabledInputMethodSubtypeList(updatedInfo, true)
- val currentSystemLocales = getSystemEnabledLocales(context).toSet()
- val systemLanguageCodes = currentSystemLocales.map { locale ->
- locale.split("_").first().lowercase(Locale.ROOT)
- }.toSet()
- val enabledHashCodes = currentlyEnabled
- .filter { subtype ->
- shouldKeepSubtype(
- context,
- context.assets,
- subtype,
- currentSystemLocales,
- systemLanguageCodes
- )
- }
- .map { it.hashCode() }
- .toMutableSet()
-
- // Add hash codes of additional subtypes to enabled set
- subtypes.forEach { additionalSubtype ->
- // Find matching subtype in allSubtypes by locale and extraValue
- val additionalLayout = getKeyboardLayoutFromSubtype(additionalSubtype)
- val matchingSubtype = allSubtypes.firstOrNull { subtype ->
- additionalLayout != null &&
- isAdditionalSubtype(subtype) &&
- matchesLocaleAndKeyboardLayoutSet(
- subtype,
- additionalSubtype.localeString(),
- additionalLayout
- )
- }
- if (matchingSubtype != null) {
- enabledHashCodes.add(matchingSubtype.hashCode())
- Log.d(TAG, "Adding subtype to enabled list: locale=${matchingSubtype.localeString()}, hashCode=${matchingSubtype.hashCode()}")
- }
- }
-
- // Enable visible base subtypes plus all configured additional subtypes.
- if (enabledHashCodes.isNotEmpty()) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
- imm.setExplicitlyEnabledInputMethodSubtypes(
- updatedInfo.id,
- enabledHashCodes.toIntArray()
- )
- Log.d(TAG, "Explicitly enabled ${enabledHashCodes.size} subtypes (${subtypes.size} additional)")
- } else {
- Log.d(TAG, "Skipping explicit subtype enable: requires Android 14+")
- }
- }
- }
- } catch (e: Exception) {
- Log.w(TAG, "Could not explicitly enable subtypes", e)
- }
- }, 500) // Wait 500ms for system to process registration
- } else {
- Log.d(TAG, "No subtypes to register, removed all additional subtypes")
- // When removing all subtypes, also clean up enabled subtypes list
- Handler(Looper.getMainLooper()).postDelayed({
- try {
- val updatedInfo = imm.inputMethodList.firstOrNull {
- it.packageName == context.packageName &&
- it.serviceName == PhysicalKeyboardInputMethodService::class.java.name
- }
-
- if (updatedInfo != null) {
- // Get currently enabled subtypes (include implicit ones from method.xml)
- val currentlyEnabled = imm.getEnabledInputMethodSubtypeList(updatedInfo, true)
- val currentSystemLocales = getSystemEnabledLocales(context).toSet()
- val systemLanguageCodes = currentSystemLocales.map { locale ->
- locale.split("_").first().lowercase(Locale.ROOT)
- }.toSet()
-
- val systemSubtypes = currentlyEnabled.filter { subtype ->
- !isAdditionalSubtype(subtype) &&
- shouldKeepSubtype(
- context,
- context.assets,
- subtype,
- currentSystemLocales,
- systemLanguageCodes
- )
- }
-
- if (systemSubtypes.isNotEmpty()) {
- val systemHashCodes = systemSubtypes.map { it.hashCode() }.toIntArray()
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
- imm.setExplicitlyEnabledInputMethodSubtypes(
- updatedInfo.id,
- systemHashCodes
- )
- Log.d(TAG, "Cleaned up enabled subtypes, kept ${systemHashCodes.size} system subtypes")
- } else {
- Log.d(TAG, "Skipping enabled subtype cleanup: requires Android 14+")
- }
- }
- }
- } catch (e: Exception) {
- Log.w(TAG, "Could not clean up enabled subtypes", e)
- }
- }, 500) // Wait 500ms for system to process removal
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ syncExplicitlyEnabledSubtypes(
+ context = context,
+ imm = imm,
+ imeId = imeId,
+ inputMethodInfo = inputMethodInfo,
+ configuredSubtypes = subtypes,
+ customPrefString = customPrefString
+ )
}
+
+ Log.d(TAG, "Registered ${subtypes.size} additional subtypes for IME: $imeId")
} catch (e: Exception) {
Log.e(TAG, "Error registering additional subtypes", e)
}
}
+
+ private fun legacyLocaleForLanguage(languageCode: String): String = when (languageCode.lowercase()) {
+ "ru" -> "ru_RU"
+ "pt" -> "pt_PT"
+ "de" -> "de_DE"
+ "da" -> "da_DK"
+ "no" -> "no_NO"
+ "nb" -> "nb_NO"
+ "nn" -> "nn_NO"
+ "fr" -> "fr_FR"
+ "es" -> "es_ES"
+ "pl" -> "pl_PL"
+ "it" -> "it_IT"
+ "en" -> "en_US"
+ "ko" -> "ko_KR"
+ else -> languageCode
+ }
+
+ @androidx.annotation.RequiresApi(34)
+ private fun syncExplicitlyEnabledSubtypes(
+ context: Context,
+ imm: InputMethodManager,
+ imeId: String,
+ inputMethodInfo: android.view.inputmethod.InputMethodInfo,
+ configuredSubtypes: Array,
+ customPrefString: String
+ ) {
+ val staticSubtypes = buildList {
+ for (index in 0 until inputMethodInfo.subtypeCount) {
+ inputMethodInfo.getSubtypeAt(index)
+ .takeUnless(::isAdditionalSubtype)
+ ?.let(::add)
+ }
+ }
+ val availableSubtypes = staticSubtypes + configuredSubtypes
+ val availableHashCodes = availableSubtypes.mapTo(mutableSetOf()) { it.hashCode() }
+ val entries = customPrefString.split(";").map { it.trim() }.filter { it.isNotEmpty() }
+ val autoAddedStyles = loadAutoAddedStyles(context, entries)
+ val enabledHashCodes = imm.getEnabledInputMethodSubtypeList(inputMethodInfo, true)
+ .asSequence()
+ .filter { it.hashCode() in availableHashCodes }
+ .filter { subtype ->
+ if (isAdditionalSubtype(subtype)) {
+ isAdditionalSubtypeVisible(context, subtype, autoAddedStyles)
+ } else {
+ val locale = subtype.localeString()
+ val layout = getKeyboardLayoutFromSubtype(subtype)
+ ?: getLayoutForLocale(context.assets, locale, context)
+ !SettingsManager.isSystemInputStyleHidden(context, locale, layout)
+ }
+ }
+ .mapTo(mutableSetOf()) { it.hashCode() }
+
+ // Static styles disappear from Android's enabled list after hiding them.
+ // Rebuild requested styles from system locales and saved selections as well.
+ val requestedLocales = getSystemEnabledLocales(context) +
+ SettingsManager.getAdditionalImeSubtypes(context).map(::legacyLocaleForLanguage)
+ staticSubtypes.filter { subtype ->
+ val locale = subtype.localeString()
+ val layout = getKeyboardLayoutFromSubtype(subtype)
+ ?: getLayoutForLocale(context.assets, locale, context)
+ val requested = requestedLocales.any { localesMatch(it, locale) } || entries.any { entry ->
+ val parts = entry.split(":")
+ parts.size >= 2 && localesMatch(parts[0], locale) && parts[1] == layout
+ }
+ requested && !SettingsManager.isSystemInputStyleHidden(context, locale, layout)
+ }.mapTo(enabledHashCodes) { it.hashCode() }
+
+ configuredSubtypes
+ .filter { isAdditionalSubtypeVisible(context, it, autoAddedStyles) }
+ .mapTo(enabledHashCodes) { it.hashCode() }
+
+ imm.setExplicitlyEnabledInputMethodSubtypes(imeId, enabledHashCodes.toIntArray())
+ Log.d(TAG, "Synchronized ${enabledHashCodes.size} explicitly enabled subtypes")
+ }
+
+ private fun isAdditionalSubtypeVisible(
+ context: Context,
+ subtype: InputMethodSubtype,
+ autoAddedStyles: Set
+ ): Boolean {
+ val locale = subtype.localeString()
+ val layout = getKeyboardLayoutFromSubtype(subtype) ?: return true
+ val styleKey = "$locale:$layout"
+ val normalizedStyleKey = "${locale.replace('-', '_')}:$layout"
+ val isAutoAdded = styleKey in autoAddedStyles || normalizedStyleKey in autoAddedStyles
+ return !isAutoAdded || !SettingsManager.isSystemInputStyleHidden(context, locale, layout)
+ }
}
diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/suggestions/SuggestionButtonHandler.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/suggestions/SuggestionButtonHandler.kt
index fd9fa6ae4..c1fb80ad9 100644
--- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/suggestions/SuggestionButtonHandler.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/suggestions/SuggestionButtonHandler.kt
@@ -23,6 +23,7 @@ object SuggestionButtonHandler {
inputConnection: InputConnection?,
listener: VariationButtonHandler.OnVariationSelectedListener? = null,
shouldDisableAutoCapitalize: Boolean,
+ onBeforeSuggestionCommitted: (() -> Unit)? = null,
onSuggestionCommitted: (() -> Unit)? = null
): View.OnClickListener {
return View.OnClickListener {
@@ -40,6 +41,7 @@ object SuggestionButtonHandler {
shouldDisableAutoCapitalize = shouldDisableAutoCapitalize
) && SettingsManager.getAutoCapitalizeFirstLetter(context)
+ onBeforeSuggestionCommitted?.invoke()
val committed = replaceCurrentWord(inputConnection, suggestion, forceLeadingCapital)
if (committed) {
onSuggestionCommitted?.invoke()
diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/suggestions/ui/FullSuggestionsBar.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/suggestions/ui/FullSuggestionsBar.kt
index 3ffd01d1e..238c65c0b 100644
--- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/suggestions/ui/FullSuggestionsBar.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/suggestions/ui/FullSuggestionsBar.kt
@@ -359,6 +359,7 @@ class FullSuggestionsBar(
onAddUserWord: ((String) -> Unit)?,
onAddUserWordSubstitutionRequested: ((String) -> Unit)?,
onSuggestionCommitted: (() -> Unit)?,
+ onBeforeSuggestionCommitted: (() -> Unit)?,
onHideSuggestion: ((String) -> Unit)?,
onDeleteUserSuggestion: ((String) -> Unit)?,
canDeleteUserSuggestion: ((String) -> Boolean)?,
@@ -429,6 +430,7 @@ class FullSuggestionsBar(
onAddUserWord,
onAddUserWordSubstitutionRequested,
onSuggestionCommitted,
+ onBeforeSuggestionCommitted,
onHideSuggestion,
onDeleteUserSuggestion,
canDeleteUserSuggestion,
@@ -455,6 +457,7 @@ class FullSuggestionsBar(
onAddUserWord = null,
onAddUserWordSubstitutionRequested = null,
onSuggestionCommitted = null,
+ onBeforeSuggestionCommitted = null,
onHideSuggestion = null,
onDeleteUserSuggestion = null,
canDeleteUserSuggestion = null,
@@ -631,6 +634,7 @@ class FullSuggestionsBar(
onAddUserWord: ((String) -> Unit)?,
onAddUserWordSubstitutionRequested: ((String) -> Unit)?,
onSuggestionCommitted: (() -> Unit)?,
+ onBeforeSuggestionCommitted: (() -> Unit)?,
onHideSuggestion: ((String) -> Unit)?,
onDeleteUserSuggestion: ((String) -> Unit)?,
canDeleteUserSuggestion: ((String) -> Boolean)?,
@@ -749,6 +753,7 @@ class FullSuggestionsBar(
inputConnection,
listener,
shouldDisableSuggestions,
+ onBeforeSuggestionCommitted,
onSuggestionCommitted
)
setOnClickListener { view ->
@@ -772,6 +777,7 @@ class FullSuggestionsBar(
onAddUserWord = onAddUserWord,
onAddUserWordSubstitutionRequested = onAddUserWordSubstitutionRequested,
onSuggestionCommitted = onSuggestionCommitted,
+ onBeforeSuggestionCommitted = onBeforeSuggestionCommitted,
onHideSuggestion = onHideSuggestion,
onDeleteUserSuggestion = onDeleteUserSuggestion,
canDeleteUserSuggestion = canDeleteUserSuggestion,
diff --git a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/EmojiPickerView.kt b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/EmojiPickerView.kt
index f922d13d7..f9131d615 100644
--- a/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/EmojiPickerView.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/inputmethod/ui/EmojiPickerView.kt
@@ -1035,6 +1035,8 @@ class EmojiPickerView(
}
}
+ var onBeforeTextCommit: ((String, android.view.inputmethod.InputConnection?) -> Unit)? = null
+
private fun onEmojiSelected(emoji: String, categoryId: String, closeAfterCommit: Boolean? = null) {
val inputConnection = currentInputConnection
// Recents persistence must survive the SYM auto-close: closing the picker evicts this
@@ -1055,6 +1057,7 @@ class EmojiPickerView(
}
// Commit synchronously before closing: a post{} on a view that the close detaches
// would only run again when the picker is re-attached (i.e. the next time it opens).
+ onBeforeTextCommit?.invoke(emoji, inputConnection)
inputConnection?.commitText(emoji, 1)
val shouldClose = closeAfterCommit
?: (
diff --git a/app/src/main/java/it/palsoftware/pastiera/ui/CustomTopBar.kt b/app/src/main/java/it/palsoftware/pastiera/ui/CustomTopBar.kt
index 31bb365ec..e6ada72e4 100644
--- a/app/src/main/java/it/palsoftware/pastiera/ui/CustomTopBar.kt
+++ b/app/src/main/java/it/palsoftware/pastiera/ui/CustomTopBar.kt
@@ -141,7 +141,7 @@ fun CustomTopBar(
color = Color.White
)
Text(
- text = "La Tastiera per la tua Tastiera",
+ text = stringResource(R.string.app_tagline),
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.9f),
fontWeight = FontWeight.Medium
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
new file mode 100644
index 000000000..b7d823116
--- /dev/null
+++ b/app/src/main/res/values-ko/strings.xml
@@ -0,0 +1,1905 @@
+
+ Pastiera
+ Pastiera
+ 영어
+ 이탈리아어
+ 프랑스어
+ 독일어
+ 폴란드어
+ 덴마크어
+ 노르웨이어
+ 스페인어
+ 포르투갈어
+ 러시아어
+ 한국어
+ 세르비아어
+ 우크라이나어
+
+
+ 설정
+ 뒤로
+ 뒤로
+
+
+ 길게 누르기
+ %1$dms
+ 길게 누르기 시간: %1$dms
+ 길게 누르기를 인식하기 전까지 기다리는 시간입니다. 값이 낮을수록 길게 누르기에 빠르게 반응하고, 값이 높을수록 더 오래 눌러야 합니다.
+ 설정이 즉시 적용됩니다
+
+
+ 대문자 처리
+ 띄어쓰기 및 구두점
+ 문자 스타일
+ 키보드 동작
+ 삭제
+
+
+ 텍스트 시작 시 대문자
+ 빈 텍스트 입력란에서 입력을 시작할 때 첫 글자를 대문자로 바꿉니다.
+ 수동 Shift 끄기 유지
+ 같은 텍스트 입력란에 있는 동안 자동 Shift를 끕니다.
+ 모든 텍스트 입력란에서 Shift
+ 주소창, 이메일 및 검색 입력란도 Shift로 시작합니다. 비밀번호 입력란에는 적용되지 않습니다.
+
+
+ 문장 끝 다음에 대문자
+ 마침표와 공백 다음 글자를 대문자로 바꿉니다.
+
+
+ 스페이스 두 번으로 마침표 입력
+ 스페이스를 두 번 눌러 마침표와 공백을 입력합니다.
+
+
+ 하이픈을 대시로
+ “단어 - 단어”를 대시로 바꿉니다.
+ 단어 안의 따옴표
+ 단어 안의 일반 따옴표를 아포스트로피로 바꿉니다.
+ 프랑스어 구두점 간격
+ ; : ! ? 및 길메 앞에 줄바꿈 없는 간격을 넣습니다.
+ 프랑스어 레이아웃에만 적용
+ 현재 입력 언어가 프랑스어일 때만 이 간격을 적용합니다.
+ 쉼표 뒤에 공백
+ 쉼표 뒤에 공백을 추가하고 앞에 있는 공백을 바꿉니다.
+ 구두점 간격
+ 선택됨:
+ 없음
+ 각 문자 앞에서 Pastiera가 자동으로 넣은 공백을 제거하고 다음 텍스트 앞에 지연된 공백을 넣을지 선택합니다.
+ 문자
+ 앞 공백 제거
+ 다음 텍스트 앞
+ 구두점 간격 설명
+ 구두점 간격 작동 방식
+ “앞 공백 제거”는 직접 입력한 공백이 아니라 Pastiera가 자동으로 넣은 공백만 제거합니다.\n\n“다음 텍스트 앞”은 더 많은 텍스트를 입력할 때까지 공백을 미룹니다. 따라서 Enter 또는 보내기를 누르면 뒤에 공백이 남지 않습니다.
+ 선택됨:
+ 끔
+ 초기화
+ 따옴표 스타일
+
+
+ 스페이스로 Alt 해제
+ 스페이스를 누르면 Alt 또는 Alt 잠금을 종료합니다.
+
+
+ 스와이프로 삭제 사용
+ 지원되는 트랙패드 입력에서 왼쪽으로 스와이프하여 이전 단어를 삭제합니다.
+ 스와이프 삭제 제공자
+ Titan 2 키코드 우회
+ 네이티브 IME 이벤트
+
+
+ 삭제
+ 커서 오른쪽의 문자를 삭제하는 대체 방법
+ Nav Mode 설정 열기
+ Shift + Backspace
+ Alt + Backspace
+ 줄 시작에서 Backspace
+ 힌트: Ctrl + 키는 Nav Mode 설정에서 구성할 수 있습니다
+ 텍스트를 선택하면 이러한 대체 방법은 비활성화되고 Backspace가 선택 영역을 삭제합니다.
+
+
+ 스와이프 바 감도
+ 커서를 한 위치 이동하는 데 필요한 거리입니다. 값이 낮을수록 커서가 더 빠르게 이동합니다.
+
+
+ 키보드 자동 표시
+ 텍스트 입력란에서 화면 키보드를 자동으로 표시합니다.
+
+
+ 음성 입력에 Alt+Ctrl 사용
+ Alt+Ctrl로 음성 입력을 시작합니다.
+
+
+ 고정 변형 바
+ 상황에 따른 변형 대신 변형 행에 고정된 유틸리티 키를 항상 표시합니다.
+ 고정 변형 사전 설정
+ 끔
+ 동적 변형과 추천 단어만 사용합니다.
+ 기호
+ 기본 구두점 행입니다.
+ 숫자
+ 빠른 숫자 행입니다.
+ 대체 기호
+ 괄호와 특수 문자입니다.
+ 개발자 선택
+ 엄선된 구두점 행입니다.
+ 대체 상단 행 사전 설정
+ 상단 고정 행을 기본 기호와 대체 괄호/특수 문자 세트 사이에서 전환합니다. Shift와 Device SYM 레이어는 독립적으로 유지됩니다.
+ 고정 레이어
+ Shift 또는 Device SYM 레이어를 두 번 눌러 고정했을 때, 해당 레이어에서 기호를 선택한 뒤에도 계속 활성 상태로 유지합니다.
+ 스와이프로 커서 이동
+
+
+ 정보
+ 설정은 자동으로 저장되며 입력 방법에 즉시 적용됩니다.
+
+
+ Pastiera 키보드
+ Shift
+ Backspace
+ 기호
+ Control
+ Alt
+ 쉼표
+ 마침표
+ 스페이스
+ Enter
+ 언어 전환
+ 키보드 키
+ 선택됨
+ 눌림
+ 사용 중지됨
+ 길게 누르기를 지원하는 물리 키보드
+ 설정
+
+
+ 설치
+ 1. 아래 버튼을 눌러 설정을 엽니다
+ 2. ‘Pastiera Physical Keyboard’를 사용 설정합니다
+ 3. 여기로 돌아와 키보드를 테스트합니다
+ 키보드 설정 열기
+
+
+ Pastiera 사용 설정
+ Pastiera가 사용 설정되지 않음
+ 입력 방법 선택
+ Pastiera가 선택되지 않음
+
+
+ 키보드 테스트
+ 물리 키보드를 연결하고 아래에서 테스트하세요:
+ • 일반 누르기: 문자를 입력합니다
+ • 길게 누르기(500ms): Alt 문자를 입력합니다
+ 테스트 입력란
+ 물리 키보드로 여기에 입력하세요…
+
+
+ 마지막 키보드 이벤트
+ 아직 이벤트 없음
+ BACK 무시
+
+
+ 길게 누르기 매핑
+ Q→0, W→1, E→2, R→3, T→(, Y→), U→-, I→_, O→\', P→:
+ A→@, S→4, D→5, F→6, G→*, H→#, J→+, K→\", L→\'
+ Z→!, X→7, C→8, V→9, B→., N→\', M→?
+
+
+ 설정
+ 키보드 변경
+ 저장
+
+
+ SYM 레이어 맞춤설정
+ SYM 레이어 맞춤설정
+ Device, Emoji 및 Symbols SYM 레이어를 정렬하고 맞춤설정합니다.
+ Device SYM 레이어 맞춤설정
+ 기기별 보조 문자 매핑입니다.
+ 기본값으로 초기화
+ 이모지 선택
+ 닫기
+
+
+ 텍스트 대체
+ 스페이스 또는 Enter를 누를 때 ‘ca → ça’와 같은 저장된 규칙을 적용합니다.
+ 텍스트 대체 관리
+ 저장된 대체 규칙은 자동 수정 전에 실행됩니다.
+ 텍스트 대체
+ 활성화할 대체 규칙 세트를 선택합니다. 이 규칙은 사전 기반 자동 수정 전에 그대로 실행됩니다.
+ 시스템 언어
+ 모든 대체에 적용되는 맞춤설정
+ 다른 대체
+ 맞춤 대체
+ 작동하려면 하나 이상의 대체가 필요합니다
+ 수정
+ 수정 항목을 탭하여 편집하거나 + 버튼으로 새 항목을 추가합니다.
+ 수정 추가
+ 수정 항목 편집
+ 원본
+ 수정 결과
+ 대체 단어를 개인 사전에 추가
+ 삭제
+ 저장
+ 취소
+ 대체 추가
+ 대체: %1$s
+ 단축어
+ 대체 단어도 사전에 추가
+ 대체가 저장됨
+ 대체를 저장할 수 없음
+ 사용 가능한 수정 항목이 없습니다. + 버튼을 눌러 추가하세요.
+ 새 대체 추가
+ 대체 코드
+ 대체 이름
+ 맞춤 대체의 코드와 이름을 입력하세요(예: ‘fr’ / ‘프랑스어’, ‘mydict’ / ‘내 사전’)
+ 대체 이름은 필수입니다
+ 이 대체는 이미 존재합니다
+ 입력 중 추천 단어 표시
+ 악센트 및 철자 기호
+ 자동 수정
+ 스페이스 또는 Enter를 누를 때 사전 추천으로 오타일 가능성이 높은 단어를 수정합니다.
+ 최대 수정 거리
+ 끔
+ 자동 수정에서 허용되는 최대 문자 차이입니다. 0 = 사용 안 함, 1~3 = 허용 범위 증가.
+ 개인 사전
+ 대체 규칙을 만들지 않고도 Pastiera가 인식하고 추천할 단어입니다.
+ 키보드 근접도 순위
+ 키 사이의 거리를 기준으로 가능성이 낮은 오타를 필터링합니다(QWERTY/AZERTY/QWERTZ)
+ 편집 유형 순위
+ 편집 유형별로 추천 순위를 매깁니다(삽입 > 대체 > 삭제)
+ 지우기
+ 실험적 추천 기능
+ 새 사전/추천 엔진 사용(베타)
+
+
+ 사용자 사전
+ 단어 추가
+ 단어 저장
+ 저장된 사용자 단어가 아직 없습니다.
+ 단어 편집
+ 사용 횟수: %1$d
+
+
+ SYM 키보드 맞춤설정
+ SYM 레이아웃 자동 닫기
+ 문자 입력, Alt 또는 Enter를 누른 뒤 SYM 레이아웃을 자동으로 닫습니다. Control 또는 Shift를 누를 때는 닫지 않습니다.
+ 화면 SYM 키를 누른 뒤에도 닫기
+ 화면 SYM 키보드에서 기호 키를 탭한 뒤 SYM 레이아웃을 닫습니다.
+ 이모지 페이지 사용
+ 페이지를 순환할 때 이모지 SYM 레이아웃을 표시합니다.
+ 기호 페이지 사용
+ 페이지를 순환할 때 기호 SYM 레이아웃을 표시합니다.
+ 클립보드 페이지 사용
+ 페이지를 순환할 때 클립보드 기록을 표시합니다.
+ 이모지 선택기 페이지 사용
+ 페이지를 순환할 때 전체 이모지 선택기를 표시합니다.
+ 더 큰 이모지 선택기
+ 이모지 검색 페이지를 약 1.5배 높이로 사용합니다. 다른 SYM 페이지는 일반 높이를 유지합니다.
+ SYM 페이지 순서 정렬
+ 드래그하거나 화살표를 사용하여 순환 순서를 정합니다. 스위치는 항목을 순환 목록에 포함할지 여부만 제어합니다.
+ Device SYM 레이어
+ 이모지 레이어
+ 기호 레이어
+ 클립보드
+ 이모지 선택기
+ 키 레이어
+ 패널
+ 위로 이동
+ 아래로 이동
+ Alt 보조키 연결
+ Alt 보조키가 실행할 SYM 매핑을 선택합니다. SYM 순환 표시 여부는 독립적으로 유지됩니다.
+ Device SYM 레이어 · 현재 입력 소스
+ SYM 순서의 첫 번째 키 레이어
+ 보조키
+ SYM 레이어, 길게 누르기, 보조키 동작 및 표시
+ SYM
+ 길게 누르기
+ Alt 키
+ 보조키 표시
+ Control
+ Nav Mode에서 Control
+ 현재 Ctrl이 Nav Mode 보조키로 구성되어 있습니다. Nav Mode를 열어 키 할당과 동작을 변경하세요.
+ 보조키 설정에서 Alt가 사용할 SYM 레이어를 선택합니다.
+ SYM 동작 및 표시
+ 이모지 레이어 편집
+ 기호 레이어 편집
+ 레이어 편집
+ 이모지 먼저
+ 기호 먼저
+ 클립보드 먼저
+ 안내
+ 아래 키를 탭하여 해당 이모지를 변경합니다. 변경 사항은 자동으로 저장됩니다.
+ 기본값으로 초기화
+ 기본값으로 초기화
+ 모든 SYM 매핑을 기본값으로 초기화할까요? 이 작업은 취소할 수 없습니다.
+ 초기화
+ 취소
+
+
+ 이모지 선택
+ %1$s 문자에 사용할 이모지 선택
+ 닫기
+ 스마일리 및 사람
+ 동물 및 자연
+ 음식 및 음료
+ 여행 및 장소
+
+
+ SHIFT
+ CTRL
+
+
+ 길게 누르기 보조키
+ 길게 누를 때 SYM 레이어나 변형을 열지, Shift를 적용할지 선택합니다.
+ Device SYM 레이어(이전 Alt)
+ Shift
+ 변형
+ 첫 번째 이모지 또는 기호 레이어
+ 기호 레이어
+ 이모지 레이어
+ 고급 보조키 탭 동작
+ 한 번 탭하여 Shift 잠금
+ Shift를 한 번 탭하면 한 글자만 대문자로 만드는 대신 대문자 잠금이 됩니다.
+ 한 번 탭하여 Alt 잠금
+ Alt를 한 번 탭하면 다시 탭할 때까지 Device SYM 레이어를 활성 상태로 유지합니다.
+ 스페이스 뒤에도 Alt 잠금 유지
+ Alt가 잠겨 있으면 스페이스를 눌러도 잠금이 끝나지 않고 Device SYM 레이어가 활성 상태로 유지됩니다.
+ 한 번 탭하여 Ctrl 잠금
+ Ctrl을 한 번 탭하면 다시 탭할 때까지 Ctrl을 활성 상태로 유지합니다.
+ Ctrl+Space 뒤에도 Ctrl 잠금 유지
+ Ctrl+Space로 레이아웃을 전환한 뒤에도 잠금이 끝나지 않고 Ctrl을 잠금 상태로 유지합니다.
+ 입력 소리
+ 끔
+ 키보드 클릭음
+ 타자기
+ 맞춤 소리 팩…
+ 입력 소리 출력
+ 미디어 음량
+ 시스템 소리
+ 알림
+ 시스템 소리와 알림은 무음 모드 및 방해 금지 설정을 따릅니다.
+ 입력 소리 팩을 가져옴
+ 이 소리 팩을 가져올 수 없습니다. normal/, space/, backspace/, enter/ 및 modifier/ 폴더에 오디오 파일이 들어 있는 ZIP을 사용하세요.
+ 소리 팩 형식
+ pastiera.eu에서 가져오기 안내 열기
+ https://pastiera.eu/guides/typing-soundpacks/
+ 탭 진동
+ 호환되는 기기에서 추천과 변형에 Android 키보드 햅틱을 사용합니다.
+ 맞춤 진동: %1$dms
+ Android 키보드 햅틱이 사용 중지된 경우 사용됩니다.
+
+
+ 키보드 레이아웃
+ 키보드 레이아웃을 선택합니다. ALT, SYM 및 Ctrl 매핑은 물리 키 위치를 기준으로 유지됩니다.
+ 키보드 레이아웃 편집기
+ 표준
+ 표준 QWERTY 레이아웃(변환 없음)
+ 레이아웃 변환
+ 선택한 레이아웃에 맞게 문자 키를 변환합니다
+ 키보드 매핑
+ 레이아웃: %1$s
+ 이 레이아웃 매핑을 불러올 수 없습니다.
+ 이 레이아웃의 키 매핑을 찾을 수 없습니다.
+ 멀티탭
+ 멀티탭
+ 단일
+ 매핑 보기
+ 키보드 레이아웃 설정
+ 자동 레이아웃 매핑
+ 켜짐: subtype/로케일 기본 매핑을 따릅니다.
+ 꺼짐: 모든 로케일에 하나의 수동 선택 레이아웃을 사용합니다.
+ Alt+Shift 레이아웃 전환
+ Alt+Shift로 언어/레이아웃을 순환합니다.
+ Alt+Enter 레이아웃 전환
+ Alt+Enter로 언어/레이아웃을 순환합니다.
+ Ctrl+Space 레이아웃 전환
+ Ctrl+Space로 언어/레이아웃을 순환합니다.
+ 레이아웃 전환 단축키
+ 레이아웃 전환 토스트
+ 언어/레이아웃을 전환할 때 토스트를 표시합니다.
+ 물리 키보드 프로필
+ 기기 지문으로 자동 감지(현재: %1$s).
+ 기기별 키 매핑을 수동으로 덮어씁니다.
+ 자동
+ BlackBerry Key2
+ BlackBerry Q25
+ Unihertz Titan
+ Unihertz Titan 2
+ Titan 2 Elite(QWERTY)
+ Minimal Phone(MP01)
+ 통화 기호
+ 전용 하드웨어 통화 키로 입력되는 기호입니다.
+ 내장 키보드
+ 이 휴대전화에 내장되었거나 영구적으로 연결된 키보드용 기기별 프로필입니다.
+ 감지된 기기
+ %1$s · %2$s
+ 기기 전용
+ Razr용 Clicks | Razrberry
+ Pixel 9/9 Pro용 Clicks | Clixel
+ Clicks Power Keyboard(변환됨)
+ 기기별 동작
+ Unihertz 전용 키 처리 및 레이아웃입니다.
+ 엄선된 프로필
+ 키보드 프로필
+ 맞춤 프로필
+ Device SYM 레이어 편집기(이전 Alt 키 편집기)에서 만든 맞춤 프로필이 여기에 표시됩니다.
+ Clicks Power Keyboard
+ 키, 백라이트, 절전, 충전 및 자동화
+ BLE Power Keyboard용으로 계획된 제어 기능입니다.
+ Clicks Power Keyboard를 직접 BLE로 제어합니다. 설정은 키보드에 저장됩니다.
+ 연결됨 · 슬롯 %1$s · 펌웨어 %2$s · 키보드 배터리 %3$s%%
+ GATT 식별 정보
+ 모델 %1$s · 일련번호 %2$s
+ 페어링된 기기 슬롯
+ 페어링된 슬롯을 보고 각 기기의 이름을 직접 변경합니다.
+ 이름 없는 기기
+ 활성 슬롯 %1$s · 사용 중: %2$s
+ 없음
+ 페어링된 기기 이름 편집
+ 슬롯 %1$d
+ 슬롯 %1$d · 활성
+ 기기 이름
+ 키보드에 최대 30바이트의 UTF-8로 저장됩니다.
+ 숫자 행 단축키
+ 각 SYM + 숫자 단축키를 직접 지정합니다.
+ 단축키 선택
+ 사용 안 함(키보드 기본값)
+ Escape
+ 음량 높이기
+ 음량 낮추기
+ 음소거
+ 재생/일시중지
+ 다음 트랙
+ 이전 트랙
+ 페이지 위로
+ 페이지 아래로
+ Home
+ End
+ 왼쪽 괄호
+ 오른쪽 괄호
+ SYM 잠금
+ 키보드 펌웨어에서 SYM 레이어를 잠글 수 있도록 합니다.
+ Caps Lock
+ 키보드 펌웨어에서 Shift를 잠글 수 있도록 합니다.
+ 소프트 Return
+ 키보드 펌웨어의 소프트 Return 동작을 사용합니다.
+ 커서 모드
+ 키보드 펌웨어의 커서 탐색 모드를 사용합니다.
+ 권장 설정 적용
+ 기본 Tab 및 Alt 출력을 복원하고, 마이크 버튼을 Pastiera 받아쓰기에 할당하며, 빨간 Clicks 버튼과 Launcher 버튼을 QuickLauncher에 직접 할당하고, Caps Lock과 커서 모드를 사용 중지합니다.
+ 권장 Clicks 설정이 적용되었습니다.
+ 권장 설정을 완전히 적용하지 못했습니다.
+ Clicks 펌웨어의 SYM 잠금 동작을 제어합니다. Pastiera의 별도 고정 Alt 동작은 보조키 설정에서 구성합니다.
+ Clicks 펌웨어의 Caps Lock 동작을 제어합니다. Pastiera의 별도 고정 Shift 동작은 보조키 설정에서 구성합니다.
+ 커서 모드가 사용 설정되면 Clicks 펌웨어는 커서 이동에 W, A, S 및 D의 SYM 조합을 사용합니다.
+ 키보드가 제공하는 펌웨어 옵션입니다. 정확한 동작이 아직 신뢰성 있게 문서화되지 않았으므로 테스트 중이 아니라면 변경하지 않는 것이 좋습니다.
+ 밝기
+ %1$s초 후 어두워짐 · %2$s분 후 절전
+ 키보드 배터리 예비량
+ 키보드 배터리 %1$d%% · 실시간
+ 키보드 배터리 %1$d%% · 마지막 확인값
+ 키보드 배터리 사용 불가 · 실시간
+ 키보드 배터리 사용 불가 · 마지막 확인값
+ 키보드 배터리 %1$d%% · 휴대전화 배터리 %2$d%% · 예비량까지 키보드 %3$d포인트 ≈ 휴대전화 %4$d포인트 증가
+ 키보드 배터리 %1$d%%(마지막 확인값) · 휴대전화 배터리 %2$d%% · 예비량까지 키보드 %3$d포인트 ≈ 휴대전화 %4$d포인트 증가
+ 키보드 배터리 %1$d%% · 예비량까지 키보드 %2$d포인트 ≈ 휴대전화 %3$d포인트 증가
+ 키보드 배터리 %1$d%%(마지막 확인값) · 예비량까지 키보드 %2$d포인트 ≈ 휴대전화 %3$d포인트 증가
+ 키보드 배터리 %1$d%% · 예비량까지 %2$d포인트 · 보정 측정 %3$d/%4$d회, 키보드 %5$d/%6$d포인트
+ 키보드 배터리 %1$d%%(마지막 확인값) · 예비량까지 %2$d포인트 · 보정 측정 %3$d/%4$d회, 키보드 %5$d/%6$d포인트
+ 백라이트를 어둡게 할 시간
+ 키보드를 절전 모드로 전환할 시간
+ %1$d초
+ %1$d분
+ 적용
+ 기본 숫자 행 동작
+ 현재 맞춤 매핑(%1$s)
+ Launcher 및 특수 버튼
+ 빨간 Clicks 버튼, Launcher 버튼, 키보드 버튼 및 마이크 버튼을 한 곳에서 구성합니다.
+ 각 물리 버튼이 원래 기능을 유지할지, Pastiera 기능을 직접 실행할지, 키 또는 키 조합을 보낼지 선택합니다.
+ 원래 기능
+ Pastiera 기능
+ Pastiera 단축키
+ 키 입력
+ 키 조합
+ 언어 전환(Ctrl+Space)
+ Ctrl+Space를 보내고 Pastiera의 Ctrl+Space 언어 전환을 사용 설정합니다.
+ 받아쓰기 모드(Alt+Ctrl)
+ Alt+Ctrl을 보내고 Android Gemini 대신 Pastiera 받아쓰기 모드를 시작합니다. 필요한 Pastiera 단축키는 자동으로 사용 설정됩니다.
+ 빨간 Clicks 버튼
+ 빨간 C 버튼
+ Launcher 버튼
+ Ctrl 옆의 검색 기호 버튼
+ 키보드 버튼
+ 키보드 기호 버튼
+ 마이크 버튼
+ 오른쪽 아래 마이크 버튼
+ 기본 Tab
+ 빨간 버튼의 원래 Tab 이벤트를 유지합니다.
+ 기본 왼쪽 Meta
+ 왼쪽 Meta를 변경 없이 Android로 전달합니다.
+ 시스템 키보드 전환
+ 시스템 전체 키보드 또는 입력 방법을 전환하는 원래 키보드 동작을 유지합니다.
+ 기본 음성 입력
+ Android의 기본 음성 입력 동작을 사용합니다.
+ 키보드 펌웨어에서 실제 Alt 보조키를 직접 보냅니다.
+ 키 조합으로 %1$s을(를) 보냅니다. Pastiera 기능과 암묵적으로 연결되지 않습니다.
+ 현재 저장된 맞춤 키보드 펌웨어 조합을 유지합니다.
+ 적용 중…
+ 버튼 할당이 적용되었습니다.
+ 버튼 할당을 적용하지 못했습니다.
+ 지원되는 키보드 연결이 준비되면 적용됩니다.
+ Pastiera Clicks 버튼 가로채기
+ 지원되는 Clicks Power Keyboard에서 명시적으로 구성된 버튼만 가로챕니다.
+ Android Clicks 버튼 가로채기
+ 사용 설정됨 · 지원되는 Clicks Power Keyboard에서 명시적으로 구성된 버튼만 필터링됩니다.
+ 소프트웨어로 할당한 Clicks 버튼에 필요합니다. 탭하여 Android 접근성 설정에서 사용 설정하세요.
+ 기본
+ 버튼의 원래 Android 키 이벤트를 유지합니다.
+ QuickLauncher
+ Pastiera QuickLauncher를 직접 열고 키 이벤트를 앱에 전달하지 않습니다.
+ Pastiera 열기
+ Pastiera 설정을 직접 열고 키 이벤트를 앱에 전달하지 않습니다.
+ 키보드 모드 전환
+ 다음 하드웨어 연결 변경 시까지 화면 키보드를 임시로 열거나 닫습니다.
+ 이모지 선택기 전환
+ Pastiera 이모지 선택기를 열거나 이미 표시되어 있으면 닫습니다.
+ Alt
+ 누르고 있는 실제 Alt 보조키처럼 동작합니다.
+ Tab
+ Pastiera의 일반 입력 경로를 통해 실제 Tab 키를 보냅니다.
+ SYM
+ 실제 Android SYM 보조키를 Pastiera로 보냅니다. Clicks 펌웨어의 물리 SYM 레이어와는 독립적입니다.
+ Clicks Power Keyboard 연결 끊김(GATT %1$d)
+ GATT 서비스를 읽을 수 없습니다.
+ Clicks 제어 서비스를 사용할 수 없습니다.
+ Clicks 알림을 사용 설정할 수 없습니다.
+ 페어링된 Clicks Power Keyboard를 찾을 수 없습니다.
+ GATT 연결을 시작할 수 없습니다.
+ Clicks GATT 연결 시간이 초과되었습니다.
+ Clicks GATT 작업 시간이 초과되었습니다. 안전하게 다시 연결하여 재시도하세요.
+ 기기
+ 연결 및 기기 정보
+ 연결 상태, 키보드 배터리 및 펌웨어 버전입니다.
+ 연결됨 · 슬롯 %1$s
+ 연결되지 않음
+ 연결되지 않음 · 마지막으로 확인된 기기 데이터: %1$s
+ 연결됨 · 슬롯 %1$s · 펌웨어 %2$s
+ 연결됨 · 슬롯 %1$s · 키보드 배터리 %2$d%%
+ 연결됨 · 슬롯 %1$s · 펌웨어 읽는 중…
+ 연결됨 · 슬롯 %1$s · 탭하여 펌웨어 버전 읽기
+ 연결됨 · 슬롯 %1$s · 펌웨어 버전 사용 불가
+ 펌웨어 요구사항
+ 이전 펌웨어는 Ctrl 키를 Ctrl 보조키가 아닌 전역 입력 언어 전환으로 보냅니다. Clicks 앱에서 키보드를 업데이트하세요.
+ 펌웨어 %1$s 설치됨 · 준비 완료. Ctrl이 올바른 보조키로 작동합니다.
+ 펌웨어 %1$s 설치됨 · 1.0.9 이상으로 업데이트하세요. 이전 펌웨어는 Ctrl을 전역 입력 언어 전환으로 보냅니다.
+ 펌웨어 업데이트
+ Clicks 앱을 열어 펌웨어 업데이트를 확인하고 설치하세요.
+ 키보드 펌웨어를 업데이트하려면 Google Play에서 Clicks 앱을 설치하세요.
+ 주변 기기 액세스를 허용할까요?
+ Pastiera가 페어링된 Clicks Power Keyboard와 직접 통신하려면 주변 기기 액세스가 필요합니다. 이를 통해 키보드 상태와 배터리 데이터를 읽고 키 동작, 백라이트, 재매핑 및 충전 자동화 같은 확장 기능을 제어할 수 있습니다.
+ 계속
+ 지금은 안 함
+ 키보드 동작
+ SYM, Ctrl 및 Alt 동작
+ 보조키를 Clicks의 부분 대체가 아닌 실제 Android 보조키로 처리합니다.
+ 겹치는 키 누르기 무시
+ 이 Clicks 키보드가 물리적으로 인접한 겹침만 무시할지, 두 번째 비보조키를 모두 무시할지 선택합니다. 보조키 단축키는 계속 작동합니다.
+ 끔
+ 인접한 키만 무시
+ 겹치는 비보조키 모두 무시
+ 숫자 행 입력 보호
+ 간격이 좁은 물리 숫자 행의 실수로 인한 누르기를 줄입니다.
+ 추가 규칙 없음
+ 인접한 키를 누르는 동안 무시
+ 어떤 키를 누르는 동안 무시
+ 모든 숫자 키 무시
+ 누르고 뗄 때만 입력
+ 숫자 행 키 반복
+ 물리 키를 누르고 있는 동안 숫자를 반복합니다.
+ 키 매핑
+ 전체 키 재매핑
+ 모든 키에 대한 Pastiera 측 Alt, Ctrl, 보조키 및 동작 할당입니다.
+ 백라이트 및 절전
+ 백라이트
+ 사용 여부와 밝기를 0~100%로 설정합니다.
+ 어두워짐 및 유휴 시간
+ 백라이트를 어둡게 하는 지연 시간과 BLE 절전 시간입니다.
+ 무선 충전
+ 충전 및 예비량
+ 무선 충전을 사용하고 키보드 배터리 예비량을 선택합니다.
+ 15분 충전
+ 자동 임계값을 임시로 재정의합니다.
+ 연결된 동안 평가
+ 키보드가 닫혀 연결이 끊긴 동안 Pastiera는 충전 명령을 보낼 수 없습니다. 현재 상태는 변경되지 않으며, 다시 연결하면 임계값과 만료된 재정의가 즉시 적용됩니다.
+ 휴대전화 배터리 임계값
+ 휴대전화 배터리가 선택한 비율 아래로 떨어질 때만 충전을 시작합니다.
+ 자동 무선 충전
+ 시작 임계값 아래에서 충전하고 상한 임계값에서 중지합니다.
+ 이하에서 켜기
+ 이상에서 끄기
+ 자동화
+ 열기, 연결 및 키보드 모드
+ 열거나 연결할 때 반응하고 하드웨어/가상 키보드 모드를 선택적으로 전환합니다.
+ 연결 해제 시 입력 닫기
+ 켜짐: Clicks 연결 해제 시 키보드를 닫습니다. 꺼짐: 활성 텍스트 입력란에서 화면 키보드를 자동으로 엽니다.
+ 텍스트 포커스가 있을 때만 키보드 표시
+ 커서가 텍스트 입력란에 있을 때 Clicks 연결 변경 후에만 물리 또는 가상 키보드를 표시합니다.
+ 계획된 프로필 지원
+ 기기 감지
+ 키보드를 식별하고 엄선된 프로필을 자동으로 선택합니다.
+ 키 이벤트 정규화
+ 모델별 스캔 코드와 보조키를 캡처하고 정규화합니다.
+ 기기 제어
+ 하드웨어가 제공하는 경우 모델별 제어 기능을 표시합니다.
+ Device SYM 레이어 편집기
+ 맞춤 프로필을 만들거나 엄선된 프로필을 템플릿으로 복제합니다. 이전 이름은 Alt 키 편집기입니다.
+ 여기서 만든 맞춤 하드웨어 프로필을 엄선된 프로필과 함께 선택할 수 있습니다.
+ 프로필 만들기
+ 새 빈 프로필
+ 빈 프로필에서 시작하여 키를 수동으로 할당합니다.
+ 엄선된 프로필 복제
+ BlackBerry, Unihertz, Minimal 또는 Clicks 프로필을 편집 가능한 템플릿으로 사용합니다.
+ 편집 범위
+ 이름 및 기기 일치
+ 프로필 메타데이터와 선택적인 기기 이름 일치를 구성합니다.
+ Device SYM 및 키 매핑
+ 문자, 보조키, 단축키 및 Pastiera 동작을 할당합니다.
+ 가져오기 및 내보내기
+ 맞춤 프로필을 공유하고 Pastiera 백업으로 복원합니다.
+ 계획됨
+ 개발 중
+ 이 기능은 개발 중이거나 계획된 기능입니다. 아직 불완전하거나 사용할 수 없거나 변경될 수 있습니다.
+ 실험적 기능
+ 이미 작동할 수 있지만 새 기능이므로 곧 변경되거나 개선될 수 있습니다. 버그 제보를 환영합니다.
+ 기능 상태 기호
+ Pastiera는 앱 안에서 개발 중인 기능을 직접 표시하므로 모든 GitHub 이슈를 찾아볼 필요가 없습니다.
+ 개발 중
+ 이 기능은 개발 중이거나 계획된 기능입니다. 아직 불완전하거나 사용할 수 없거나 변경될 수 있습니다.
+ 실험적 기능
+ 이미 작동할 수 있지만 새 기능이므로 곧 조정되거나 개선될 수 있습니다. 버그 제보를 환영합니다.
+ 설정에서 두 기호 중 하나를 탭하면 간단한 설명을 볼 수 있습니다. 로드맵 표시는 특정 버전 출시를 약속하는 것이 아닙니다.
+
+
+ GitHub
+ Pastiera IME - Palsoftware 2026
+ 기기: %1$s; 키보드: %2$s
+ 정보
+ 크레딧 정보를 불러올 수 없습니다.
+
+
+ Pastiera-QuickLauncher
+ 빠른 실행, SYM 단축키 및 런처 키 할당
+ 이 설정은 하나의 런처 키 할당 목록을 공유합니다. Pastiera가 할당된 키를 수신할 위치를 선택하세요.
+ 홈 화면 바로가기
+ 실험적 기능
+ Android 런처 또는 홈 화면에서 할당된 런처 키를 직접 사용합니다.
+ 할당된 런처 키
+ 키를 탭하여 명령을 할당하거나 교체합니다. 할당된 키는 두 트리거 모드에서 공유됩니다.
+ 키를 탭하여 명령을 할당하거나 교체합니다. 현재 QuickLauncher가 %1$s에 할당되어 있습니다.
+ 새 기능: SYM으로 빠른 런처를 열 수 있습니다. Pastiera는 보통 이를 스페이스에 할당하지만 스페이스에는 이미 단축키가 있습니다. 할당 페이지를 열고 아무 키에나 ‘빠른 런처 열기’를 선택하여 사용해 보세요.
+
+
+ Pastiera-QuickLauncher
+ 할당되지 않음
+ 삭제
+ 앱(이름 없음)
+ 바로가기: %1$s
+ 알 수 없음
+ 동작: %1$s
+
+
+ 키에 명령 할당
+ 키 %1$s
+ 단축키
+ 명령 검색…
+ 검색
+ 닫기
+ 명령을 찾을 수 없음
+ ‘%1$s’에 대한 결과 없음
+ 키 %1$d
+ Pastiera-QuickLauncher
+ 입력하여 검색합니다. Enter를 누르면 첫 번째 결과를 엽니다.
+ 닫기
+ 입력하여 검색 시작…
+ 결과가 하나만 남으면 자동으로 실행
+ 일치 항목 하나를 자동으로 열기
+ 빠른 런처 검색 결과 앱이 하나만 남으면 Enter를 누르지 않고 실행합니다.
+ 상위 검색 결과만 표시
+ 전체 알파벳순 앱 목록을 표시하지 않습니다. 입력하기 전에는 비워 두고 입력 후 상위 세 개의 결과만 표시합니다.
+ 텍스트 입력란에서 SYM+Space 허용
+ 사용 설정하면 커서가 앱의 텍스트 입력란 안에 있을 때도 QuickLauncher를 열 수 있습니다.
+ 텍스트 입력란에서 SYM을 누른 채 스페이스를 누르세요. SYM 잠금은 SYM을 탭하면 SYM 페이지가 순환되므로 이 기능을 실행할 수 없습니다.
+ 텍스트 입력란에서 Alt+Space 허용
+ 공백을 입력하는 대신 Alt+Space로 QuickLauncher를 엽니다. 기본적으로 사용 중지되어 있습니다.
+ 텍스트 입력란 밖에서 Alt 단축키 허용
+ 할당된 키와 Alt를 사용하여 SYM 키 단축키와 같은 앱 또는 동작을 실행합니다.
+ 활성 키보드 레이아웃 사용
+ 사용 설정하면 검색 입력이 Android의 원시 QWERTY 키 출력 대신 현재 Pastiera 레이아웃을 따릅니다.
+ 오타 허용 검색
+ 작은 입력 실수가 있는 앱 이름도 일치시킵니다. 기존 접두사 및 약어 일치는 계속 더 높은 순위를 가집니다.
+ QuickLauncher 동작
+ 단축키로 Pastiera 검색 또는 Niagara Launcher 검색 중 무엇을 열지 선택합니다. Niagara를 사용할 수 없으면 Pastiera로 대체됩니다.
+ Pastiera QuickLauncher
+ Niagara Launcher 검색
+ 순위 작동 방식
+ QuickLauncher 순위
+ 결과는 정확한 앱 이름, 앱 이름 접두사, 단어 접두사, 약어/부분 문자열 일치, 오타를 허용한 앱 이름 일치, 패키지 이름 일치 순으로 정렬됩니다. 매우 짧은 검색어에는 오타 허용 기능이 적용되지 않습니다.
+ 동작
+ 런처 제공자, 검색/순위 규칙 및 텍스트 입력란에서의 SYM+Space입니다.
+ 애니메이션 시간
+ %1$dms
+ 모양
+ 너비, 알약 모드, 표시할 명령 소스, 즐겨찾기, 별칭 및 항목 색상입니다.
+
+
+ 키보드 테마
+ 사전 설정 색상, 키보드 미리보기, 키, 구분선 및 LED입니다.
+ 사전 설정 선택
+ 키보드 UI 미리보기
+ 색상 맞춤설정
+ 색상
+ 키
+ 물리 키보드 테마 할당
+ 화면 키보드 테마 할당
+ 고정
+ 시스템 설정 따르기
+ 밝은 모드 테마
+ 어두운 모드 테마
+ 레이아웃 재정의
+ 특정 언어나 레이아웃에 테마를 강제 적용합니다. 위 모드보다 재정의가 우선합니다.
+ 재정의 추가
+ 테마 재정의
+ 입력 스타일
+ 현재 입력 스타일
+ 현재 레이아웃
+ 모든 언어
+ 테마
+ 활성
+ 새 테마
+ 빈 초안을 만듭니다. 값을 설정할 때까지 필수 값은 비어 있습니다.
+ 테마 이름
+ 저장된 테마나 초안에서 이미 사용하는 이름입니다.
+ 초안 만들기
+ 초안 · %1$d/%2$d개 섹션
+ %1$d단계 / %2$d단계
+ 기본 색상
+ 키보드 표면, 키, 구분선 및 읽기 쉬운 전경색을 설정합니다.
+ 상호작용 색상
+ 강조 색상, 추천, 커서 피드백, 상태 버튼 및 팝업 색상을 설정합니다.
+ LED 색상
+ 사용 중지, 활성 및 잠긴 보조키 표시 색상을 선택합니다.
+ 기하
+ 모서리 둥글기, 키 크기, 간격, 추천 및 변형을 구성합니다.
+ 키보드 동작
+ 레이아웃, LED 및 키 팝업 표시 동작을 확인합니다.
+ 검토
+ 이 초안을 활성 테마로 바꾸기 전에 확인한 모든 섹션을 검토합니다.
+ 확인하고 계속
+ 테마 사용
+ 초안 삭제
+ ‘%1$s’ 초안을 삭제할까요?
+ 초안 저장 후 닫기
+ 이 테마를 사용하기 전에 모든 섹션을 확인하세요.
+ 새로 만들기
+ 가져오기
+ 복제
+ 다른 이름으로 저장
+ 초안 복제
+ 내보내기
+ 삭제
+ 테마 복제
+ 테마를 다른 이름으로 저장
+ 초안 복제
+ 테마 가져오기
+ 테마 JSON
+ 완전하고 올바른 테마가 아닙니다.
+ 가져온 테마 이름 지정
+ 저장하고 사용
+ 초안 편집 중
+ 필수 값 %1$d개 누락
+ 필수
+ 값 설정
+ 미리보기를 사용하려면 모든 필수 색상을 설정하세요.
+ 테마 저장 및 사용
+ 켜짐
+ 꺼짐
+ 강조 색상
+ 추천
+ 상태 표시줄 버튼
+ 커서 스와이프
+ 키 팝업
+ 선택된 팝업 키
+ 키 모서리 둥글기
+ 추천/변형 모서리 둥글기
+ 키 높이
+ 숫자 행 높이
+ 키 너비
+ 행 간격
+ 추천 높이
+ 변형 높이
+ LED 표시
+ 간격 균등 배분
+ 직교 배열
+ 팝업을 키에 연결
+ 팝업 꼬리 연결선
+ 누르고 있을 때만 키 미리보기 표시
+ 길게 눌러 문자 선택기
+ 초기화
+ 배경
+ 구분선
+ 일반 키
+ 특수 키
+ 텍스트 및 아이콘
+ LED 비활성
+ LED 활성
+ LED 잠김
+ 런처 너비
+ 화면 너비의 %1$d%%
+ 알약 모드
+ 간결한 검색 알약으로 열고 입력한 후에만 확장합니다.
+ 입력하여 앱 검색
+ 앱을 불러오는 중…
+ 빠른 런처 열기
+ 앱을 찾을 수 없음
+ ‘%1$s’에 대한 결과 없음
+ Enter
+
+
+ SYM 키 단축키
+ 할당된 키와 SYM을 사용하여 앱과 동작을 실행합니다.
+ Alt 키 단축키
+ 할당된 키와 Alt를 사용하여 앱과 동작을 실행합니다.
+ Alt 단축키 및 텍스트 입력란 액세스를 구성합니다.
+ 텍스트 입력란에서 허용
+ 입력 중에도 할당된 SYM 단축키를 사용합니다.
+ 입력 중에도 할당된 Alt 단축키를 사용합니다.
+ SYM 키 호환성 정보
+ SYM 키 호환성
+ SYM 키 단축키를 사용하려면 Android에 SYM을 보조키로 노출하는 키보드가 필요합니다. 펌웨어가 SYM 조합을 Pastiera가 받기 전에 최종 문자나 탐색 이벤트로 변환하는 키보드에서는 현재 작동하지 않습니다. 여기에는 현재 SYM 동작을 변환하는 Clicks Power Keyboard가 포함됩니다.
+ 단축키를 눌러 실행
+
+
+ Pastiera Nav Mode
+ Pastiera Nav Mode 알림
+ Nav Mode 활성화됨
+ Nav Mode가 활성화됨
+
+
+ Pastiera 업데이트
+ 새 Pastiera 버전에 대한 알림
+ Pastiera - 새 업데이트 사용 가능
+ Pastiera의 새 버전이 있습니다(%1$s)
+
+
+ 지금 말하세요…
+ 인식된 텍스트가 없습니다. 다시 시도하세요.
+ 음성 입력이 감지되지 않았습니다.
+ 마이크 권한이 거부되었습니다.
+ 네트워크 오류입니다.
+ 음성 인식 오류입니다.
+ 음성 인식을 사용할 수 없습니다.
+
+
+ 수정 항목 검색…
+ 검색
+ 검색 지우기
+ 수정 항목을 찾을 수 없음
+
+
+ Nav Mode
+ Nav Mode 안내
+ Ctrl, 매핑, 앱 단축키 및 레이아웃 힌트가 함께 작동하는 방식입니다.
+ Nav Mode는 문자 키를 탐색 및 단축키로 바꿉니다. 일부 앱은 Android 편집기 동작을 이해하지만 다른 앱은 실제 Ctrl 단축키에만 반응하므로 결과가 앱마다 다를 수 있습니다.
+
+Ctrl 누르기 탐색
+Ctrl을 누르는 동안 탐색이 꺼져 있으면 물리적으로 누르고 있는 Ctrl이 가능한 경우 앱으로 전달됩니다. 그러면 편집기에서 Ctrl+B, Ctrl+I 또는 Ctrl+Z 같은 단축키를 직접 사용할 수 있습니다.
+
+Ctrl을 누르는 동안 탐색이 켜져 있으면 물리적으로 누르고 있는 Ctrl이 아래의 Nav Mode 매핑을 먼저 사용합니다. 매핑된 키는 구성된 Nav Mode 명령을 실행합니다.
+
+Ctrl 두 번 누르기
+Ctrl을 두 번 누르면 Nav Mode가 잠깁니다. 그 후에는 Nav Mode를 종료할 때까지 문자 키를 누를 때 Nav Mode 매핑이 실행됩니다. Ctrl을 계속 누르지 않고도 화살표, Page Up/Down, Escape, Tab 및 선택 명령을 사용할 때 유용합니다.
+
+동작과 기본 Ctrl
+복사, 붙여넣기, 잘라내기, 모두 선택 및 실행 취소 같은 동작은 Android 편집기 동작으로 전송됩니다. 많은 텍스트 입력란에서 작동하지만 모든 앱이 모든 동작을 지원하는 것은 아닙니다.
+
+앱이 실제 단축키를 요구하면 기본 Ctrl을 사용하세요. 예를 들어 I를 기본 Ctrl로 매핑하면 Ctrl+I가 전송되므로 Google Docs 같은 편집기에서 기울임꼴을 사용할 수 있습니다. 앱이 Android 실행 취소보다 Ctrl+Z에 더 잘 반응한다면 실행 취소도 기본 Ctrl로 매핑할 수 있습니다.
+
+물리 QWERTY에서 QWERTZ 사용
+Nav Mode는 물리 키 위치를 사용하므로 탐색이 안정적으로 유지됩니다. 활성 텍스트 레이아웃에서 해당 물리 키를 다른 문자로 처리할 때는 키에 레이아웃 힌트가 표시됩니다(예: QWERTZ의 Y/Z).
+
+레이아웃을 인식하는 앱 Ctrl 단축키
+Ctrl을 누른 채 앱으로 전달할 때 이 선택 설정을 사용하면 활성 Pastiera 레이아웃을 먼저 거쳐 Ctrl+문자를 변환할 수 있습니다. 앱 단축키에 영향을 주며 물리 Nav Mode 격자에는 영향을 주지 않습니다.
+
+재정의
+각 키는 키코드, 동작, 기본 Ctrl 또는 없음으로 구성할 수 있습니다. 전체 모드를 바꾸지 않고 앱별 충돌을 해결하려면 재정의를 사용하세요.
+ Nav Mode 사용
+ Ctrl을 두 번 눌러 탐색 모드 활성화
+ Ctrl 누르기 탐색
+ 사용 설정하면 Ctrl을 누르는 동안 텍스트 입력란에서 Nav Mode 매핑을 사용합니다. 끄면 Ctrl 단축키가 앱으로 전달됩니다. 기본 Ctrl로 설정된 키는 항상 실제 Ctrl 단축키로 전달됩니다.
+ 레이아웃을 인식하는 앱 Ctrl 단축키
+ Ctrl을 누른 채 앱으로 전달할 때 Ctrl+문자를 먼저 활성 Pastiera 레이아웃으로 변환합니다.
+ Ctrl을 누르는 동안 Nav Mode가 처리하므로 Ctrl 누르기 탐색이 활성화된 동안에는 사용 중지됩니다.
+ Ctrl 단축키와 Nav Mode
+ Ctrl을 누른 상태에서는 두 가지 경로가 있습니다. Ctrl 누르기 탐색이 켜져 있으면 Pastiera가 아래에 표시된 Nav Mode 매핑으로 Ctrl+키를 처리합니다. 꺼져 있으면 Pastiera가 Ctrl+키를 앱으로 전달합니다. 이 옵션은 앱 경로에만 영향을 줍니다. 켜면 Ctrl+문자가 활성 Pastiera 레이아웃을 거쳐 변환되므로 입력하는 문자에 따라 단축키를 사용할 수 있습니다. 끄면 앱이 원래 물리 키 위치를 받습니다.
+ 키 매핑
+ 기본값으로 되돌리기
+ 참고: Nav Mode 키를 변경하면 텍스트 입력란의 Ctrl+키 조합에도 영향을 줍니다.
+ ⚠
+ %1$s 구성
+ 유형
+ 키코드
+ 동작
+ 기본 Ctrl
+ 없음
+ 기본값 사용: %1$s
+ 저장
+ 취소
+ 기본값
+
+
+ %1$s에 사용할 문자 선택
+ 유니코드 문자 선택
+ 닫기
+ 구두점
+ 수학 기호
+ 통화
+ 기술 기호
+ 화살표
+ 기타
+ 변형
+ 스마일리 및 감정
+ 사람 및 신체
+ 동물 및 자연
+ 음식 및 음료
+ 여행 및 장소
+ 활동
+ 사물
+ 기호
+ 국기
+ 최근 사용
+ 이모티콘
+ 이모지를 불러올 수 없음
+ 검색
+ 이모지 검색…
+ 이모지를 찾을 수 없음
+
+
+ 변형 맞춤설정
+ Enter 키 동작
+ 앱별 Enter 및 줄바꿈 처리를 구성합니다
+ 앱별 Enter 동작
+ 선택한 메시지 앱에 Pastiera 재정의를 사용합니다. 테스트된 앱에는 Pastiera가 실제로 사용하는 방식이 표시됩니다.
+ 메시지 사전 설정
+ 설치된 알려진 메시지 앱에 일관된 기본값을 적용합니다.
+ 앱 기본값
+ Enter로 전송, Shift+Enter로 줄바꿈
+ Enter로 줄바꿈, Ctrl+Enter로 전송
+ Enter는 줄바꿈만
+ 맞춤설정
+ 앱 재정의
+ 앱 추가
+ 추가 앱에서는 먼저 대상 구성을 설정합니다. 엄선된 메시지 목록에는 테스트된 방식이 사용됩니다.
+ 앱 선택
+ 앱 삭제
+ 수동 재정의
+ 수동 입력란 숨기기
+ 엄선된 방식이 일반적으로 더 나은 기본값입니다. 앱 업데이트나 특수한 경우에만 재정의하세요.
+ 앱 기본값
+ Enter로 전송, Shift+Enter로 줄바꿈
+ Enter로 줄바꿈, Ctrl+Enter로 전송
+ Enter로 줄바꿈
+ Pastiera가 개입하지 않습니다.
+ 활성: %1$s.
+ 부분 활성: %1$s.
+ 실험적으로 활성: %1$s.
+ 아직 활성화되지 않음: %1$s은(는) 계획된 기능입니다.
+ 앱 기본값: Pastiera가 개입하지 않습니다.
+ 활성: %1$s.
+ 실험적으로 활성: %1$s.
+ 재정의 없음
+ 앱 동작
+ 일반 Enter
+ 구성되지 않음
+ Enter로 전송할지 줄바꿈을 입력할지는 앱이 결정합니다.
+ Pastiera가 줄바꿈을 직접 입력하고 앱의 전송 동작으로 전송합니다.
+ Pastiera가 앱의 전송 동작으로 전송합니다. Shift+Enter는 줄바꿈으로 직접 입력됩니다.
+ 테스트됨: Pastiera가 Enter를 줄바꿈으로 직접 입력하며 전송을 실행하지 않습니다.
+ 테스트됨: Discord는 이미 Enter로 전송하고 Shift+Enter로 줄바꿈하는 방식으로 올바르게 작동합니다. Pastiera가 개입하지 않습니다.
+ 테스트됨: 줄바꿈은 직접 입력됩니다. Discord의 앱 동작은 키보드만 숨기므로 Ctrl+Enter는 Ctrl 메타 없이 일반 Enter를 전송합니다.
+ 확인되지 않음: Pastiera가 앱의 전송 동작과 직접 줄바꿈 입력을 시도합니다.
+ 이 앱의 기술적 방식은 아직 테스트해야 합니다.
+ 원하는 동작
+ 전송 방식
+ 자동/테스트되지 않음
+ 편집기 동작
+ Ctrl+Enter
+ 일반 Enter
+ 추가 전송 단축키
+ 아래에 표시된 앱 목록에 추가 전송 단축키를 적용합니다.
+ 없음
+ SYM + Enter
+ SYM + Enter가 이미 QuickLauncher에 사용 중
+ SYM + Enter를 추가 전송 단축키로 사용하기 전에 QuickLauncher 트리거를 다른 키로 옮기세요.
+ 트리거 변경
+ 안내
+ 변형 상자를 탭하여 문자를 선택합니다. 각 문자에는 최대 7개의 변형 문자가 있을 수 있습니다.
+ %1$s의 변형 선택
+ 맞춤 문자
+ 추가
+ 지우기(비어 있음)
+ 기본값으로 초기화
+ 기본값으로 초기화
+ 모든 변형을 기본값으로 초기화할까요? 이 작업은 취소할 수 없습니다.
+ 초기화
+ 값
+ 슬롯을 삭제하려면 비워 두고 저장하세요.
+ 전체 변형 매핑
+ 모든 레이아웃에 하나의 레이아웃에서 변형 순서를 강제합니다. 기본값은 현재 IME 레이아웃(%1$s)입니다.
+ 없음(현재 레이아웃 사용)
+
+
+ 이모지
+ 문자
+
+
+ 앱 선택
+ 취소
+ 앱 검색…
+
+
+ Ko-fi에서 후원
+ Nav Mode 키 매핑 구성
+ 업데이트
+ GitHub에서 최신 릴리스를 확인합니다.
+ 업데이트 확인 중…
+ 앱이 최신 상태입니다.
+ GitHub에 연결할 수 없습니다.
+ 새 업데이트 사용 가능
+ GitHub에서 버전 %1$s을(를) 사용할 수 있습니다.
+ GitHub 열기
+ 나중에
+ APK 다운로드
+
+
+ Ricette Pastiera
+
+
+ 스마트 기능
+ 접근성
+ 자동 수정
+ 추가 맞춤설정
+ 소리 및 햅틱
+ 입력 소리, 진동 및 소리 팩 가져오기
+ 고급
+ 키보드
+ 입력
+ 스마트 기능
+ 인터페이스
+ 고급
+ 유틸리티
+ 정보
+ v%1$s
+ 상태 표시줄 변경 실시간 읽기
+ 사용 설정하면 TalkBack이 언어/레이아웃 및 상태 표시줄 변경을 자동으로 읽어 줍니다.
+ 두 번째 행 읽기(빠른 설정 및 변형)
+ 사용 중지하면 TalkBack이 아래 행을 건너뛰고 추천 단어를 별도로 유지합니다.
+ 추천 읽기 지연
+ %1$dms
+ 지연 시간을 50밀리초 줄이기
+ 지연 시간을 50밀리초 늘리기
+ 추천 지연: %1$dms
+ 반복 키 무시
+ 같은 물리 키를 실수로 반복해서 누른 입력을 무시합니다.
+ 같은 키의 최소 지연: %1$dms
+ 이 지연 시간보다 짧은 같은 키 입력은 무시됩니다.
+ 문자 키
+ 문자, 숫자 및 기호 키입니다.
+ 보조키
+ Shift, Ctrl, Alt 및 Sym입니다.
+ 텍스트 제어 키
+ 스페이스
+ 실수로 반복한 공백을 무시합니다.
+ Enter
+ 실수로 반복한 줄바꿈 또는 전송 동작을 무시합니다.
+ Backspace
+ 실수로 반복한 삭제를 무시합니다.
+ 겹치는 키 누르기 무시
+ 모든 물리 키보드에서 첫 번째 키를 놓을 때까지 두 번째 비보조키를 무시합니다. 보조키 단축키는 계속 작동합니다.
+
+
+ 언어
+ Android 입력 방법 선택기에 언어를 추가합니다. 사전이 있고 시스템 언어가 아닌 언어만 표시됩니다.
+ 사용 가능한 언어를 모두 추가했거나 시스템 언어입니다.
+
+
+ 레이아웃 저장
+ 레이아웃 가져오기
+ 레이아웃을 성공적으로 가져왔습니다
+ 레이아웃을 가져오지 못했습니다
+ 선택한 파일이 올바른 JSON이 아닙니다.
+ 레이아웃 파일에 mappings 객체가 없습니다.
+ mappings 값은 JSON 객체여야 합니다.
+ 레이아웃에 키 매핑이 없습니다.
+ 레이아웃에 지원되는 키 매핑이 없습니다.
+ 지원되는 키 매핑 중 하나 이상이 불완전하거나 올바르지 않습니다.
+ 레이아웃 이름이 비어 있거나 제어 문자를 포함합니다.
+ 이 이름의 레이아웃이 이미 존재합니다.
+ 검증된 레이아웃을 저장할 수 없습니다.
+ 잘못된 레이아웃 파일
+ 레이아웃 가져오기 오류: %1$s
+ 저장이 취소됨
+ 레이아웃을 성공적으로 저장했습니다
+ 레이아웃 저장 오류: %1$s
+ 가져온 레이아웃
+ 파일에서 가져옴
+ 파일에서 가져오기
+ 클라우드 저장소에서 다운로드
+ 클라우드 레이아웃
+ 새로고침
+ 클라우드 레이아웃을 찾을 수 없습니다.
+ 레이아웃 목록을 불러오지 못했습니다
+ 설치됨
+ 크기: %1$s
+ 다운로드
+ 다운로드 중…
+ %1$s / %2$s
+ %1$s 다운로드됨
+ 네트워크 오류
+ 잘못된 레이아웃 형식
+ 다운로드 검증 실패
+ 다운로드 실패
+ 삭제
+ 레이아웃 삭제
+ %1$s을(를) 삭제할까요? 이 작업은 취소할 수 없습니다.
+ %1$s 삭제됨
+ 레이아웃을 삭제하지 못했습니다
+ 레이아웃 삭제
+ 레이아웃 삭제
+ ‘%1$s’ 레이아웃을 삭제할까요? 이 작업은 취소할 수 없습니다.
+ 레이아웃을 성공적으로 삭제했습니다
+ 레이아웃을 삭제하지 못했습니다
+ 매핑 보기
+ 삭제
+ 순환에 사용
+ 활성 레이아웃으로 선택
+ 삭제
+
+
+ ALT
+ 동작:
+ 키코드:
+ 스캔 코드:
+ 유니코드:
+ 출력:
+ 해당 없음
+ 기록
+ 중지
+ 복사
+ 지우기
+ 보기
+ 공유
+ 디버그 보고서 공유
+ 디버그 보고서 복사됨(%1$d개 이벤트)
+ 레코더: %1$s · 이벤트: %2$d
+ 기록 중
+ 중지됨
+ 시작 시간:
+ 추천 포함
+ 원시 트랙패드 포함
+ 디버그 보고서 뷰어
+
+
+ 복사
+ 붙여넣기
+ 잘라내기
+ 실행 취소
+ 모두 선택
+ ←선택
+ 선택→
+ ←단어
+ 단어→
+ ←단어 선택
+ 단어 선택→
+ 문서 시작
+ 문서 끝
+ Pastierina
+ 재생/일시중지
+ 이전 미디어
+ 다음 미디어
+ ↑
+ ↓
+ ←
+ →
+ ○
+ Home
+ End
+ PgUp
+ PgDn
+ Esc
+ Tab
+ Del→
+ ?
+
+
+ 건너뛰기
+ 건너뛰기
+ 이전
+ 다음
+ 완료
+ 이번 버전의 새로운 기능: %1$s
+ 계속하기 전에 알아두면 좋은 변경 사항이 있습니다.
+ 버그 수정
+ Pastiera 문서 열기
+ 튜토리얼을 계속 진행하여 이번 릴리스에서 가장 유용한 설정을 확인하세요.
+ Pastiera에 오신 것을 환영합니다
+ Pastiera는 Nav Mode, 단축키, 맞춤 키보드 매핑 등을 지원하는 PKB Android 기기용 고급 입력 방법입니다.
+ 개발자 선택
+ 개발자 선택 설정이 적용되었습니다.
+ Pastiera 사용 설정
+ 시작하려면 Android 설정에서 Pastiera를 입력 방법으로 사용 설정해야 합니다. 아래 버튼을 눌러 설정을 여세요.
+ Pastiera 선택
+ Pastiera를 사용하려면 입력 방법 중에서 선택하세요. 다음 단계에서 가상 키보드 모드와 하드웨어 키보드 모드 중 Pastiera가 사용할 모드를 선택할 수 있습니다.
+ 키보드 모드
+ 여기서 Pastiera가 사용할 키보드 표면을 선택하세요.
+ 권장: 알려진 PKB 기기에서는 하드웨어 모드, 터치 기기에서는 가상 키보드 모드.
+ 터치 중심 사용을 위한 전체 화면 키보드입니다. 아직 알파 단계입니다.
+ 물리 키가 있는 기기용 간결한 화면입니다.
+ 알파 기능: 가상 키보드는 아직 개발 중이며 일상적인 사용에는 권장되지 않습니다.
+ IME 캡션 바 숨기기
+ Titan 2 기기에서는 Pastiera 아래에 추가 IME 캡션 바가 표시될 수 있습니다. 이 OEM 설정을 사용하면 캡션 바를 숨기고 Pastiera를 간결하게 유지할 수 있습니다.
+ 이전: 추가 캡션 바
+ 이후: 간결한 Pastiera
+ Android 16 이상
+ Unihertz Titan 2 또는 Titan 2 Elite
+ OEM 제스처 설정 사용 가능
+ ‘IME 캡션 바 숨기기’ 열기
+ 사용할 수 없을 수 있는 이유
+ 누락됨: %1$s.
+ Unihertz의 기기별 동작입니다. 이 단축키는 Android가 OEM 제스처 설정을 제공하는 지원되는 Titan 2 펌웨어에서만 작동합니다.
+ Titan 2 제스처 설정을 열 수 없습니다. 대신 Android 설정을 엽니다.
+ 자동 감지
+ 현재 자동 선택: %1$s
+ 현재 재정의: %1$s. 자동 선택값: %2$s
+ 감지된 프로필: %1$s / %2$s
+ 이유: 이 프로필은 물리 키보드 기기로 처리되므로 자동 모드가 하드웨어 키보드 모드를 사용합니다.
+ 이유: 이 프로필은 물리 키보드 기기로 인식되지 않으므로 자동 모드가 가상 키보드 모드를 사용합니다.
+ 보조키 표시
+ Pastiera를 최대한 활용하려면 Android 시스템 설정에서 Fn 키를 Ctrl로 사용하도록 설정하세요. Pastiera는 하단 스트립, 메뉴 바 또는 Pastiera 상태 표시줄에 보조키 상태를 표시할 수 있습니다. 한 번만 적용되는 활성 상태에는 활성 색상, 잠긴 상태에는 잠금 색상이 사용됩니다.
+ Nav Mode
+ Nav Mode는 문자 키를 탐색 및 편집 제어 키로 바꿉니다. Ctrl을 두 번 눌러 잠그거나 아래에서 Ctrl 누르기 탐색을 사용 설정하세요.
+ 선택 동작으로 왼쪽, 오른쪽 또는 단어 단위로 텍스트 선택 영역을 확장할 수 있습니다.
+ 단어 동작은 텍스트 입력란에서 더 빠르게 커서를 이동하도록 전체 단어 단위로 이동합니다.
+ 매핑은 앱 단축키, 미디어 제어, Escape, Tab, Page Up/Down 및 Pastierina도 처리할 수 있습니다.
+ 맞춤설정
+ Pastiera는 다양한 맞춤설정 옵션을 제공합니다:\n• 맞춤 키보드 레이아웃\n• 런처 단축키 및 Power 단축키\n• IME 바를 커서 이동용 스와이프 바로 사용\n• Nav Mode 맞춤 매핑\n설정을 살펴보고 나에게 맞는 구성을 찾아보세요!
+ QuickLauncher
+ 입력 흐름을 벗어나지 않고 키보드에서 앱을 엽니다.
+ SYM을 누른 채 스페이스를 눌러 어디서나 QuickLauncher를 엽니다.
+ 커서가 텍스트 입력란 안에 있을 때도 작동하므로 입력 중에 앱을 전환할 수 있습니다.
+ 몇 글자, 약어 또는 작은 오타를 입력하여 원하는 앱을 찾습니다.
+ 검색에서 QWERTZ 같은 현재 Pastiera 레이아웃을 사용할 수 있습니다.
+ QuickLauncher 설정 열기
+ SYM + Space가 다른 항목에 할당됨
+ 현재 스페이스가 ‘%1$s’을(를) 엽니다. 이 매핑을 QuickLauncher로 바꾸고 텍스트 입력란에서 사용 설정하여 새 기능을 사용해 보세요.
+ QuickLauncher에 SYM + Space 사용
+ 메신저 사전 설정
+ 앱마다 줄바꿈과 전송 방식이 다른 채팅 앱에서 Enter의 동작을 선택합니다.
+ 각 키를 수동으로 조정하는 대신 일반적인 메신저 앱에 테스트된 사전 설정을 사용합니다.
+ 선택적으로 SYM + Enter를 추가 전송 단축키로 사용합니다.
+ 한 앱의 동작이 다른 경우에도 앱별 재정의를 사용할 수 있습니다.
+ 메신저 사전 설정 열기
+ 입력 소리(재미 기능)
+ 소리 스타일을 선택하고 아래에서 입력하여 즉시 들어 보세요.
+ 여기에 입력해 보기
+ SYM/이모지
+ 기기별 문자는 Device SYM 사용
+ Pastiera는 이제 기기별 보조 문자 매핑을 Device SYM 레이어라고 부릅니다. Alt는 이를 실행할 수 있는 보조키로 유지되고, 이모지와 기호는 추가 SYM 레이어입니다.
+ 변형
+ 상태 표시줄
+ 시작할 준비가 되었습니다
+ 이제 Pastiera를 사용할 준비가 되었습니다! 언제든지 설정으로 돌아와 키보드를 필요에 맞게 더 맞춤설정할 수 있습니다.
+ Pastiera 사용 설정
+ Pastiera가 사용 설정되었습니다!
+ Pastiera 선택
+ 이전 페이지에서 먼저 Pastiera를 사용 설정하세요
+ Pastiera가 선택되었습니다!
+ 튜토리얼 보기
+ 소개 튜토리얼 다시 보기
+ 출시 정보 튜토리얼 보기
+ 업그레이드를 설치하지 않고 업데이트 소개 미리보기
+
+ Pastierina 모드
+ 간결한 UI 전환
+ 키보드 모드
+ Pastierina와 별개입니다. 하드웨어 키보드와 가상 키보드 동작을 제어합니다.
+ 자동(현재: %1$s)
+ 가상 키보드
+ 하드웨어
+ 가상 키보드
+ 하드웨어
+ 자동
+ 키보드 모드 전환
+ 화면 키보드 임시로 열기/닫기
+ 키보드 모드: 가상 키보드
+ 키보드 모드: 하드웨어
+ 모드 전환 토스트
+ 가상 키보드와 하드웨어 키보드 사이를 전환할 때 토스트를 표시합니다.
+ 가상 키보드 레이아웃
+ %1$s. 가상 키보드에서 사용할 키 격자를 제어합니다. 전체 레이아웃은 ANSI/ISO 레이아웃 계열의 추가 키를 표시합니다.
+ 간결
+ 확장 ISO
+ 전체 ANSI
+ 전체 ISO
+ 화면 키보드
+ 화면 키보드의 레이아웃, 터치 동작 및 모양입니다.
+ 화면 키보드 테마
+ 화면 키보드 페이지에서 테마 편집기를 엽니다.
+ 숫자 행
+ 가상 키보드 문자 위에 1~0을 표시합니다.
+ 가장 가까운 키 터치
+ 키 사이의 빈 공간을 터치하면 가장 가까운 것으로 예상되는 키에 입력됩니다.
+ 레이어 대체 팝업
+ 길게 누를 때 누른 키 아래에 구성된 Alt 및 기호/이모지 페이지 매핑을 표시합니다.
+ 키 아래에 레이어 팝업 배치
+ Alt/기호/이모지 팝업을 누른 키 위에 쌓지 않고 아래에 표시합니다.
+ 하단 행 보조키
+ 왼쪽 키
+ 오른쪽 키
+ Ctrl
+ Alt
+
+ Titan 2 레이아웃 정렬
+ 화면 키보드를 Titan 2 물리 키에 맞춥니다
+
+
+ 지금 백업
+ 모든 설정과 맞춤 레이아웃을 ZIP으로 내보냅니다
+ 파일에서 복원
+ Pastiera 백업 ZIP을 가져옵니다
+ 백업 완료
+ 복원 완료
+ 백업 실패: %1$s
+ 복원 실패: %1$s
+ 다른 기기에서 만든 백업
+ 이 백업은 %1$s에서 생성되었고 %2$s에서 가져오는 중입니다. 이 기기에서 감지된 키보드 프로필과 화면별 설정을 유지하도록 조정합니다.
+ 이 기기에 맞게 조정
+ 변경 없이 가져오기
+
+
+ DIP
+
+
+ 입력 언어
+ 키보드 및 기기
+ 자동 전환
+ 키보드 자동 전환
+ 하드웨어가 연결되거나 해제될 때 화면 키보드와 물리 키보드 사이를 전환합니다.
+ 현재 키보드
+ 화면 키보드 · 임시 재정의
+ 물리 · 임시 재정의
+ 화면 키보드 · 물리 키보드가 감지되지 않음
+ 화면 키보드 · 수동 선택됨
+ 물리 · Clicks Power Keyboard 연결됨
+ 물리 · 내장 키보드
+ 화면 키보드
+ 물리
+ 임시 재정의: %1$s
+ 단축키가 할당되지 않음
+ 화면 키보드를 임시로 열거나 닫습니다. 다음 하드웨어 연결 변경 시 구성된 자동 상태로 돌아갑니다.
+ 키보드 소스
+ 화면 키보드
+ 레이아웃
+ 터치 동작
+ 모양
+ 내장 키보드
+ 키보드 액세서리
+ 가상 키보드 · 활성
+ 가상 키보드 · 대기
+ 기기 전용 · 지원되는 기기가 감지되지 않음
+ 기기 전용 · 감지됨
+ Clicks Power Keyboard · 연결됨 · 슬롯 %1$s
+ Clicks Power Keyboard · 연결되지 않음
+ 맞춤 프로필을 이 휴대전화, 특정 입력 기기 또는 모든 외부 키보드에 연결할 수 있습니다. 엄선된 프로필은 템플릿으로 복제할 수 있습니다.
+ 시스템 로케일에 연결되지 않은 추가 언어와 키보드 레이아웃을 추가합니다
+ 앱 언어
+ 앱 인터페이스에 사용할 언어를 선택합니다.
+ 언어
+ 시스템 기본값
+ 시스템 기본값(%1$s)
+ 입력 스타일 추가
+ 아직 추가된 맞춤 입력 스타일이 없습니다. +를 눌러 추가하세요.
+ 삭제
+ 숨기기
+ 표시
+ 하나 이상의 입력 스타일을 표시해야 합니다
+ 입력 스타일 삭제
+ 이 입력 스타일을 삭제할까요?
+ 입력 스타일 추가
+ 입력 스타일 편집
+ 시스템 로케일 레이아웃 편집
+ 언어 선택
+ 키보드 레이아웃 선택
+ 저장
+ 취소
+ 이 언어와 레이아웃 조합은 이미 존재합니다
+ IME 선택기 열기
+ 새 언어를 사용 설정하려면 시스템 입력 방법 선택기를 엽니다
+ %1$s - %2$s
+ 맞춤 로케일 추가…
+ 맞춤 로케일 코드 입력
+ 로케일 코드
+ 로케일 코드를 입력하세요(예: en_US, it_IT, fr_FR)
+ 로케일 코드는 비워 둘 수 없습니다
+ 잘못된 로케일 코드 형식입니다. xx_XX 또는 xx 형식을 사용하세요
+ 이 로케일에 사용할 수 있는 사전이 없습니다. 추천과 자동 수정이 사용 중지됩니다.
+ 레이아웃 매핑 업데이트됨: %1$s - %2$s
+ 입력 스타일 업데이트됨: %1$s - %2$s
+ 입력 스타일 추가됨: %1$s - %2$s
+ 입력 스타일 삭제됨
+ 언어
+ %1$s(%2$s)
+ 시스템 로케일 - 변경할 수 없음
+ 시스템
+ qwerty
+ 탭하여 레이아웃 변경
+ en_US, it_IT, fr_FR 등
+ 설치된 사전
+ 추천 단어 사전
+ 기본 사전: %1$s
+ 사용 가능한 다른 설치된 사전이 없습니다.
+
+
+ 설치된 사전
+ 직렬화된 사전을 찾을 수 없습니다.
+ 언어: %1$s
+ 파일: %1$s
+ 사전 가져오기
+ %1$s 가져옴
+ 잘못된 파일 이름입니다. *_base.dict 형식이어야 합니다
+ 잘못된 사전 형식
+ 가져오기 실패
+ 가져옴
+ 설치됨
+ 온라인에서 사용 가능
+ 크기: %1$s
+ 새로고침
+ 다운로드
+ %1$s 다운로드됨
+ 네트워크 오류
+ 잘못된 사전 형식
+ 다운로드 검증 실패
+ 다운로드 실패
+ %1$s / %2$s
+ 사전 목록을 불러오지 못했습니다
+ 제거
+ 사전 제거
+ %1$s을(를) 제거할까요? 이 작업은 취소할 수 없습니다.
+ %1$s 제거됨
+ 사전 파일을 찾을 수 없습니다
+ 내장 사전은 제거할 수 없습니다
+ 사전을 제거하지 못했습니다
+
+
+ 클립보드 기록
+ 모두 지우기
+ 클립보드 기록 없음
+ 고정
+ 고정 해제
+ 삭제
+ 적용
+ 클립보드 보관 기간
+ 0 = 클립을 삭제하지 않음
+ %1$d분
+
+
+ 트랙패드 제스처로 추천 단어 입력
+ 트랙패드에서 위로 스와이프하여 추천 단어를 입력합니다
+ 트랙패드 제스처 사용
+ 트랙패드에서 위로 스와이프하여 추천 단어를 입력합니다
+ 제스처로 단어 추가
+ 추천 단어 입력 제스처로 사전에 단어 추가 버튼을 누를 수 있습니다.
+ 전체 너비 단어 추가 스와이프
+ 단어 추가 버튼만 표시될 때 트랙패드의 어느 영역에서나 위로 스와이프하여 단어를 추가할 수 있습니다.
+ 튜토리얼 보기
+ 트랙패드 제스처 사용 방법 알아보기
+ Shizuku 설치
+ 트랙패드 제스처 감지에 필요합니다
+ https://github.com/RikkaApps/Shizuku/releases/
+ Shizuku 연결됨
+ Shizuku 연결되지 않음
+ Shizuku에서 앱이 승인되지 않음
+ 트랙패드 입력 제공자
+ Shizuku(getevent)
+ 기본 IME 이벤트(실험적)
+ 기본 IME 이벤트 활성(실험적)
+ Shizuku 입력 기기
+ 자동(권장)
+ %1$s(현재 사용 불가)
+ 기기를 감지하지 못했습니다. 자동 대체가 계속 활성 상태입니다.
+ 트랙패드로 명확히 식별된 기기를 찾지 못했습니다. 기기를 수동으로 선택하거나 대체 기능을 유지하세요.
+ 트랙패드 진단
+ 실시간 좌표, 축 범위 및 감지된 영역을 확인합니다.
+ 실시간 좌표
+ Y축 표시
+ InputDevice.MotionRange
+ 디스플레이 대체값
+ %1$s = %2$.2f · 영역 %3$d
+ %1$s = %2$.2f
+ 트랙패드 스와이프 감도
+ 추천 단어를 입력하기 위해 위로 스와이프해야 하는 거리를 조정합니다.
+ 트랙패드 감도
+ 추천 단어 입력 및 삭제 제스처를 따로 조정합니다.
+ 추천 단어 입력 스와이프
+ 위로 스와이프하여 추천 단어를 입력하는 데 필요한 거리입니다.
+ 삭제 스와이프
+ 왼쪽으로 스와이프하여 이전 단어를 삭제하는 데 필요한 거리입니다.
+
+
+ 트랙패드 제스처로 추천 단어 입력
+ 트랙패드 제스처로 추천 단어 입력
+ 클릭하지 않고 트랙패드에서 위로 스와이프하여 추천 단어를 빠르게 입력하세요!
+ 작동 방식
+ 트랙패드는 세 영역으로 나뉩니다:\n\n• 왼쪽 영역 → 왼쪽 추천 단어 입력\n• 가운데 영역 → 가운데 추천 단어 입력\n• 오른쪽 영역 → 오른쪽 추천 단어 입력\n\n해당 영역에서 위로 스와이프하면 추천 단어가 입력됩니다.
+ Shizuku 필요
+ 이 기능은 트랙패드 이벤트에 액세스하기 위해 Shizuku가 필요합니다.\n\n1. GitHub 릴리스에서 Shizuku 설치\n2. Android 설정에서 개발자 옵션 사용\n3. ADB가 설치된 PC에 기기 연결\n4. Shizuku 앱에 표시되는 명령 실행\n5. Shizuku에서 Pastiera에 권한 부여\n\nShizuku가 실행되면 기능이 자동으로 작동합니다.
+ 팁
+ • 스와이프가 주로 수직 방향인지 확인하세요\n• 빠르고 의도적인 스와이프에서 가장 잘 작동합니다\n• 키보드에 다른 손가락이 있으면 제스처가 감지되지 않을 수 있습니다(하드웨어 손바닥 거부)\n• 고급 설정에서 이 기능을 사용하거나 사용 중지할 수 있습니다
+ 확인
+ 다음
+ 이전
+
+
+ 디버깅을 위해 작은 오버레이에 트랙패드 이벤트를 표시합니다
+
+
+ 상태 표시줄
+ 상태 표시줄과 Pastierina 모드를 구성합니다.
+ 상태 표시줄 스타일
+ 확장 상태 표시줄
+ 확장 기능
+ 버튼
+ 추천 단어
+ 기기별 인터페이스
+ Titan 2 Elite 둥근 모서리
+ Pastierina와 확장 상태 표시줄 제어 항목을 Elite의 둥근 화면 모서리 안에 유지합니다.
+ 왼쪽 슬롯
+ 오른쪽 슬롯 1
+ 오른쪽 슬롯 2
+ 왼쪽 버튼
+ 오른쪽 버튼
+ Pastierina 모드
+ 간결한 Pastierina 행의 왼쪽 및 오른쪽 가장자리에 표시할 버튼을 선택합니다.
+ Pastierina 왼쪽 버튼
+ Pastierina 오른쪽 버튼
+ 상태 표시줄 버튼 추가
+ 상태 표시줄 버튼 제거
+ 없음
+ 비어 있음
+ 클립보드
+ 클립보드 기록
+ 마이크
+ 음성 입력
+ 이모지
+ 이모지 선택기
+ 언어
+ 언어 전환
+ 알 수 없음
+ 언어 %1$s, 레이아웃 %2$s
+ 메뉴
+ 빠른 동작
+ Pastierina
+ 키보드 모드
+ 화면 키보드를 임시로 열거나 닫기(Ctrl+B)
+ 빠른 동작 닫기
+ 설정
+ 설정 열기
+ 기호
+ 기호 키보드
+ 실행 취소
+ Ctrl+Z 보내기
+ 다시 실행
+ Ctrl+Y 보내기
+ 변형 표시
+ 상태 표시줄 버튼 사이에 구성된 변형 행을 표시합니다. 끄면 버튼이 전체 상태 표시줄을 사용하지만 커서 스와이프 제어는 계속 작동합니다.
+ 상태 표시줄을 가로질러 스와이프하여 커서를 이동합니다.
+ 동적 변형 슬롯
+ 상태 표시줄 버튼 사이에 동적 항목 %1$d개를 표시하도록 합니다.
+ 동적 변형 크기 조정
+ 사용 설정하면 표시되는 항목 수에 따라 동적 항목이 사용 가능한 행을 채웁니다. 끄면 항목이 슬라이더의 슬롯 크기를 유지하고 왼쪽에 맞춰집니다.
+ 보조키 표시
+ 하드웨어 키보드의 Shift, Ctrl, Alt 및 SYM 상태를 표시할 위치를 선택합니다.
+ 하단 스트립 LED
+ Pastiera 아래 스트립에 색상으로 표시되는 보조키 LED입니다.
+ 메뉴 바
+ Android 상단 상태 표시줄의 간결한 아이콘 하나입니다.
+ 상태 표시줄
+ Pastiera 상태 표시줄 왼쪽 가장자리의 간결한 기호입니다.
+ 모든 보조키 표시가 꺼져 있습니다.
+ 켜짐
+ 꺼짐
+ 비어 있음
+ %1$d개 항목
+ %1$s, %2$s
+ 간결한 키보드 UI 전환
+ 초기화
+ 텍스트 확장
+ 스니펫과 입력한 이모지 또는 기호 단축 코드를 구성합니다.
+ 스니펫
+ 스니펫 사용
+ 공통 접두사 뒤에 전역 텍스트 스니펫을 확장합니다.
+ 스니펫 접두사
+ 출력 가능한 기호 하나입니다. 콜론은 이모지 및 기호 단축 코드용으로 예약되어 있습니다.
+ 콜론이 아닌 공백 없는 기호 하나를 선택하세요.
+ 스니펫 관리
+ 전역 단축어와 여러 줄 대체 텍스트를 추가합니다.
+ 스니펫 추가
+ 스니펫 편집
+ 스니펫 삭제
+ 아직 스니펫이 없습니다.
+ 단축어
+ 문자, 숫자 또는 밑줄을 1~40개 사용하세요.
+ 대체 텍스트
+ 저장
+ 일치 항목 표시 위치
+ 끔
+ 플로팅 팝업
+ 추천 단어 바
+ 스페이스로 정확히 일치하는 항목 확장
+ 스페이스를 눌러 정확히 일치하는 고유 단축어를 확장하고 뒤의 공백을 유지합니다. 접두사 일치는 무시됩니다.
+ 스페이스로 접두사 일치 적용
+ 입력한 단축어가 정확히 일치하지 않을 때 스페이스를 눌러 강조된 일치 항목을 적용합니다.
+ Tab으로 적용
+ Tab으로 강조된 항목 또는 고유하게 정확히 일치하는 항목을 적용합니다.
+ Enter로 적용
+ Enter로 강조된 항목 또는 고유하게 정확히 일치하는 항목을 적용합니다.
+ 이모지 및 기호
+ 이모지 단축 코드 사용
+ :name을 입력하여 유니코드 이모지를 검색합니다.
+ 기호 단축 코드 사용
+ :name을 입력하여 글꼴 및 수학 기호를 검색합니다.
+ :name: 확장
+ 닫는 콜론을 입력하면 고유하게 정확히 일치하는 항목을 확장합니다. 충돌하는 항목은 선택할 수 있습니다.
+
+
+ 설정 검색
+ 일치하는 설정 없음
+ 링크 복사
+ Markdown 복사
+ 공유
+ 설명 포함
+ 링크가 복사됨
+ 이 설정은 사용할 수 없습니다
+
+
+ 키보드, 기기, 하드웨어, 블루투스, 물리 키보드, Clicks, keyboard, devices
+ LED, 조명, 표시, Shift, Ctrl, Alt, SYM, 보조키
+ 키보드 레이아웃, 레이아웃, 키보드 배열, 배열, 입력 스타일, 언어, 언어별, keyboard layout, languages
+ 입력, 텍스트, 구두점, 따옴표, 띄어쓰기, 대문자, typing, punctuation
+ 수정, 자동 수정, 철자, 추천, 제안, 대체, 사전, autocorrect, suggestions
+ 테마, 색상, 어두운, 밝은, 사전 설정, 하드웨어 키보드, 화면 키보드, 가상 키보드, 소프트웨어 키보드, 화상 키보드, Pastiera Dark, Pastiera Light, Cloud Tap, Moon Tap, Classic Cloud, Classic Midnight
+ 언어, 앱 언어, 로케일, 번역, language, locale
+ 맞춤설정, 모양, 사용자 지정, customization, appearance
+ 고급, 백업, 복원, 디버그, 전문가, backup, restore
+ 접근성, 지연, 반복 키, 겹치는 키, 스크린 리더, accessibility
+ 정보, 버전, 지원, Ko-fi, 후원, about, donate
+ 간격, 공백, 구두점, spacing, punctuation
+ 하이픈, 대시, en dash, 구두점, hyphen
+ 따옴표, 아포스트로피, 곡선 따옴표, 문자 스타일, quotation marks
+ Shift, Backspace, 단어 삭제, delete word
+ Alt, Backspace, 단어 삭제, delete word
+ Backspace, 줄 삭제, 텍스트 시작, delete line
+ 화상 키보드, 화면 키보드, 소프트웨어 키보드, 가상 키보드, on-screen keyboard
+ 내장 키보드, 레이아웃, 배열, 하드웨어, 프로필, built-in keyboards
+ 액세서리, Clicks, 커버, accessories
+ 키보드 레이아웃, 키보드 배열, 입력 언어, 레이아웃, 배열, 사전, 언어별, dictionaries
+ 추천, 추천 단어, 제안, 후보, 예측, suggestions, candidates
+ 악센트, 발음 구별 기호, 일치, accents
+ 지연, 추천, 제안, 접근성, 시간 초과, delay
+ 길게 누르기, 지연, 타이밍, 임계값, long press
+ 스와이프, 선택, 임계값, 커서, swipe
+ 길게 누르기, 보조키, 한 번 적용, modifier
+ 길게 누르기, 지연, 임계값, 보조키, threshold
+ LED, 조명, 표시, 보조키 상태, indicators
+ 레이아웃 모드, 배열 모드, 앱별, 전환, layout mode
+ 지원, 후원, Ko-fi, 커피, donate
+ LED, 조명, 표시, 화면 키보드, 가상 키보드, 소프트웨어 키보드, 화상 키보드, on-screen keyboard
+ 간격, 여백, 키, spacing, gaps
+ 격자, 키 정렬, 직교 배열, 레이아웃, grid, layout
+ 팝업, 말풍선, 키, popup
+ 꼬리, 연결선, 팝업, connector
+ 미리보기, 누르기, 길게 누르기, preview
+ 문자 선택기, 악센트, 대체 문자, 기호, character picker
+ LED, 비활성, 활성, 잠김, 보조키, 색상, indicators
+ LED, 조명, 스트립, 하단, bottom
+ LED, 조명, 아이콘, 상태 표시줄, 표시, status bar
+ LED, 조명, 기호, 표시, Pastiera, status bar
+
+
+ 키보드를 위한 키보드
+ 분
+ IME 테스트 화면
+ 모든 입력 필드 유형과 IME 동작을 테스트합니다
+ 출처: %1$s
+ 레이아웃: %1$s
+
+ 텍스트 입력 유형
+ 숫자 입력 유형
+ 기타 입력 유형
+ IME 동작
+ text (일반 텍스트)
+ textCapCharacters (모두 대문자)
+ textCapWords (단어 첫 글자 대문자)
+ textCapSentences (문장 첫 글자 대문자)
+ textEmailAddress (이메일 주소)
+ textPassword (비밀번호)
+ textVisiblePassword (표시되는 비밀번호)
+ textWebPassword (웹 비밀번호)
+ textUri (URI)
+ textPersonName (이름)
+ textPostalAddress (주소)
+ textMultiLine (여러 줄)
+ textNoSuggestions (추천 단어 없음)
+ textAutoComplete (자동 완성)
+ textAutoCorrect (자동 수정)
+ number (숫자)
+ numberSigned (부호 포함)
+ numberDecimal (소수)
+ phone (전화번호)
+ datetime (날짜 및 시간)
+ date (날짜)
+ time (시간)
+ actionNone (동작 없음)
+ actionGo (이동)
+ actionSearch (검색)
+ actionSend (전송)
+ actionNext (다음)
+ actionDone (완료)
+ actionPrevious (이전)
+ actionUnspecified (기본값)
+
+ QuickLauncher 항목
+ Pastiera 검색에 표시할 소스를 선택합니다.
+ 항목 맞춤설정
+ 즐겨찾기, 숨긴 항목 및 검색 별칭
+ 항목 검색
+ 모두
+ 즐겨찾기
+ 선택한 소스에 사용할 수 있는 명령이 없습니다.
+ "%1$s"과(와) 일치하는 항목이 없습니다.
+ 검색 별칭
+ 즐겨찾기에 추가
+ 즐겨찾기에서 삭제
+ 위로 이동
+ 아래로 이동
+ 숨기기
+ 동적
+ 항목 색상 자동 지정
+ 항목 색상
+ 항목 모양
+ 목록에서 즐겨찾기 강조
+ 앱 아이콘 색상으로 항목 색상 지정
+ 상위 일치 항목에 고정 강조 색상 사용
+ 항목 이름 앞에 검색 별칭 표시
+
+ 앱
+ Pastiera 동작
+ 앱 동작
+ 기기 제어
+ 탐색 동작
+ Pastiera QuickLauncher
+ Pastiera 검색 열기
+ Pastiera
+ 앱 설정 열기
+ 키보드 모드 전환
+ 가상 키보드/하드웨어 키보드 전환
+ 시스템
+ 미디어
+ 오디오
+ 화면
+ 설정
+ 시스템 패널
+ 홈 화면
+ 재생/일시중지
+ 이전 트랙
+ 다음 트랙
+ 볼륨 높이기
+ 볼륨 낮추기
+ 음소거
+ 밝기 높이기
+ 밝기 낮추기
+ 설정
+ 앱
+ 기본 앱
+ 키보드 설정
+ 접근성
+ 언어 및 입력
+ 블루투스
+ Wi-Fi
+ 인터넷
+ 화면/밝기
+ 소리 및 진동
+ NFC
+ 배터리
+ 알림
+ Pastiera 알림
+ Niagara 검색
+ Niagara 일정
+ Tasker 작업 선택
+ Tasker 환경설정
+ Tasker 바로가기 만들기
+ Home Assistant 탐색
+ Home Assistant Assist
+ Home Assistant 음성 명령
+ 탐색
+ 동작
+ 위
+ 아래
+ 왼쪽
+ 오른쪽
+ Tab
+ 처음
+ 끝
+ 페이지 위
+ 페이지 아래
+ Esc
+ 가운데
+ 앞쪽 삭제
+ 복사
+ 붙여넣기
+ 잘라내기
+ 실행 취소
+ 모두 선택
+ 왼쪽 선택
+ 오른쪽 선택
+ 왼쪽 단어
+ 오른쪽 단어
+ 왼쪽 단어 선택
+ 오른쪽 단어 선택
+ 페이지 처음
+ 페이지 끝
+ Pastierina
+ 재생/일시중지
+ 이전 미디어
+ 다음 미디어
+
+ 미리보기 최대 영역
+ 테마를 삭제하시겠습니까?
+ ‘%1$s’을(를) 삭제하시겠습니까? 현재 적용된 색상은 유지됩니다.
+ 테마 내보내기
+ 16진수 색상
+ 사전 설정
+ 명령
+ 슬롯 추가
+ 맨 오른쪽 슬롯 삭제
+
+ 수집된 이벤트: %1$d
+ 트랙패드 이벤트를 기다리는 중…\n트랙패드에서 스와이프하면 이벤트가 여기에 표시됩니다.
+ 누름
+ 뗌
+ 이동
+ 진입
+ 이탈
+ 스크롤
+ 알 수 없음(%1$s)
+ 위치: X=%1$.2f Y=%2$.2f
+ 누름 상태: %1$b
+ 압력: %1$.3f
+ 시간: %1$d
+ 스크롤 변화량: %1$s
+ 트랙패드 디버그(%1$d개 이벤트)\n\n
+
+ 패키지를 사용할 수 없습니다
+ 앱을 열 수 없습니다
+ 명령을 사용할 수 없습니다
+ 명령이 차단되었습니다
+ 명령에 실패했습니다
+ QuickLauncher를 열 수 없습니다
+ Pastiera를 열 수 없습니다
+ 알 수 없는 동작입니다
+ 홈 화면으로 이동할 수 없습니다
+ 오디오를 사용할 수 없습니다
+ Shizuku가 필요합니다
+ 탐색 모드를 사용할 수 없습니다
+ 입력 컨텍스트가 없습니다
+ 탐색 동작에 실패했습니다
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index eabeda0f9..bbc7ee715 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -11,6 +11,7 @@
Español
Português
Русский
+ 한국어
Српски
Українська
@@ -1588,6 +1589,7 @@ Each key can be configured as Keycode, Action, Native Ctrl, or None. Use overrid
Emoji picker
Language
Switch language
+ Unknown
Language %1$s, layout %2$s
Menu
Quick actions
@@ -1718,6 +1720,195 @@ Each key can be configured as Keycode, Action, Native Ctrl, or None. Use overrid
LED, LEDs, lights, strip, bottom
LED, lights, icon, status bar, indicators
LED, lights, symbols, indicators, pastiera
+
+
+ The Keyboard for Your Keyboard
+ min
+ IME Test Screen
+ Test all input field types and IME actions
+ Origin: %1$s
+ Layout: %1$s
+
+ Text Input Types
+ Numeric Input Types
+ Other Input Types
+ IME Actions
+ text (Plain Text)
+ textCapCharacters (All Caps)
+ textCapWords (Title Case)
+ textCapSentences (Sentence Case)
+ textEmailAddress (Email Address)
+ textPassword (Password)
+ textVisiblePassword (Visible Password)
+ textWebPassword (Web Password)
+ textUri (URI)
+ textPersonName (Person Name)
+ textPostalAddress (Postal Address)
+ textMultiLine (Multiple Lines)
+ textNoSuggestions (No Suggestions)
+ textAutoComplete (Auto-complete)
+ textAutoCorrect (Auto-correct)
+ number
+ numberSigned (Signed)
+ numberDecimal (Decimal)
+ phone (Phone)
+ datetime (Date and time)
+ date (Date)
+ time (Time)
+ actionNone
+ actionGo
+ actionSearch
+ actionSend
+ actionNext
+ actionDone
+ actionPrevious
+ actionUnspecified (Default)
+
+ QuickLauncher entries
+ Choose which sources appear in Pastiera search.
+ Customize entries
+ Favorites, hidden entries, and search aliases
+ Search entries
+ All
+ Favorites
+ No commands available for the selected sources.
+ No entries match "%1$s".
+ Search alias
+ Favorite
+ Unfavorite
+ Move up
+ Move down
+ Hide
+ Dynamic
+ Dynamic entry color
+ Entry color
+ Entry appearance
+ Highlight favorites in list
+ Tint entries from app icon colors
+ Use static top-match highlight color
+ Show search alias before entry name
+
+ Apps
+ Pastiera actions
+ App actions
+ Device control
+ Navigation actions
+ Pastiera QuickLauncher
+ Open Pastiera search
+ Pastiera
+ Open app settings
+ Toggle Keyboard Mode
+ Switch Virtual / Hardware
+ System
+ Media
+ Audio
+ Display
+ Settings
+ System panel
+ Home screen
+ Play / pause
+ Previous track
+ Next track
+ Volume up
+ Volume down
+ Mute volume
+ Brightness up
+ Brightness down
+ Settings
+ Apps
+ Default apps
+ Keyboard settings
+ Accessibility
+ Language & input
+ Bluetooth
+ Wi-Fi
+ Internet
+ Display / brightness
+ Sound & vibration
+ NFC
+ Battery
+ Notifications
+ Pastiera notifications
+ Niagara Search
+ Niagara Agenda
+ Tasker Select Task
+ Tasker Preferences
+ Tasker Create Shortcut
+ Home Assistant Navigate
+ Home Assistant Assist
+ Home Assistant Voice Command
+ Navigation
+ Action
+ Up
+ Down
+ Left
+ Right
+ Tab
+ Home
+ End
+ Page up
+ Page down
+ Escape
+ Center
+ Forward delete
+ Copy
+ Paste
+ Cut
+ Undo
+ Select all
+ Select left
+ Select right
+ Word left
+ Word right
+ Select word left
+ Select word right
+ Page start
+ Page end
+ Pastierina
+ Play/Pause
+ Media previous
+ Media next
+
+ Preview max viewport
+ Delete theme?
+ Delete ‘%1$s’? The currently applied colors will be kept.
+ Export theme
+ Hex color
+ Presets
+ Command
+ Add slot
+ Remove rightmost slot
+
+ Events captured: %1$d
+ Waiting for trackpad events...\nSwipe on the trackpad to see events here.
+ Press
+ Release
+ Move
+ Enter
+ Exit
+ Scroll
+ Unknown (%1$s)
+ Position: X=%1$.2f Y=%2$.2f
+ Pressed: %1$b
+ Pressure: %1$.3f
+ Time: %1$d
+ Scroll delta: %1$s
+ Trackpad Debug (%1$d events)\n\n
+
+ Package not available
+ Could not open app
+ Command not available
+ Command blocked
+ Command failed
+ Could not open QuickLauncher
+ Could not open Pastiera
+ Unknown action
+ Could not go home
+ Audio unavailable
+ Shizuku required
+ Nav mode unavailable
+ No input context
+ Nav action failed
Upper button corners
Round only the upper corner facing the display edge. Corners facing inward and inner buttons stay unchanged.
Maximum icon reduction: %1$d%%
diff --git a/app/src/test/java/it/palsoftware/pastiera/LocalizationAuditRegressionTest.kt b/app/src/test/java/it/palsoftware/pastiera/LocalizationAuditRegressionTest.kt
new file mode 100644
index 000000000..8d61d1c29
--- /dev/null
+++ b/app/src/test/java/it/palsoftware/pastiera/LocalizationAuditRegressionTest.kt
@@ -0,0 +1,101 @@
+package it.palsoftware.pastiera
+
+import android.content.Context
+import android.content.res.Configuration
+import java.io.File
+import java.util.Locale
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33])
+class LocalizationAuditRegressionTest {
+ private fun localizedContext(tag: String): Context {
+ val app = RuntimeEnvironment.getApplication()
+ val configuration = Configuration(app.resources.configuration)
+ configuration.setLocale(Locale.forLanguageTag(tag))
+ return app.createConfigurationContext(configuration)
+ }
+ private fun dictionaryLocales(context: Context): List<*> {
+ val method = Class.forName("it.palsoftware.pastiera.CustomInputStylesScreenKt")
+ .getDeclaredMethod("getLocalesWithDictionary", Context::class.java)
+ method.isAccessible = true
+ return method.invoke(null, context) as List<*>
+ }
+
+ @Test fun dictionaryCandidatesRequireInstalledDataButAcceptImportedKorean() {
+ val context = localizedContext("ko")
+ val file = File(context.filesDir,"dictionaries_serialized/custom/ko_base.dict")
+ file.delete()
+ assertFalse(dictionaryLocales(context).any { it.toString().startsWith("ko") })
+ assertTrue(dictionaryLocales(context).contains("en"))
+ try {
+ file.parentFile!!.mkdirs()
+ // Inventory checks presence only; dictionary decoding has its own tests.
+ file.writeBytes(byteArrayOf(1))
+ assertTrue(dictionaryLocales(context).contains("ko"))
+ } finally { file.delete() }
+ }
+
+ @Test fun inputLanguagePickerUsesBaseKoreanCodeWithOrWithoutImportedDictionary() {
+ val context = localizedContext("en")
+ val dictionaries = dictionaryLocales(context).map { it.toString() }.filterNot { it == "ko" }
+ for (installed in listOf(dictionaries, dictionaries + "ko")) {
+ val locales = getAvailableInputLocales(installed)
+ assertEquals(listOf("ko"), locales.filter { it.startsWith("ko") })
+ }
+ }
+
+ @Test fun dictionaryWarningsRecognizeBaseAndBothRegionalLocaleFormats() {
+ val context = localizedContext("ko")
+ val method = Class.forName("it.palsoftware.pastiera.CustomInputStylesScreenKt")
+ .getDeclaredMethod("hasDictionaryForLocale", Context::class.java, String::class.java)
+ .apply { isAccessible = true }
+ val file = File(context.filesDir, "dictionaries_serialized/custom/ko_base.dict")
+ try {
+ file.parentFile!!.mkdirs()
+ file.writeBytes(byteArrayOf(1))
+ for (tag in listOf("ko", "ko_KR", "ko-KR", "en", "en_US", "en-US")) {
+ assertEquals(tag, true, method.invoke(null, context, tag))
+ }
+ } finally {
+ file.delete()
+ }
+ }
+
+ @Test fun editingActiveRegionalLayoutNotifiesImeAcrossLocaleSeparators() {
+ val app = RuntimeEnvironment.getApplication()
+ val imm = org.mockito.Mockito.mock(android.view.inputmethod.InputMethodManager::class.java)
+ val subtype = android.view.inputmethod.InputMethodSubtype.InputMethodSubtypeBuilder()
+ .setLanguageTag("ko-KR").setSubtypeMode("keyboard").build()
+ org.mockito.Mockito.`when`(imm.currentInputMethodSubtype).thenReturn(subtype)
+ val context = object : android.content.ContextWrapper(app) {
+ override fun getSystemService(name: String): Any? =
+ if (name == Context.INPUT_METHOD_SERVICE) imm else super.getSystemService(name)
+ }
+ val update = Class.forName("it.palsoftware.pastiera.CustomInputStylesScreenKt")
+ .getDeclaredMethod("updateLocaleLayoutMapping", Context::class.java, String::class.java, String::class.java)
+ .apply { isAccessible = true }
+ val prefs = SettingsManager.getPreferences(context)
+ val key = SettingsManager.KEY_KEYBOARD_LAYOUT_AUTO_MAPPING_UPDATED
+ try {
+ SettingsManager.setKeyboardLayoutAutoByLocale(context, true)
+ prefs.edit().remove(key).commit()
+ update.invoke(null, context, "ko_KR", "qwerty")
+ assertTrue("Changing the active regional mapping must refresh the IME", prefs.contains(key))
+ prefs.edit().remove(key).commit()
+ update.invoke(null, context, "en_US", "azerty")
+ assertFalse("An unrelated language must not refresh the active IME", prefs.contains(key))
+ SettingsManager.setKeyboardLayoutAutoByLocale(context, false)
+ update.invoke(null, context, "ko_KR", "korean_2set")
+ assertFalse("Manual layout selection must not be overridden", prefs.contains(key))
+ } finally {
+ File(context.filesDir, "locale_layout_mapping.json").delete()
+ prefs.edit().clear().commit()
+ }
+ }
+}
diff --git a/app/src/test/java/it/palsoftware/pastiera/clipboard/ClipboardHistoryPrivacyTest.kt b/app/src/test/java/it/palsoftware/pastiera/clipboard/ClipboardHistoryPrivacyTest.kt
index 4b1d4c257..d14d424b9 100644
--- a/app/src/test/java/it/palsoftware/pastiera/clipboard/ClipboardHistoryPrivacyTest.kt
+++ b/app/src/test/java/it/palsoftware/pastiera/clipboard/ClipboardHistoryPrivacyTest.kt
@@ -15,6 +15,9 @@ import android.widget.FrameLayout
import android.widget.TextView
import it.palsoftware.pastiera.R
import it.palsoftware.pastiera.SettingsManager
+import it.palsoftware.pastiera.core.composition.NormalizedCompositionStroke
+import it.palsoftware.pastiera.inputmethod.composition.CompositionFinishReason
+import it.palsoftware.pastiera.inputmethod.composition.ImeCompositionCoordinator
import it.palsoftware.pastiera.inputmethod.ui.ClipboardHistoryView
import org.junit.After
import org.junit.Assert.assertEquals
@@ -118,6 +121,33 @@ class ClipboardHistoryPrivacyTest {
assertEquals("protected-secret", historyManager.getHistoryEntry(0)?.text)
}
+ @Test
+ fun pastePreservesCompositionWhileLockedAndFinishesItBeforeUnlockedCommit() {
+ val inputConnection = BaseInputConnection(View(context), true)
+ val coordinator = ImeCompositionCoordinator().apply {
+ startEditorSession()
+ selectLayout("korean_2set")
+ }
+ val manager = ClipboardHistoryManager(context, accessPolicy) { connection ->
+ coordinator.finish(connection, CompositionFinishReason.COMMAND)
+ }
+ "ㅎㅏㄴ".forEach {
+ assertTrue(coordinator.handleMappedStroke(inputConnection, NormalizedCompositionStroke(it)))
+ }
+ assertEquals("한", inputConnection.editable.toString())
+
+ accessPolicy.accessible = false
+ manager.pasteText("paste", inputConnection)
+ assertTrue(coordinator.isActive)
+ assertEquals("한", inputConnection.editable.toString())
+
+ accessPolicy.accessible = true
+ manager.pasteText("paste", inputConnection)
+ assertFalse(coordinator.isActive)
+ assertEquals("한paste", inputConnection.editable.toString())
+ assertEquals(-1, BaseInputConnection.getComposingSpanStart(requireNotNull(inputConnection.editable)))
+ }
+
@Test
fun accessListenersReceiveOnlyRealStateTransitions() {
val states = mutableListOf()
diff --git a/app/src/test/java/it/palsoftware/pastiera/core/composition/HangulComposerTest.kt b/app/src/test/java/it/palsoftware/pastiera/core/composition/HangulComposerTest.kt
new file mode 100644
index 000000000..b55a3c097
--- /dev/null
+++ b/app/src/test/java/it/palsoftware/pastiera/core/composition/HangulComposerTest.kt
@@ -0,0 +1,74 @@
+package it.palsoftware.pastiera.core.composition
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class HangulComposerTest {
+ private fun compose(text: String): HangulComposer {
+ val composer = HangulComposer()
+ text.forEach { composer.accept(NormalizedCompositionStroke(it)) }
+ return composer
+ }
+
+ private fun rendered(text: String): String = compose(text).snapshot?.renderedText.orEmpty()
+
+ @Test fun basicWords() {
+ assertEquals("ㄱ", rendered("ㄱ"))
+ assertEquals("가", rendered("ㄱㅏ"))
+ assertEquals("간", rendered("ㄱㅏㄴ"))
+ assertEquals("한글", rendered("ㅎㅏㄴㄱㅡㄹ"))
+ assertEquals("안녕하세요", rendered("ㅇㅏㄴㄴㅕㅇㅎㅏㅅㅔㅇㅛ"))
+ assertEquals("과", rendered("ㄱㅗㅏ"))
+ assertEquals("의", rendered("ㅇㅡㅣ"))
+ assertEquals("값", rendered("ㄱㅏㅂㅅ"))
+ assertEquals("갑사", rendered("ㄱㅏㅂㅅㅏ"))
+ assertEquals("가나", rendered("ㄱㅏㄴㅏ"))
+ }
+
+ @Test fun noImplicitIeungOrDoubleConsonant() {
+ assertEquals("ㅏ", rendered("ㅏ"))
+ assertEquals("ㅏㅓ", rendered("ㅏㅓ"))
+ assertEquals("ㄱㄱ", rendered("ㄱㄱ"))
+ assertEquals("가ㄸ", rendered("ㄱㅏㄸ"))
+ }
+
+ @Test fun backspaceReplaysPhysicalStrokes() {
+ val composer = compose("ㄱㅏㅂㅅ")
+ val expected = listOf("갑", "가", "ㄱ", "")
+ expected.forEach { value ->
+ assertTrue(composer.isActive)
+ assertEquals(value, (composer.backspace() as ComposerUpdate.Replace).snapshot.renderedText)
+ }
+ assertEquals(ComposerUpdate.NotHandled, composer.backspace())
+ }
+
+ @Test fun splitFinalAndMoveToNextSyllable() {
+ assertEquals("고", rendered("ㄱㅗㅏ".dropLast(1)))
+ assertEquals("한그", rendered("ㅎㅏㄴㄱㅡ"))
+ assertEquals("달가", rendered("ㄷㅏㄹㄱㅏ"))
+ assertEquals("값", rendered("ㄱㅏㅂㅅ"))
+ }
+
+ @Test fun shiftedJamoAreSingleStrokes() {
+ val composer = compose("ㄲㅏ")
+ assertEquals("까", composer.snapshot?.renderedText)
+ assertEquals("ㄲ", (composer.backspace() as ComposerUpdate.Replace).snapshot.renderedText)
+ }
+
+ @Test fun unsupportedInputIsNotHandled() {
+ val composer = HangulComposer()
+ assertEquals(ComposerUpdate.NotHandled, composer.accept(NormalizedCompositionStroke('a')))
+ assertEquals(0, composer.strokeCount)
+ }
+
+ @Test fun boundedRunSignalsInternalSplitWithoutDroppingState() {
+ val composer = HangulComposer(maxStrokes = 2)
+ composer.accept(NormalizedCompositionStroke('ㄱ'))
+ composer.accept(NormalizedCompositionStroke('ㅏ'))
+ assertTrue(composer.needsInternalSplit)
+ assertEquals(ComposerUpdate.NotHandled, composer.accept(NormalizedCompositionStroke('ㄴ')))
+ composer.beginNewRun()
+ assertEquals("ㄴ", (composer.accept(NormalizedCompositionStroke('ㄴ')) as ComposerUpdate.Replace).snapshot.renderedText)
+ }
+}
diff --git a/app/src/test/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodServiceDeviceBehaviorTest.kt b/app/src/test/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodServiceDeviceBehaviorTest.kt
index e86c1ba0f..f4ae7457d 100644
--- a/app/src/test/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodServiceDeviceBehaviorTest.kt
+++ b/app/src/test/java/it/palsoftware/pastiera/inputmethod/PhysicalKeyboardInputMethodServiceDeviceBehaviorTest.kt
@@ -438,6 +438,71 @@ class PhysicalKeyboardInputMethodServiceDeviceBehaviorTest {
assertTrue(modifierController().shiftOneShot)
}
+ @Test
+ fun switchingFromAutoCapitalizedLatinFieldToKorean_clearsStaleShift() {
+ service.onStartInput(editorInfo, false)
+ assertTrue(modifierController().shiftOneShot)
+
+ invokePrivate(service, "switchToLayout", "korean_2set", false)
+
+ assertFalse(modifierController().shiftOneShot)
+ service.onKeyDown(
+ KeyEvent.KEYCODE_R,
+ keyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_R, 2_950L, 2_950L)
+ )
+ service.onKeyDown(
+ KeyEvent.KEYCODE_K,
+ keyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_K, 3_000L, 3_000L)
+ )
+ assertEquals("가", recorder.composingTexts.last())
+ }
+
+ @Test
+ fun manualShiftPressedAfterSwitchingToKorean_producesTenseConsonant() {
+ invokePrivate(service, "switchToLayout", "korean_2set", false)
+ tapShift(3_050L)
+
+ service.onKeyDown(
+ KeyEvent.KEYCODE_R,
+ keyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_R, 3_100L, 3_100L)
+ )
+ service.onKeyDown(
+ KeyEvent.KEYCODE_K,
+ keyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_K, 3_150L, 3_150L)
+ )
+
+ assertEquals("까", recorder.composingTexts.last())
+ }
+
+ @Test
+ fun koreanComposition_doesNotSurviveRestartOfCurrentField() {
+ invokePrivate(service, "switchToLayout", "korean_2set", false)
+ service.onKeyDown(
+ KeyEvent.KEYCODE_R,
+ keyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_R, 3_200L, 3_200L)
+ )
+ service.onKeyDown(
+ KeyEvent.KEYCODE_K,
+ keyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_K, 3_250L, 3_250L)
+ )
+ assertEquals("가", recorder.composingTexts.last())
+
+ // Messaging apps can restart the same editor after sending and clearing its text.
+ recorder.composingTexts.clear()
+ service.onStartInput(editorInfo, true)
+
+ service.onKeyDown(
+ KeyEvent.KEYCODE_S,
+ keyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_S, 3_300L, 3_300L)
+ )
+ service.onKeyDown(
+ KeyEvent.KEYCODE_K,
+ keyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_K, 3_350L, 3_350L)
+ )
+
+ assertEquals("나", recorder.composingTexts.last())
+ }
+
@Test
fun autoCap_manualShiftOff_survivesRestartOfCurrentField() {
val context = RuntimeEnvironment.getApplication()
@@ -1086,11 +1151,28 @@ class PhysicalKeyboardInputMethodServiceDeviceBehaviorTest {
error("Field not found: $fieldName")
}
+ private fun invokePrivate(target: Any, methodName: String, vararg args: Any?) {
+ var cls: Class<*>? = target.javaClass
+ while (cls != null) {
+ val method = cls.declaredMethods.firstOrNull { candidate ->
+ candidate.name == methodName && candidate.parameterCount == args.size
+ }
+ if (method != null) {
+ method.isAccessible = true
+ method.invoke(target, *args)
+ return
+ }
+ cls = cls.superclass
+ }
+ error("Method not found: $methodName")
+ }
+
private class RecordingInputConnection {
var textBeforeCursor: String = ""
var sendKeyEventResult: Boolean = true
var performEditorActionResult: Boolean = false
val committedTexts = mutableListOf()
+ val composingTexts = mutableListOf()
val sentKeyEvents = mutableListOf()
val editorActions = mutableListOf()
val contextMenuActions = mutableListOf()
@@ -1110,6 +1192,11 @@ class PhysicalKeyboardInputMethodServiceDeviceBehaviorTest {
}
true
}
+ "setComposingText" -> {
+ val text = args?.getOrNull(0)?.toString()
+ if (text != null) composingTexts += text
+ true
+ }
"deleteSurroundingText" -> {
val before = (args?.getOrNull(0) as? Int) ?: 0
val after = (args?.getOrNull(1) as? Int) ?: 0
diff --git a/app/src/test/java/it/palsoftware/pastiera/inputmethod/composition/HangulAuditRegressionTest.kt b/app/src/test/java/it/palsoftware/pastiera/inputmethod/composition/HangulAuditRegressionTest.kt
new file mode 100644
index 000000000..61b092e23
--- /dev/null
+++ b/app/src/test/java/it/palsoftware/pastiera/inputmethod/composition/HangulAuditRegressionTest.kt
@@ -0,0 +1,240 @@
+package it.palsoftware.pastiera.inputmethod.composition
+
+import android.text.InputType
+import android.view.View
+import android.view.KeyEvent
+import android.view.inputmethod.BaseInputConnection
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputConnection
+import it.palsoftware.pastiera.core.composition.NormalizedCompositionStroke
+import it.palsoftware.pastiera.core.InputContextState
+import it.palsoftware.pastiera.SettingsManager
+import it.palsoftware.pastiera.inputmethod.PhysicalKeyboardInputMethodService
+import it.palsoftware.pastiera.inputmethod.CandidatesBarController
+import it.palsoftware.pastiera.inputmethod.VariationButtonHandler
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33])
+class HangulAuditRegressionTest {
+ private fun ic() = BaseInputConnection(View(RuntimeEnvironment.getApplication()), true)
+ private fun coordinator() = ImeCompositionCoordinator().apply {
+ startEditorSession()
+ selectLayout("korean_2set")
+ }
+ private fun type(c: ImeCompositionCoordinator, ic: InputConnection, text: String) {
+ text.forEach { assertTrue(c.handleMappedStroke(ic, NormalizedCompositionStroke(it))) }
+ }
+
+ @Test fun deletingLastStrokeMustRemoveEditorText() {
+ val ic = ic(); val c = coordinator()
+ type(c, ic, "ㄱㅏ")
+ assertEquals("가", ic.editable.toString())
+ assertTrue(c.handleBackspace(ic))
+ assertEquals("ㄱ", ic.editable.toString())
+ assertTrue(c.handleBackspace(ic))
+ assertEquals("", ic.editable.toString())
+ }
+
+ @Test fun maxLengthSplitMustPreserveLastSyllable() {
+ val ic = ic(); val c = coordinator()
+ type(c, ic, "ㄱㅏ".repeat(63) + "ㄱㅗㅏ")
+ assertEquals("가".repeat(63) + "과", ic.editable.toString())
+ }
+
+ @Test fun coalescedSelectionMustRemainOwned() {
+ val ic = ic(); val c = coordinator()
+ type(c, ic, "ㄱㅏㄴㅏ")
+ assertEquals("가나", ic.editable.toString())
+ assertEquals(SelectionUpdateDisposition.OWN_COMPOSING_UPDATE,
+ c.onUpdateSelection(0, 0, 2, 2, 0, 2))
+ }
+
+ @Test fun missingComposingRangeMustInvalidateOwnership() {
+ val ic = ic(); val c = coordinator()
+ type(c, ic, "ㄱㅏ")
+ c.onUpdateSelection(0,0,1,1,0,1)
+ c.onUpdateSelection(1,1,1,1,0,1)
+ // The editor commits the composing range without changing the cursor.
+ ic.finishComposingText()
+ assertEquals(SelectionUpdateDisposition.EXTERNAL_CHANGE,
+ c.onUpdateSelection(1,1,1,1,-1,-1))
+ }
+
+ @Test fun composingFailureInNextEditorMustNotSwallowStroke() {
+ val c = coordinator()
+ type(c, ic(), "ㄱ")
+ c.startEditorSession(); c.selectLayout("korean_2set")
+ val rejecting = object : BaseInputConnection(View(RuntimeEnvironment.getApplication()), true) {
+ override fun setComposingText(text: CharSequence?, newCursorPosition: Int) = false
+ }
+ val handled = c.handleMappedStroke(rejecting, NormalizedCompositionStroke('ㄴ'))
+ assertTrue("Handled input must either reach the editor or return false for fallback",
+ !handled || rejecting.editable.toString() == "ㄴ")
+ }
+
+ @Test fun staticVariationMustNotReplaceHangulRun() {
+ val context = RuntimeEnvironment.getApplication()
+ val service = Robolectric.buildService(PhysicalKeyboardInputMethodService::class.java).create().get()
+ val ic = ic()
+ val info = EditorInfo().apply { inputType = InputType.TYPE_CLASS_TEXT; packageName = "test.audit" }
+ setField(service,"mInputConnection",ic)
+ setField(service,"mStartedInputConnection",ic)
+ setField(service,"mInputEditorInfo",info)
+ setField(service,"isInputViewActive",true)
+ setField(service,"inputContextState",InputContextState.fromEditorInfo(info))
+ service.javaClass.getDeclaredMethod("switchToLayout",String::class.java,Boolean::class.javaPrimitiveType).apply { isAccessible=true }.invoke(service,"korean_2set",false)
+ val c = getField(service,"compositionCoordinator") as ImeCompositionCoordinator
+ type(c,ic,"ㅎㅏㄴㄱㅡㄹ")
+ val bars = getField(service,"candidatesBarController") as CandidatesBarController
+ VariationButtonHandler.createStaticVariationClickListener("(",ic,context,bars.onVariationSelectedListener).onClick(View(context))
+ assertEquals("한글(", ic.editable.toString())
+ }
+
+ @Test fun numericFieldMustKeepNumberMapping() {
+ val (service, ic) = serviceWithEditor(InputType.TYPE_CLASS_NUMBER)
+ service.addAltKeyMapping(KeyEvent.KEYCODE_R, "1")
+ assertTrue(service.onKeyDown(KeyEvent.KEYCODE_R, KeyEvent(1000,1000,KeyEvent.ACTION_DOWN,KeyEvent.KEYCODE_R,0)))
+ assertEquals("1", ic.editable.toString())
+ }
+
+ @Test fun symPageMustKeepSymbolMapping() {
+ val (service, ic) = serviceWithEditor(InputType.TYPE_CLASS_TEXT)
+ val sym = getField(service,"symLayoutController") as it.palsoftware.pastiera.core.SymLayoutController
+ sym.openSymbolsPage()
+ val entry = sym.currentSymMappings()!!.entries.first { it.key in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z }
+ assertTrue(service.onKeyDown(entry.key, KeyEvent(1000,1000,KeyEvent.ACTION_DOWN,entry.key,0)))
+ assertEquals(entry.value, ic.editable.toString())
+ }
+
+ @Test fun latinNumericAndSymControlsStillWork() {
+ val (service, ic) = serviceWithEditor(InputType.TYPE_CLASS_NUMBER)
+ service.javaClass.getDeclaredMethod("switchToLayout",String::class.java,Boolean::class.javaPrimitiveType).apply { isAccessible=true }.invoke(service,"qwerty",false)
+ service.addAltKeyMapping(KeyEvent.KEYCODE_R, "1")
+ service.onKeyDown(KeyEvent.KEYCODE_R, KeyEvent(1000,1000,KeyEvent.ACTION_DOWN,KeyEvent.KEYCODE_R,0))
+ assertEquals("1", ic.editable.toString())
+ val info = EditorInfo().apply { inputType = InputType.TYPE_CLASS_TEXT; packageName = "test.audit" }
+ setField(service,"mInputEditorInfo",info)
+ setField(service,"inputContextState",InputContextState.fromEditorInfo(info))
+ val sym = getField(service,"symLayoutController") as it.palsoftware.pastiera.core.SymLayoutController
+ sym.openSymbolsPage()
+ val entry = sym.currentSymMappings()!!.entries.first { it.key in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z }
+ service.onKeyDown(entry.key, KeyEvent(2000,2000,KeyEvent.ACTION_DOWN,entry.key,0))
+ assertEquals("1" + entry.value, ic.editable.toString())
+ }
+
+ @Test fun softwareSymbolFooterPreservesHangulBeforeEveryInsertion() {
+ for (text in listOf(",", ".", " ")) {
+ val (service, ic) = serviceWithEditor(InputType.TYPE_CLASS_TEXT)
+ val c = getField(service,"compositionCoordinator") as ImeCompositionCoordinator
+ type(c,ic,"ㅎㅏㄴㄱㅡㄹ")
+ val bars = getField(service,"candidatesBarController") as CandidatesBarController
+ val status = getField(bars,"inputStatusBar")!!
+ status.javaClass.getDeclaredMethod("commitSoftwareSymbolText", InputConnection::class.java, String::class.java)
+ .apply { isAccessible = true }.invoke(status,ic,text)
+ assertEquals("한글$text",ic.editable.toString())
+ assertFalse(c.isActive)
+ }
+ }
+
+ @Test fun softwareDeleteAndPhysicalDeleteRemoveFinalJamo() {
+ val (service, ic) = serviceWithEditor(InputType.TYPE_CLASS_TEXT)
+ val c = getField(service,"compositionCoordinator") as ImeCompositionCoordinator
+ type(c,ic,"ㄱ")
+ assertTrue(service.onKeyDown(KeyEvent.KEYCODE_DEL,KeyEvent(KeyEvent.ACTION_DOWN,KeyEvent.KEYCODE_DEL)))
+ assertEquals("",ic.editable.toString())
+ type(c,ic,"ㄴ")
+ val bars = getField(service,"candidatesBarController") as CandidatesBarController
+ assertTrue(bars.onSoftwareKeyboardBackspace!!.invoke(ic))
+ assertEquals("",ic.editable.toString())
+ }
+
+ @Test fun phoneFieldKeepsNumericMapping() {
+ val (service,ic) = serviceWithEditor(InputType.TYPE_CLASS_PHONE)
+ service.addAltKeyMapping(KeyEvent.KEYCODE_R,"1")
+ assertTrue(service.onKeyDown(KeyEvent.KEYCODE_R,KeyEvent(KeyEvent.ACTION_DOWN,KeyEvent.KEYCODE_R)))
+ assertEquals("1",ic.editable.toString())
+ }
+
+ @Test fun activeSymMappingCommitsExistingHangulBeforeSymbol() {
+ val (service,ic) = serviceWithEditor(InputType.TYPE_CLASS_TEXT)
+ val c = getField(service,"compositionCoordinator") as ImeCompositionCoordinator
+ type(c,ic,"ㅎㅏㄴㄱㅡㄹ")
+ val sym = getField(service,"symLayoutController") as it.palsoftware.pastiera.core.SymLayoutController
+ sym.openSymbolsPage()
+ val entry = sym.currentSymMappings()!!.entries.first { it.key in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z }
+ assertTrue(service.onKeyDown(entry.key,KeyEvent(KeyEvent.ACTION_DOWN,entry.key)))
+ assertEquals("한글"+entry.value,ic.editable.toString())
+ }
+
+ @Test fun clipboardAndMultiCharacterStaticVariationPreserveRun() {
+ val (service,ic) = serviceWithEditor(InputType.TYPE_CLASS_TEXT)
+ val c = getField(service,"compositionCoordinator") as ImeCompositionCoordinator
+ type(c,ic,"ㅎㅏㄴㄱㅡㄹ")
+ val clipboard = getField(service,"clipboardHistoryManager") as it.palsoftware.pastiera.clipboard.ClipboardHistoryManager
+ clipboard.pasteText(" pasted",ic)
+ assertEquals("한글 pasted",ic.editable.toString())
+ type(c,ic,"ㄱㅏ")
+ val bars = getField(service,"candidatesBarController") as CandidatesBarController
+ VariationButtonHandler.createStaticVariationClickListener("🙂",ic,RuntimeEnvironment.getApplication(),bars.onVariationSelectedListener).onClick(View(RuntimeEnvironment.getApplication()))
+ assertEquals("한글 pasted가🙂",ic.editable.toString())
+ }
+
+ @Test fun allModernSyllablesComposeFromTwoSetStrokes() {
+ val initials = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ"
+ val medials = listOf("ㅏ","ㅐ","ㅑ","ㅒ","ㅓ","ㅔ","ㅕ","ㅖ","ㅗ","ㅗㅏ","ㅗㅐ","ㅗㅣ","ㅛ","ㅜ","ㅜㅓ","ㅜㅔ","ㅜㅣ","ㅠ","ㅡ","ㅡㅣ","ㅣ")
+ val finals = listOf("","ㄱ","ㄲ","ㄱㅅ","ㄴ","ㄴㅈ","ㄴㅎ","ㄷ","ㄹ","ㄹㄱ","ㄹㅁ","ㄹㅂ","ㄹㅅ","ㄹㅌ","ㄹㅍ","ㄹㅎ","ㅁ","ㅂ","ㅂㅅ","ㅅ","ㅆ","ㅇ","ㅈ","ㅊ","ㅋ","ㅌ","ㅍ","ㅎ")
+ var cases = 0
+ initials.forEachIndexed { l, initial -> medials.forEachIndexed { v, medial -> finals.forEachIndexed { t, final ->
+ val composer = it.palsoftware.pastiera.core.composition.HangulComposer()
+ val strokes = initial.toString()+medial+final
+ strokes.forEach { composer.accept(NormalizedCompositionStroke(it)) }
+ assertEquals((0xAC00 + (l*21+v)*28+t).toChar().toString(), composer.snapshot?.renderedText)
+ cases++
+ for (end in strokes.length-1 downTo 0) {
+ val actual = (composer.backspace() as it.palsoftware.pastiera.core.composition.ComposerUpdate.Replace).snapshot.renderedText
+ val prefix = it.palsoftware.pastiera.core.composition.HangulComposer()
+ strokes.take(end).forEach { prefix.accept(NormalizedCompositionStroke(it)) }
+ assertEquals(prefix.snapshot?.renderedText.orEmpty(),actual)
+ cases++
+ }
+ } } }
+ assertEquals(52402,cases)
+ }
+
+ private fun serviceWithEditor(inputTypeValue: Int): Pair {
+ val service = Robolectric.buildService(PhysicalKeyboardInputMethodService::class.java).create().get()
+ val ic = ic()
+ val info = EditorInfo().apply { inputType = inputTypeValue; packageName = "test.audit" }
+ setField(service,"mInputConnection",ic)
+ setField(service,"mStartedInputConnection",ic)
+ setField(service,"mInputEditorInfo",info)
+ setField(service,"isInputViewActive",true)
+ setField(service,"inputContextState",InputContextState.fromEditorInfo(info))
+ service.javaClass.getDeclaredMethod("switchToLayout",String::class.java,Boolean::class.javaPrimitiveType).apply { isAccessible=true }.invoke(service,"korean_2set",false)
+ return service to ic
+ }
+
+ private fun getField(target: Any, name: String): Any? {
+ var k: Class<*>? = target.javaClass
+ while (k != null) {
+ try { return k.getDeclaredField(name).apply { isAccessible=true }.get(target) }
+ catch (_: NoSuchFieldException) { k=k.superclass }
+ }
+ error(name)
+ }
+ private fun setField(target: Any, name: String, value: Any) {
+ var k: Class<*>? = target.javaClass
+ while (k != null) {
+ try { k.getDeclaredField(name).apply { isAccessible=true }.set(target,value); return }
+ catch (_: NoSuchFieldException) { k=k.superclass }
+ }
+ error(name)
+ }
+}
diff --git a/app/src/test/java/it/palsoftware/pastiera/inputmethod/composition/ImeCompositionCoordinatorTest.kt b/app/src/test/java/it/palsoftware/pastiera/inputmethod/composition/ImeCompositionCoordinatorTest.kt
new file mode 100644
index 000000000..15f0bfac6
--- /dev/null
+++ b/app/src/test/java/it/palsoftware/pastiera/inputmethod/composition/ImeCompositionCoordinatorTest.kt
@@ -0,0 +1,92 @@
+package it.palsoftware.pastiera.inputmethod.composition
+
+import android.view.inputmethod.InputConnection
+import it.palsoftware.pastiera.core.composition.CompositionInputSource
+import it.palsoftware.pastiera.core.composition.NormalizedCompositionStroke
+import java.lang.reflect.Proxy
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ImeCompositionCoordinatorTest {
+
+ @Test
+ fun composingAfterCommittedPrefix_usesRelativeSelectionExpectations() {
+ val coordinator = ImeCompositionCoordinator()
+ val inputConnection = successfulInputConnection()
+ coordinator.startEditorSession()
+ coordinator.selectLayout("korean_2set")
+
+ assertTrue(coordinator.handleMappedStroke(inputConnection, stroke('ㄱ')))
+ assertEquals(
+ SelectionUpdateDisposition.OWN_COMPOSING_UPDATE,
+ coordinator.onUpdateSelection(4, 4, 5, 5, 4, 5)
+ )
+ assertTrue(coordinator.handleMappedStroke(inputConnection, stroke('ㅏ')))
+ assertEquals(
+ SelectionUpdateDisposition.OWN_COMPOSING_UPDATE,
+ coordinator.onUpdateSelection(5, 5, 5, 5, 4, 5)
+ )
+ assertEquals("가", coordinator.snapshot?.renderedText)
+
+ coordinator.finish(inputConnection, CompositionFinishReason.BOUNDARY)
+ assertEquals(
+ SelectionUpdateDisposition.NOT_TRACKED,
+ coordinator.onUpdateSelection(5, 5, 6, 6, -1, -1)
+ )
+
+ assertTrue(coordinator.handleMappedStroke(inputConnection, stroke('ㅌ')))
+ assertEquals(
+ SelectionUpdateDisposition.OWN_COMPOSING_UPDATE,
+ coordinator.onUpdateSelection(6, 6, 7, 7, 6, 7)
+ )
+ assertTrue(coordinator.handleMappedStroke(inputConnection, stroke('ㅔ')))
+ assertEquals(
+ SelectionUpdateDisposition.OWN_COMPOSING_UPDATE,
+ coordinator.onUpdateSelection(7, 7, 7, 7, 6, 7)
+ )
+ assertTrue(coordinator.isActive)
+ assertEquals("테", coordinator.snapshot?.renderedText)
+ }
+
+ @Test
+ fun activeComposition_stillRejectsUnrelatedCursorMovement() {
+ val coordinator = ImeCompositionCoordinator()
+ val inputConnection = successfulInputConnection()
+ coordinator.startEditorSession()
+ coordinator.selectLayout("korean_2set")
+
+ assertTrue(coordinator.handleMappedStroke(inputConnection, stroke('ㄱ')))
+
+ assertEquals(
+ SelectionUpdateDisposition.EXTERNAL_CHANGE,
+ coordinator.onUpdateSelection(4, 4, 2, 2, -1, -1)
+ )
+ }
+
+ private fun stroke(jamo: Char) = NormalizedCompositionStroke(
+ jamo = jamo,
+ physicalKeyCode = null,
+ source = CompositionInputSource.PHYSICAL,
+ shiftedByUser = false
+ )
+
+ private fun successfulInputConnection(): InputConnection {
+ return Proxy.newProxyInstance(
+ InputConnection::class.java.classLoader,
+ arrayOf(InputConnection::class.java)
+ ) { _, method, _ ->
+ when (method.name) {
+ "beginBatchEdit", "endBatchEdit", "setComposingText", "finishComposingText" -> true
+ else -> defaultValue(method.returnType)
+ }
+ } as InputConnection
+ }
+
+ private fun defaultValue(type: Class<*>): Any? = when {
+ type == Boolean::class.javaPrimitiveType -> false
+ type == Int::class.javaPrimitiveType -> 0
+ type == Long::class.javaPrimitiveType -> 0L
+ else -> null
+ }
+}
diff --git a/app/src/test/java/it/palsoftware/pastiera/inputmethod/composition/ImeCompositionEditorRegressionTest.kt b/app/src/test/java/it/palsoftware/pastiera/inputmethod/composition/ImeCompositionEditorRegressionTest.kt
new file mode 100644
index 000000000..5775abc53
--- /dev/null
+++ b/app/src/test/java/it/palsoftware/pastiera/inputmethod/composition/ImeCompositionEditorRegressionTest.kt
@@ -0,0 +1,188 @@
+package it.palsoftware.pastiera.inputmethod.composition
+
+import android.text.Selection
+import android.view.View
+import android.view.inputmethod.BaseInputConnection
+import android.view.inputmethod.ExtractedText
+import android.view.inputmethod.ExtractedTextRequest
+import it.palsoftware.pastiera.core.composition.NormalizedCompositionStroke
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33])
+class ImeCompositionEditorRegressionTest {
+ private open class Editor : BaseInputConnection(View(RuntimeEnvironment.getApplication()), true) {
+ var rejectComposing = false
+ var rejectCommit = false
+ var throwComposing = false
+ override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean {
+ if (throwComposing) throw IllegalStateException("Editor disconnected")
+ return !rejectComposing && super.setComposingText(text, newCursorPosition)
+ }
+ override fun commitText(text: CharSequence?, newCursorPosition: Int) =
+ !rejectCommit && super.commitText(text, newCursorPosition)
+ override fun getExtractedText(request: ExtractedTextRequest?, flags: Int) = ExtractedText().apply {
+ text = editable.toString()
+ startOffset = 0
+ selectionStart = Selection.getSelectionStart(editable)
+ selectionEnd = Selection.getSelectionEnd(editable)
+ }
+ }
+ private fun coordinator() = ImeCompositionCoordinator().apply {
+ startEditorSession()
+ selectLayout("korean_2set")
+ }
+ private fun type(c: ImeCompositionCoordinator, e: BaseInputConnection, text: String) {
+ text.forEach { assertTrue(c.handleMappedStroke(e, NormalizedCompositionStroke(it))) }
+ }
+ private fun acknowledge(c: ImeCompositionCoordinator, e: BaseInputConnection, old: Int): SelectionUpdateDisposition {
+ val cursor = Selection.getSelectionStart(e.editable)
+ return c.onUpdateSelection(old, old, cursor, cursor,
+ BaseInputConnection.getComposingSpanStart(e.editable!!),
+ BaseInputConnection.getComposingSpanEnd(e.editable!!))
+ }
+
+ @Test fun finalDeletePreservesCommittedPrefixAndEndsOwnership() {
+ val e = Editor(); val c = coordinator()
+ e.commitText("prefix ", 1)
+ type(c, e, "ㄱㅏㅂㅅ")
+ listOf("갑", "가", "ㄱ", "").forEach {
+ assertTrue(c.handleBackspace(e))
+ assertEquals("prefix $it", e.editable.toString())
+ }
+ assertFalse(c.isActive)
+ assertEquals(-1, BaseInputConnection.getComposingSpanStart(e.editable!!))
+ assertEquals(7, Selection.getSelectionStart(e.editable))
+ assertFalse(c.handleBackspace(e))
+ }
+
+ @Test fun replacingSelectionAndCoalescingUpdatesKeepsNewSyllableOwned() {
+ val e = Editor(); val c = coordinator()
+ e.commitText("prefix replace tail", 1)
+ e.setSelection(7, 14)
+ type(c, e, "ㄱㅏㄴㅏ")
+ assertEquals(SelectionUpdateDisposition.OWN_COMPOSING_UPDATE, c.onUpdateSelection(7,14,9,9,7,9))
+ type(c, e, "ㄴ")
+ assertEquals("prefix 가난 tail", e.editable.toString())
+ }
+
+ @Test fun lostRangeWithAndWithoutPendingWritesCannotReplayStaleRun() {
+ for (pending in listOf(false, true)) {
+ val e = Editor(); val c = coordinator()
+ type(c, e, "ㄱ")
+ assertEquals(SelectionUpdateDisposition.OWN_COMPOSING_UPDATE, acknowledge(c,e,0))
+ type(c, e, "ㅏ")
+ if (!pending) acknowledge(c,e,1)
+ e.finishComposingText()
+ assertEquals(SelectionUpdateDisposition.EXTERNAL_CHANGE, acknowledge(c,e,1))
+ assertFalse(c.isActive)
+ type(c, e, "ㄴ")
+ assertEquals("가ㄴ", e.editable.toString())
+ assertTrue(c.handleBackspace(e))
+ assertEquals("가", e.editable.toString())
+ }
+ }
+
+ @Test fun unexpectedRangeAtSameCursorReleasesOwnership() {
+ val e = Editor(); val c = coordinator()
+ type(c,e,"ㄱㅏㄴㅏ")
+ acknowledge(c,e,0)
+ e.setComposingRegion(1,2)
+ assertEquals(SelectionUpdateDisposition.EXTERNAL_CHANGE, acknowledge(c,e,2))
+ assertFalse(c.isActive)
+ }
+
+ @Test fun editorWithoutCandidateRangesCanAcknowledgeAbsoluteSelection() {
+ val e = Editor(); val c = coordinator()
+ type(c,e,"ㄱㅏㄴㅏ")
+ assertEquals(SelectionUpdateDisposition.OWN_COMPOSING_UPDATE,c.onUpdateSelection(0,0,2,2,-1,-1))
+ type(c,e,"ㄴ")
+ assertEquals("가난",e.editable.toString())
+ }
+
+ @Test fun rejectedComposingFallsBackOnEveryStrokeAndResetsInNewEditor() {
+ val e = Editor().apply { rejectComposing = true }; val c = coordinator()
+ var consumedShift = 0
+ "ㄱㅏㄴ".forEach { assertTrue(c.handleMappedStroke(e,NormalizedCompositionStroke(it)) { consumedShift++ }) }
+ assertEquals("ㄱㅏㄴ",e.editable.toString())
+ assertEquals(3,consumedShift)
+ assertFalse(c.isActive)
+ c.startEditorSession(); c.selectLayout("korean_2set")
+ val next = Editor()
+ type(c,next,"ㄱㅏ")
+ assertEquals("가",next.editable.toString())
+ assertTrue(c.isActive)
+ }
+
+ @Test fun rejectedAndThrowingWritesDoNotConsumeKeyOrShiftWhenCommitAlsoFails() {
+ for (throws in listOf(false,true)) {
+ val e = Editor().apply { rejectComposing = true; rejectCommit = true; throwComposing = throws }
+ val c = coordinator()
+ var shiftConsumed = false
+ assertFalse(c.handleMappedStroke(e,NormalizedCompositionStroke('ㄱ')) { shiftConsumed = true })
+ assertFalse(shiftConsumed)
+ assertFalse(c.isActive)
+ assertEquals("",e.editable.toString())
+ }
+ }
+
+ @Test fun failedReplacementCommitsOnlyNewStrokeAndKeepsPreviousText() {
+ val e = Editor(); val c = coordinator()
+ type(c,e,"ㄱㅏ")
+ e.rejectComposing = true
+ type(c,e,"ㄴ")
+ assertEquals("가ㄴ",e.editable.toString())
+ assertFalse(c.isActive)
+ }
+
+ @Test fun failedDeleteLeavesTextAndAllowsCallerFallback() {
+ val e = Editor(); val c = coordinator()
+ type(c,e,"ㄱ")
+ e.rejectComposing = true
+ assertFalse(c.handleBackspace(e))
+ assertFalse(c.isActive)
+ assertEquals("ㄱ",e.editable.toString())
+ }
+
+ @Test fun splitBoundariesPreserveCompoundVowelsFinalsAndCarryWithDeletion() {
+ val tails = listOf("ㄱㅗㅏ", "ㄱㅏㅂㅅ", "ㄱㅏㅂㅅㅏ", "ㄱㅗㅏㄴ", "ㄱㅏㄴㅏ")
+ for (padding in 123..128) for (tail in tails) {
+ val e = Editor(); val c = coordinator()
+ val prefix = "ㅏ".repeat(padding)
+ type(c,e,prefix+tail)
+ val reference = it.palsoftware.pastiera.core.composition.HangulComposer(maxStrokes = 1000)
+ (prefix+tail).forEach { reference.accept(NormalizedCompositionStroke(it)) }
+ assertEquals("padding=$padding tail=$tail", reference.snapshot?.renderedText,e.editable.toString())
+ repeat(tail.length) {
+ assertTrue(c.handleBackspace(e))
+ reference.backspace()
+ assertEquals(reference.snapshot?.renderedText,e.editable.toString())
+ }
+ }
+ }
+
+ @Test fun delayedAcknowledgedCallbackDoesNotInterruptNewerComposition() {
+ val e = Editor(); val c = coordinator()
+ type(c,e,"ㄱ")
+ acknowledge(c,e,0)
+ type(c,e,"ㅏㄴㅏ")
+ acknowledge(c,e,1)
+ assertEquals(SelectionUpdateDisposition.OWN_COMPOSING_UPDATE,
+ c.onUpdateSelection(0,0,1,1,0,1))
+ type(c,e,"ㄴ")
+ assertEquals("가난",e.editable.toString())
+ }
+
+ @Test fun newEditorIgnoresOldCallbacksUntilItOwnsText() {
+ val e = Editor(); val c = coordinator()
+ type(c,e,"ㄱㅏ")
+ c.startEditorSession(); c.selectLayout("korean_2set")
+ assertEquals(SelectionUpdateDisposition.NOT_TRACKED,c.onUpdateSelection(0,0,1,1,0,1))
+ assertFalse(c.isActive)
+ }
+}
diff --git a/app/src/test/java/it/palsoftware/pastiera/inputmethod/subtype/AdditionalSubtypeUtilsLayoutTest.kt b/app/src/test/java/it/palsoftware/pastiera/inputmethod/subtype/AdditionalSubtypeUtilsLayoutTest.kt
index 1ff2d9be9..b118b1006 100644
--- a/app/src/test/java/it/palsoftware/pastiera/inputmethod/subtype/AdditionalSubtypeUtilsLayoutTest.kt
+++ b/app/src/test/java/it/palsoftware/pastiera/inputmethod/subtype/AdditionalSubtypeUtilsLayoutTest.kt
@@ -1,8 +1,11 @@
package it.palsoftware.pastiera.inputmethod.subtype
+import android.os.LocaleList
import android.view.KeyEvent
+import it.palsoftware.pastiera.SettingsManager
import it.palsoftware.pastiera.data.layout.JsonLayoutLoader
import it.palsoftware.pastiera.data.layout.LayoutFileStore
+import it.palsoftware.pastiera.inputmethod.subtype.AdditionalSubtypeUtils.localeString
import java.util.Locale
import org.junit.After
import org.junit.Assert.assertEquals
@@ -21,7 +24,12 @@ class AdditionalSubtypeUtilsLayoutTest {
@After
fun tearDown() {
- LayoutFileStore.getLayoutsDirectory(RuntimeEnvironment.getApplication()).deleteRecursively()
+ val context = RuntimeEnvironment.getApplication()
+ LayoutFileStore.getLayoutsDirectory(context).deleteRecursively()
+ SettingsManager.getPreferences(context).edit().clear().commit()
+ val configuration = context.resources.configuration
+ configuration.setLocales(LocaleList(Locale.US))
+ context.resources.updateConfiguration(configuration, context.resources.displayMetrics)
ShadowLog.clear()
}
@@ -61,6 +69,95 @@ class AdditionalSubtypeUtilsLayoutTest {
assertEquals("vietnamese_telex_qwerty", AdditionalSubtypeUtils.getKeyboardLayoutFromSubtype(subtypes[0]))
}
+ @Test
+ fun regionalMapping_acceptsBothSeparatorsBeforeLanguageFallback() {
+ val context = RuntimeEnvironment.getApplication()
+ val mapping = java.io.File(context.filesDir, "locale_layout_mapping.json")
+ try {
+ for (key in listOf("ko_KR", "ko-KR")) {
+ mapping.writeText("""{"$key":"qwerty","ko":"korean_2set"}""")
+ for (locale in listOf("ko_KR", "ko-KR")) {
+ assertEquals("qwerty", AdditionalSubtypeUtils.getLayoutForLocale(context.assets, locale, context))
+ }
+ assertEquals("korean_2set", AdditionalSubtypeUtils.getLayoutForLocale(context.assets, "ko", context))
+ }
+ } finally {
+ mapping.delete()
+ }
+ assertEquals("azerty", AdditionalSubtypeUtils.getLayoutForLocale(context.assets, "fr-FR", context))
+ }
+
+ @Test
+ fun koreanPickerAndSavedRegionalLocales_resolveAndRegisterWithoutDictionary() {
+ val context = RuntimeEnvironment.getApplication()
+ for ((locale, languageTag) in listOf("ko" to "ko", "ko_KR" to "ko-KR", "ko-KR" to "ko-KR")) {
+ assertEquals("korean_2set", AdditionalSubtypeUtils.getLayoutForLocale(context.assets, locale, context))
+ val subtype = AdditionalSubtypeUtils.createAdditionalSubtypesArray(
+ "$locale:korean_2set", context.assets, context
+ ).single()
+ assertEquals(languageTag, subtype.localeString())
+ assertEquals("korean_2set", AdditionalSubtypeUtils.getKeyboardLayoutFromSubtype(subtype))
+ assertTrue(AdditionalSubtypeUtils.matchesLocaleAndKeyboardLayoutSet(subtype, locale, "korean_2set"))
+ }
+ }
+
+ @Test
+ fun koreanSystemLocale_withoutDictionary_isAutoAddedAsDynamicSubtype() {
+ val context = RuntimeEnvironment.getApplication()
+ val configuration = context.resources.configuration
+ configuration.setLocales(LocaleList(Locale.KOREA))
+ context.resources.updateConfiguration(configuration, context.resources.displayMetrics)
+ SettingsManager.setCustomInputStyles(context, "en:qwerty")
+
+ AdditionalSubtypeUtils.autoAddSystemLocalesWithoutDictionary(context)
+
+ assertEquals("en:qwerty;ko_KR:korean_2set", SettingsManager.getCustomInputStyles(context))
+ val subtypes = AdditionalSubtypeUtils.createAdditionalSubtypesArray(
+ SettingsManager.getCustomInputStyles(context),
+ context.assets,
+ context
+ )
+ assertTrue(subtypes.any { subtype ->
+ subtype.localeString() == "ko-KR" &&
+ AdditionalSubtypeUtils.getKeyboardLayoutFromSubtype(subtype) == "korean_2set" &&
+ subtype.isAsciiCapable
+ })
+ }
+
+ @Test
+ fun turkishSystemLocale_withoutDictionary_usesSameDynamicSubtypeConvention() {
+ val context = RuntimeEnvironment.getApplication()
+ val configuration = context.resources.configuration
+ configuration.setLocales(LocaleList(Locale.forLanguageTag("tr-TR")))
+ context.resources.updateConfiguration(configuration, context.resources.displayMetrics)
+ SettingsManager.setCustomInputStyles(context, "")
+
+ AdditionalSubtypeUtils.autoAddSystemLocalesWithoutDictionary(context)
+
+ assertEquals("tr_TR:turkish_multitap", SettingsManager.getCustomInputStyles(context))
+ }
+
+ @Test
+ fun removingAutoAddedSystemLocale_preservesUserStyleForSameLocale() {
+ val context = RuntimeEnvironment.getApplication()
+ val configuration = context.resources.configuration
+ configuration.setLocales(LocaleList(Locale.KOREA))
+ context.resources.updateConfiguration(configuration, context.resources.displayMetrics)
+ SettingsManager.setCustomInputStyles(context, "")
+
+ AdditionalSubtypeUtils.autoAddSystemLocalesWithoutDictionary(context)
+ SettingsManager.setCustomInputStyles(
+ context,
+ "${SettingsManager.getCustomInputStyles(context)};ko_KR:qwerty"
+ )
+
+ configuration.setLocales(LocaleList(Locale.US))
+ context.resources.updateConfiguration(configuration, context.resources.displayMetrics)
+ AdditionalSubtypeUtils.removeSystemLocalesWithoutDictionary(context)
+
+ assertEquals("ko_KR:qwerty", SettingsManager.getCustomInputStyles(context))
+ }
+
@Test
fun subtypeMatching_usesLocaleAndLayout() {
val context = RuntimeEnvironment.getApplication()
diff --git a/app/src/test/java/it/palsoftware/pastiera/inputmethod/subtype/SubtypeRegistrationAuditRegressionTest.kt b/app/src/test/java/it/palsoftware/pastiera/inputmethod/subtype/SubtypeRegistrationAuditRegressionTest.kt
new file mode 100644
index 000000000..d581f5444
--- /dev/null
+++ b/app/src/test/java/it/palsoftware/pastiera/inputmethod/subtype/SubtypeRegistrationAuditRegressionTest.kt
@@ -0,0 +1,117 @@
+package it.palsoftware.pastiera.inputmethod.subtype
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.os.LocaleList
+import android.view.inputmethod.InputMethodInfo
+import android.view.inputmethod.InputMethodManager
+import android.view.inputmethod.InputMethodSubtype
+import it.palsoftware.pastiera.SettingsManager
+import it.palsoftware.pastiera.inputmethod.PhysicalKeyboardInputMethodService
+import java.util.Locale
+import org.junit.After
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.any
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.Mockito.doAnswer
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.`when`
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class SubtypeRegistrationAuditRegressionTest {
+ private val app = RuntimeEnvironment.getApplication()
+ private val imm = mock(InputMethodManager::class.java)
+ private val info = mock(InputMethodInfo::class.java)
+ private val imeId = "${app.packageName}/${PhysicalKeyboardInputMethodService::class.java.name}"
+ private val en = subtype("en_US")
+ private val it = subtype("it_IT")
+ private val ru = subtype("ru_RU")
+ private val staticSubtypes = listOf(en, it, ru)
+ private var enabled: List = listOf(en, it)
+ private var lastExplicit = intArrayOf()
+ private val context = object : ContextWrapper(app) {
+ override fun getSystemService(name: String): Any? = if (name == Context.INPUT_METHOD_SERVICE) imm else super.getSystemService(name)
+ }
+
+ init {
+ SettingsManager.getPreferences(app).edit().clear().commit()
+ val configuration = app.resources.configuration
+ configuration.setLocales(LocaleList(Locale.US, Locale.ITALY))
+ app.resources.updateConfiguration(configuration, app.resources.displayMetrics)
+ `when`(info.id).thenReturn(imeId)
+ `when`(info.packageName).thenReturn(app.packageName)
+ `when`(info.serviceName).thenReturn(PhysicalKeyboardInputMethodService::class.java.name)
+ `when`(info.subtypeCount).thenReturn(staticSubtypes.size)
+ staticSubtypes.forEachIndexed { index, subtype -> `when`(info.getSubtypeAt(index)).thenReturn(subtype) }
+ `when`(imm.inputMethodList).thenReturn(listOf(info))
+ `when`(imm.getEnabledInputMethodSubtypeList(info, true)).thenAnswer { enabled }
+ doAnswer { call ->
+ lastExplicit = call.getArgument(1)
+ enabled = staticSubtypes.filter { it.hashCode() in lastExplicit }
+ null
+ }.`when`(imm).setExplicitlyEnabledInputMethodSubtypes(eq(imeId), any(IntArray::class.java))
+ }
+
+ @After fun cleanup() {
+ SettingsManager.getPreferences(app).edit().clear().commit()
+ }
+
+ @Test fun systemStyleHiddenThenShown_isEnabledAgain() {
+ SettingsManager.hideSystemInputStyle(app, "it_IT", "qwerty")
+ sync()
+ assertTrue("Hide should remove Italian", it.hashCode() !in lastExplicit)
+ SettingsManager.showSystemInputStyle(app, "it_IT", "qwerty")
+ sync()
+ assertTrue("Visible Italian system style must be restored to the enabled list", it.hashCode() in lastExplicit)
+ }
+
+ @Test fun addedSystemLocale_isEnabledWithExistingExplicitEnglish() {
+ enabled = listOf(en)
+ sync()
+ assertTrue("New Italian system locale must be added even when English was explicitly enabled", it.hashCode() in lastExplicit)
+ }
+
+ @Test fun legacyRussianSelection_isNotDiscardedAsRedundantStaticSubtype() {
+ val configuration = app.resources.configuration
+ configuration.setLocales(LocaleList(Locale.US))
+ app.resources.updateConfiguration(configuration, app.resources.displayMetrics)
+ enabled = listOf(en)
+ SettingsManager.setCustomInputStyles(app, "")
+ SettingsManager.setAdditionalImeSubtypes(app, setOf("ru"))
+ AdditionalSubtypeUtils.registerAdditionalSubtypes(context)
+ assertTrue("Russian selected in Languages must be enabled", ru.hashCode() in lastExplicit)
+ }
+
+ @Test fun explicitlySelectedStaticStyleIsRestoredWithoutBecomingSystemLocale() {
+ enabled = listOf(en)
+ SettingsManager.setCustomInputStyles(app, "ru_RU:russian_translit")
+ AdditionalSubtypeUtils.registerAdditionalSubtypes(context)
+ assertTrue(ru.hashCode() in lastExplicit)
+ }
+
+ @Test fun dynamicKoreanStyleIsRegisteredAndEnabledAlongsideLatin() {
+ SettingsManager.setCustomInputStyles(app,"ko_KR:korean_2set")
+ AdditionalSubtypeUtils.registerAdditionalSubtypes(context)
+ val korean = AdditionalSubtypeUtils.createAdditionalSubtypesArray("ko_KR:korean_2set",app.assets,app).single()
+ assertTrue(korean.hashCode() in lastExplicit)
+ assertTrue(en.hashCode() in lastExplicit)
+ }
+
+ private fun sync() {
+ val method = AdditionalSubtypeUtils::class.java.declaredMethods.single { it.name == "syncExplicitlyEnabledSubtypes" }
+ method.isAccessible = true
+ method.invoke(AdditionalSubtypeUtils, app, imm, info.id, info, emptyArray(), "")
+ }
+
+ private fun subtype(locale: String): InputMethodSubtype = InputMethodSubtype.InputMethodSubtypeBuilder()
+ .setSubtypeLocale(locale)
+ .setSubtypeMode("keyboard")
+ .setSubtypeExtraValue("noSuggestions=true")
+ .build()
+}
diff --git a/app/src/test/java/it/palsoftware/pastiera/inputmethod/subtype/SubtypeRegistrationCompatRegressionTest.kt b/app/src/test/java/it/palsoftware/pastiera/inputmethod/subtype/SubtypeRegistrationCompatRegressionTest.kt
new file mode 100644
index 000000000..ce635f820
--- /dev/null
+++ b/app/src/test/java/it/palsoftware/pastiera/inputmethod/subtype/SubtypeRegistrationCompatRegressionTest.kt
@@ -0,0 +1,47 @@
+package it.palsoftware.pastiera.inputmethod.subtype
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.view.inputmethod.InputMethodInfo
+import android.view.inputmethod.InputMethodManager
+import android.view.inputmethod.InputMethodSubtype
+import it.palsoftware.pastiera.SettingsManager
+import it.palsoftware.pastiera.inputmethod.PhysicalKeyboardInputMethodService
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.Mockito.*
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29,33])
+class SubtypeRegistrationCompatRegressionTest {
+ @Suppress("DEPRECATION")
+ @Test fun olderAndroidRegistersKoreanWithoutCallingApi34() {
+ val app = RuntimeEnvironment.getApplication()
+ val prefs = SettingsManager.getPreferences(app)
+ prefs.edit().clear().commit()
+ val imm = mock(InputMethodManager::class.java)
+ val info = mock(InputMethodInfo::class.java)
+ val imeId = "${app.packageName}/${PhysicalKeyboardInputMethodService::class.java.name}"
+ `when`(info.id).thenReturn(imeId)
+ `when`(info.packageName).thenReturn(app.packageName)
+ `when`(info.serviceName).thenReturn(PhysicalKeyboardInputMethodService::class.java.name)
+ `when`(imm.inputMethodList).thenReturn(listOf(info))
+ val context = object : ContextWrapper(app) {
+ override fun getSystemService(name: String): Any? =
+ if (name == Context.INPUT_METHOD_SERVICE) imm else super.getSystemService(name)
+ }
+ try {
+ SettingsManager.setCustomInputStyles(app,"ko_KR:korean_2set")
+ AdditionalSubtypeUtils.registerAdditionalSubtypes(context)
+ val captor = ArgumentCaptor.forClass(Array::class.java)
+ verify(imm).setAdditionalInputMethodSubtypes(eq(imeId),captor.capture())
+ assertTrue(captor.value.any { AdditionalSubtypeUtils.getKeyboardLayoutFromSubtype(it) == "korean_2set" })
+ assertFalse(mockingDetails(imm).invocations.any { it.method.name == "setExplicitlyEnabledInputMethodSubtypes" })
+ } finally { prefs.edit().clear().commit() }
+ }
+}