⚠ This page is served via a proxy. Original site: https://github.com
This service does not collect credentials or authentication data.
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
6 changes: 3 additions & 3 deletions app/src/main/res/xml/keys_letters_english_qwerty.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
app:topSmallNumber="1" />
<Key
app:keyLabel="w"
app:popupCharacters="2"
app:popupCharacters=""
app:popupKeyboard="@xml/keyboard_popup_template"
app:topSmallNumber="2" />
<Key
Expand Down Expand Up @@ -78,7 +78,7 @@
app:topSmallNumber="8" />
<Key
app:keyLabel="o"
app:popupCharacters="őöøóôòõ9ō"
app:popupCharacters="őöøóôòõ9ōơ"
app:popupKeyboard="@xml/keyboard_popup_template"
app:topSmallNumber="9" />
<Key
Expand All @@ -93,7 +93,7 @@
app:horizontalGap="5%"
app:keyEdgeFlags="left"
app:keyLabel="a"
app:popupCharacters="áàâãäåāæą"
app:popupCharacters="áàâãäåāæąă"
app:popupKeyboard="@xml/keyboard_popup_template" />
<Key
app:keyLabel="s"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
package org.fossify.keyboard.services

import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test

class VietnameseTelexTest {

private lateinit var telexRules: HashMap<String, String>

@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
}

}
Loading