diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index c7ed8ad2..11715ad7 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -146,4 +146,7 @@ dependencies {
implementation(libs.bundles.room)
ksp(libs.androidx.room.compiler)
detektPlugins(libs.compose.detekt)
+
+ // Unit testing dependencies
+ testImplementation("junit:junit:4.13.2")
}
diff --git a/app/src/main/kotlin/org/fossify/keyboard/services/SimpleKeyboardIME.kt b/app/src/main/kotlin/org/fossify/keyboard/services/SimpleKeyboardIME.kt
index 45b1cbdb..7ac46c46 100644
--- a/app/src/main/kotlin/org/fossify/keyboard/services/SimpleKeyboardIME.kt
+++ b/app/src/main/kotlin/org/fossify/keyboard/services/SimpleKeyboardIME.kt
@@ -383,18 +383,50 @@ class SimpleKeyboardIME : InputMethodService(), OnKeyboardActionListener, Shared
} else 0
if (lastIndexEmpty >= 0) {
val word = fullText.subSequence(lastIndexEmpty, fullText.length).trim().toString()
- val wordChars = word.toCharArray()
- val predictWord = StringBuilder()
- for (char in wordChars.size - 1 downTo 0) {
- predictWord.append(wordChars[char])
- val shouldChangeText = predictWord.reverse().toString()
- if (cachedVNTelexData.containsKey(shouldChangeText)) {
- inputConnection.setComposingRegion(fullText.length - shouldChangeText.length, fullText.length)
- inputConnection.setComposingText(cachedVNTelexData[shouldChangeText], fullText.length)
+
+ // Check for escape sequence FIRST (before single-char transformations)
+ // Only applies when: 1) last two chars are same,
+ // 2) no rule for doubled sequence, 3) rule exists for single char
+ if (word.length >= 2) {
+ val lastTwo = word.takeLast(2)
+ if (lastTwo[0] == lastTwo[1]) {
+ val doubledSeq = lastTwo.lowercase()
+ val singleChar = lastTwo[0].toString().lowercase()
+ // If there's NO rule for the doubled sequence,
+ // but there IS a rule for single char, it's an escape
+ if (!cachedVNTelexData.containsKey(doubledSeq) && cachedVNTelexData.containsKey(singleChar)) {
+ // This is an escape sequence - delete last char to keep just one
+ inputConnection.deleteSurroundingText(1, 0)
+ return
+ }
+ }
+ }
+
+ // Then check for transformation rules (longest patterns first)
+ for (i in word.indices) {
+ val partialWord = word.substring(i, word.length)
+ val partialWordLower = partialWord.lowercase()
+ if (cachedVNTelexData.containsKey(partialWordLower)) {
+ val replacement = cachedVNTelexData[partialWordLower]!!
+ // Preserve case: if first char is uppercase, capitalize replacement
+ val finalReplacement = if (
+ partialWord.firstOrNull()?.isUpperCase() == true &&
+ replacement.isNotEmpty()
+ ) {
+ replacement.replaceFirstChar { it.uppercase() }
+ } else {
+ replacement
+ }
+ inputConnection.setComposingRegion(
+ fullText.length - partialWordLower.length,
+ fullText.length
+ )
+ inputConnection.setComposingText(finalReplacement, fullText.length)
inputConnection.setComposingRegion(fullText.length, fullText.length)
return
}
}
+
inputConnection.commitText(codeChar.toString(), 1)
updateShiftKeyState()
}
diff --git a/app/src/main/res/xml/keys_letters_english_qwerty.xml b/app/src/main/res/xml/keys_letters_english_qwerty.xml
index e6fdf53d..2690dd16 100644
--- a/app/src/main/res/xml/keys_letters_english_qwerty.xml
+++ b/app/src/main/res/xml/keys_letters_english_qwerty.xml
@@ -43,7 +43,7 @@
app:topSmallNumber="1" />
+
+ @Before
+ fun setup() {
+ telexRules = hashMapOf(
+ "w" to "ư",
+ "a" to "ă",
+ "aw" to "ă",
+ "aa" to "â",
+ "dd" to "đ",
+ "ee" to "ê",
+ "oo" to "ô",
+ "ow" to "ơ",
+ "uw" to "ư",
+ "uow" to "ươ"
+ )
+ }
+
+ @Test
+ fun testShortestMatchFirst() {
+ val input = "aw"
+ val result = applyRulesShortestFirst(input)
+ assertEquals("aư", result)
+ }
+
+ @Test
+ fun testLongestMatchFirst() {
+ val input = "aw"
+ val result = applyRulesLongestFirst(input)
+ assertEquals("ă", result)
+ }
+
+ @Test
+ fun testDoubleA() {
+ val input = "aa"
+ val result = applyRulesLongestFirst(input)
+ assertEquals("â", result)
+ }
+
+ @Test
+ fun testTripleCharacter() {
+ val input = "uow"
+ val result = applyRulesLongestFirst(input)
+ assertEquals("ươ", result)
+ }
+
+ @Test
+ fun testNoTransformation() {
+ val input = "b"
+ val result = applyRulesLongestFirst(input)
+ assertEquals("b", result)
+ }
+
+ @Test
+ fun testDoubleWTransformation() {
+ val input = "ww"
+ val shortestFirstResult = applyRulesShortestFirst(input)
+ assertEquals("wư", shortestFirstResult)
+
+ // With escape logic, "ww" should produce "w" (escape sequence)
+ val longestFirstResult = applyRulesLongestFirst(input)
+ assertEquals("w", longestFirstResult)
+ }
+
+ @Test
+ fun testDoubleDTransformation() {
+ val input = "dd"
+ val result = applyRulesLongestFirst(input)
+ assertEquals("đ", result)
+ }
+
+ @Test
+ fun testMixedCaseNotMatching() {
+ val input = "Ee"
+ val result = applyRulesCaseSensitive(input)
+ assertEquals("Ee", result)
+ }
+
+ @Test
+ fun testAllUppercaseNotMatching() {
+ val input = "EE"
+ val result = applyRulesCaseSensitive(input)
+ assertEquals("EE", result)
+ }
+
+ @Test
+ fun testPatternPrecedence() {
+ val input = "aw"
+
+ val shortestFirstResult = applyRulesShortestFirst(input)
+ assertEquals("aư", shortestFirstResult)
+
+ val longestFirstResult = applyRulesLongestFirst(input)
+ assertEquals("ă", longestFirstResult)
+ }
+
+ @Test
+ fun testSingleW() {
+ val input = "w"
+ val result = applyRulesLongestFirst(input)
+ assertEquals("ư", result)
+ }
+
+ @Test
+ fun testTripleW() {
+ val input = "www"
+ val result = applyRulesLongestFirst(input)
+ // "www" → lastTwo "ww" escape to "w", result is "ww"
+ assertEquals("ww", result)
+ }
+
+ @Test
+ fun testSpaceDoubleW() {
+ val input = "O ww"
+ val result = applyRulesLongestFirst(input)
+ // "ww" after space escapes to "w"
+ assertEquals("O w", result)
+ }
+
+ @Test
+ fun testUppercaseCasePreservation_Aw() {
+ val input = "Aw"
+ val result = applyRulesWithCasePreservation(input)
+ assertEquals("Ă", result)
+ }
+
+ @Test
+ fun testLowercaseCasePreservation_aw() {
+ val input = "aw"
+ val result = applyRulesWithCasePreservation(input)
+ assertEquals("ă", result)
+ }
+
+ @Test
+ fun testUppercaseCasePreservation_Ow() {
+ val input = "Ow"
+ val result = applyRulesWithCasePreservation(input)
+ assertEquals("Ơ", result)
+ }
+
+ @Test
+ fun testLowercaseCasePreservation_ow() {
+ val input = "ow"
+ val result = applyRulesWithCasePreservation(input)
+ assertEquals("ơ", result)
+ }
+
+ @Test
+ fun testEscapeSequence_ww() {
+ // Typing "ww" should produce "w" (escape transformation)
+ val result = applyRulesLongestFirst("ww")
+ assertEquals("Double 'w' should escape to literal 'w'", "w", result)
+ }
+
+ @Test
+ fun testEscapeAfterTransformation() {
+ // "ưw" is not a doubled character (last two are 'ư' and 'w'), so "w" transforms normally to "ư"
+ // This gives us "ư" (prefix) + "ư" (transformation of "w") = "ưư"
+ val result = applyRulesLongestFirst("ưw")
+ assertEquals("ưw should transform the 'w' to 'ư', giving 'ưư'", "ưư", result)
+ }
+
+ /**
+ * Helper function that applies transformation rules checking shortest patterns first.
+ * This demonstrates incorrect behavior when shorter patterns match before longer ones.
+ */
+ private fun applyRulesShortestFirst(word: String): String {
+ val wordChars = word.toCharArray()
+ val predictWord = StringBuilder()
+
+ for (char in wordChars.size - 1 downTo 0) {
+ predictWord.append(wordChars[char])
+ val shouldChangeText = predictWord.reverse().toString()
+ val shouldChangeTextLower = shouldChangeText.lowercase()
+
+ if (telexRules.containsKey(shouldChangeTextLower)) {
+ val prefix = word.substring(0, word.length - shouldChangeText.length)
+ return prefix + telexRules[shouldChangeTextLower]
+ }
+
+ predictWord.reverse()
+ }
+
+ return word
+ }
+
+ /**
+ * Helper function that applies transformation rules checking longest patterns first.
+ * This demonstrates correct behavior where longer patterns take precedence.
+ */
+ private fun applyRulesLongestFirst(word: String): String {
+ // Check for escape sequence FIRST (before single-char transformations)
+ // Only applies when: 1) last two chars are same,
+ // 2) no rule for doubled sequence, 3) rule exists for single char
+ if (word.length >= 2) {
+ val lastTwo = word.takeLast(2)
+ if (lastTwo[0] == lastTwo[1]) {
+ val doubledSeq = lastTwo.lowercase()
+ val singleChar = lastTwo[0].toString().lowercase()
+ // If there's NO rule for the doubled sequence, but there IS a rule for single char, it's an escape
+ if (!telexRules.containsKey(doubledSeq) && telexRules.containsKey(singleChar)) {
+ // This is an escape sequence - return word with just one of the doubled char
+ return word.substring(0, word.length - 1)
+ }
+ }
+ }
+
+ // Then check for transformation rules (longest patterns first)
+ for (length in word.length downTo 1) {
+ val suffix = word.substring(word.length - length)
+ val suffixLower = suffix.lowercase()
+ if (telexRules.containsKey(suffixLower)) {
+ val prefix = word.substring(0, word.length - length)
+ return prefix + telexRules[suffixLower]!!
+ }
+ }
+
+ return word
+ }
+
+ /**
+ * Helper function that applies transformation rules with case preservation.
+ * Matches case-insensitively but preserves the original case in output.
+ */
+ private fun applyRulesWithCasePreservation(word: String): String {
+ // Check for escape sequence FIRST (before single-char transformations)
+ if (word.length >= 2) {
+ val lastTwo = word.takeLast(2)
+ if (lastTwo[0] == lastTwo[1]) {
+ val doubledSeq = lastTwo.lowercase()
+ val singleChar = lastTwo[0].toString().lowercase()
+ // If there's NO rule for the doubled sequence, but there IS a rule for single char, it's an escape
+ if (!telexRules.containsKey(doubledSeq) && telexRules.containsKey(singleChar)) {
+ // This is an escape sequence - return word with just one of the doubled char
+ return word.substring(0, word.length - 1)
+ }
+ }
+ }
+
+ // Then check for transformation rules (longest patterns first)
+ for (length in word.length downTo 1) {
+ val suffix = word.substring(word.length - length)
+ val suffixLower = suffix.lowercase()
+ if (telexRules.containsKey(suffixLower)) {
+ val prefix = word.substring(0, word.length - length)
+ val replacement = telexRules[suffixLower]!!
+ // Preserve case: if first char is uppercase, capitalize replacement
+ val finalReplacement = if (suffix.firstOrNull()?.isUpperCase() == true && replacement.isNotEmpty()) {
+ replacement.replaceFirstChar { it.uppercase() }
+ } else {
+ replacement
+ }
+ return prefix + finalReplacement
+ }
+ }
+
+ return word
+ }
+
+ /**
+ * Helper function that applies rules with case-sensitive matching.
+ * Mixed-case input won't match lowercase rules.
+ */
+ private fun applyRulesCaseSensitive(word: String): String {
+ for (length in word.length downTo 1) {
+ val suffix = word.substring(word.length - length)
+ if (telexRules.containsKey(suffix)) {
+ val prefix = word.substring(0, word.length - length)
+ return prefix + telexRules[suffix]!!
+ }
+ }
+
+ return word
+ }
+
+}