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
56 changes: 56 additions & 0 deletions app/src/main/java/helium314/keyboard/latin/Suggest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) {
getSuggestionsInfoListWithDebugInfo(capitalizedTypedWord, suggestionsContainer)
else suggestionsContainer

// Detect and suggest corrections for concatenated words with accidental bottom-row key presses
if (!resultsArePredictions && typedWordString.length > 4 && Settings.getValues().mSuggestSplitConcatenatedWords) {
tryAddConcatenatedWordSuggestions(typedWordString, suggestionsList, firstOccurrenceOfTypedWordInSuggestions)
}

val inputStyle = if (resultsArePredictions) {
if (suggestionResults.mIsBeginningOfSentence) SuggestedWords.INPUT_STYLE_BEGINNING_OF_SENTENCE_PREDICTION
else SuggestedWords.INPUT_STYLE_PREDICTION
Expand Down Expand Up @@ -252,6 +257,57 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) {
return allowsToBeAutoCorrected to hasAutoCorrection
}

/**
* Detects concatenated words with accidental bottom-row key presses (c, v, b, n, m)
* and adds spaced suggestions if both parts are valid dictionary words.
* Example: "hellobthere" -> suggests "hello there"
*/
internal fun tryAddConcatenatedWordSuggestions(
typedWord: String,
suggestions: ArrayList<SuggestedWordInfo>,
firstOccurrenceOfTypedWord: Int
) {
if (firstOccurrenceOfTypedWord >= 0) return // typed word is already valid

val bottomRowChars = setOf('c', 'v', 'b', 'n', 'm')
val lowerTypedWord = typedWord.lowercase(mDictionaryFacilitator.mainLocale)

// Try splitting at each bottom-row character position
for (i in 2 until lowerTypedWord.length - 2) { // min 2 chars on each side
val char = lowerTypedWord[i]
if (char !in bottomRowChars) continue

// Try splitting at this position (removing the accidental character)
val part1 = lowerTypedWord.substring(0, i)
val part2 = lowerTypedWord.substring(i + 1)

if (mDictionaryFacilitator.isValidSpellingWord(part1) &&
mDictionaryFacilitator.isValidSpellingWord(part2)) {

val spacedSuggestion = "$part1 $part2"
// Add with high score to make it a prominent suggestion
val suggestionInfo = SuggestedWordInfo(
spacedSuggestion,
"",
SuggestedWordInfo.MAX_SCORE - 1, // high score but below typed word
SuggestedWordInfo.KIND_CORRECTION,
Dictionary.DICTIONARY_USER_TYPED,
SuggestedWordInfo.NOT_AN_INDEX,
SuggestedWordInfo.NOT_A_CONFIDENCE
)

// Insert at appropriate position
if (!suggestions.any { it.mWord == spacedSuggestion }) {
// If there are already suggestions, insert at position 1 (right after typed word)
// Otherwise just add to the list
val insertPosition = if (suggestions.size > 1) 1 else suggestions.size
suggestions.add(insertPosition, suggestionInfo)
}
return // only add one spaced suggestion
}
}
}

// Retrieves suggestions for the batch input
// and calls the callback function with the suggestions.
private fun getSuggestedWordsForBatchInput(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ object Defaults {
const val PREF_AUTO_CORRECT_THRESHOLD = 0.185f
const val PREF_AUTOCORRECT_SHORTCUTS = true
const val PREF_BACKSPACE_REVERTS_AUTOCORRECT = true
const val PREF_SUGGEST_SPLIT_CONCATENATED_WORDS = false
const val PREF_CENTER_SUGGESTION_TEXT_TO_ENTER = false
const val PREF_SHOW_SUGGESTIONS = true
const val PREF_ALWAYS_SHOW_SUGGESTIONS = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang
public static final String PREF_AUTO_CORRECT_THRESHOLD = "auto_correct_threshold";
public static final String PREF_AUTOCORRECT_SHORTCUTS = "autocorrect_shortcuts";
public static final String PREF_BACKSPACE_REVERTS_AUTOCORRECT = "backspace_reverts_autocorrect";
public static final String PREF_SUGGEST_SPLIT_CONCATENATED_WORDS = "suggest_split_concatenated_words";
public static final String PREF_CENTER_SUGGESTION_TEXT_TO_ENTER = "center_suggestion_text_to_enter";
public static final String PREF_SHOW_SUGGESTIONS = "show_suggestions";
public static final String PREF_ALWAYS_SHOW_SUGGESTIONS = "always_show_suggestions";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ public class SettingsValues {
public final boolean mAutoCorrectEnabled;
public final float mAutoCorrectionThreshold;
public final boolean mBackspaceRevertsAutocorrect;
public final boolean mSuggestSplitConcatenatedWords;
public final int mScoreLimitForAutocorrect;
public final boolean mAutoCorrectShortcuts;
private final boolean mSuggestionsEnabledPerUserSettings;
Expand Down Expand Up @@ -208,6 +209,7 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina
: (mAutoCorrectionThreshold < 0.07 ? 800000 : 950000); // aggressive or modest
mAutoCorrectShortcuts = prefs.getBoolean(Settings.PREF_AUTOCORRECT_SHORTCUTS, Defaults.PREF_AUTOCORRECT_SHORTCUTS);
mBackspaceRevertsAutocorrect = prefs.getBoolean(Settings.PREF_BACKSPACE_REVERTS_AUTOCORRECT, Defaults.PREF_BACKSPACE_REVERTS_AUTOCORRECT);
mSuggestSplitConcatenatedWords = prefs.getBoolean(Settings.PREF_SUGGEST_SPLIT_CONCATENATED_WORDS, Defaults.PREF_SUGGEST_SPLIT_CONCATENATED_WORDS);
mBigramPredictionEnabled = prefs.getBoolean(Settings.PREF_BIGRAM_PREDICTIONS, Defaults.PREF_BIGRAM_PREDICTIONS);
mSuggestPunctuation = prefs.getBoolean(Settings.PREF_SUGGEST_PUNCTUATION, Defaults.PREF_SUGGEST_PUNCTUATION);
mSuggestClipboardContent = prefs.getBoolean(Settings.PREF_SUGGEST_CLIPBOARD_CONTENT, Defaults.PREF_SUGGEST_CLIPBOARD_CONTENT);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ fun TextCorrectionScreen(
if (autocorrectEnabled) Settings.PREF_AUTOCORRECT_SHORTCUTS else null,
if (autocorrectEnabled) Settings.PREF_AUTO_CORRECT_THRESHOLD else null,
if (autocorrectEnabled) Settings.PREF_BACKSPACE_REVERTS_AUTOCORRECT else null,
if (autocorrectEnabled) Settings.PREF_SUGGEST_SPLIT_CONCATENATED_WORDS else null,
Settings.PREF_AUTO_CAP,
R.string.settings_category_space,
Settings.PREF_KEY_USE_DOUBLE_SPACE_PERIOD,
Expand Down Expand Up @@ -134,6 +135,11 @@ fun createCorrectionSettings(context: Context) = listOf(
Setting(context, Settings.PREF_BACKSPACE_REVERTS_AUTOCORRECT, R.string.backspace_reverts_autocorrect) {
SwitchPreference(it, Defaults.PREF_BACKSPACE_REVERTS_AUTOCORRECT)
},
Setting(context, Settings.PREF_SUGGEST_SPLIT_CONCATENATED_WORDS,
R.string.suggest_split_concatenated_words, R.string.suggest_split_concatenated_words_summary
) {
SwitchPreference(it, Defaults.PREF_SUGGEST_SPLIT_CONCATENATED_WORDS)
},
Setting(context, Settings.PREF_AUTO_CAP,
R.string.auto_cap, R.string.auto_cap_summary
) {
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@
<string name="auto_correct_shortcuts_summary">When enabled shortcuts might be expanded by autocorrect</string>
<!-- Option to undo auto correction with backspace -->
<string name="backspace_reverts_autocorrect">Backspace reverts autocorrect</string>
<!-- Option to suggest split words when accidentally typing bottom row keys instead of space -->
<string name="suggest_split_concatenated_words">Suggest split words</string>
<!-- Description for suggest_split_concatenated_words -->
<string name="suggest_split_concatenated_words_summary">Suggest space-separated words when c, v, b, n, or m is accidentally typed instead of space</string>
<!-- Option to disable auto correction. -->
<string name="auto_correction_threshold_mode_off">Off</string>
<!-- Option to suggest auto correction suggestions modestly. Auto-corrects only to a word which has small edit distance from typed word. -->
Expand Down
118 changes: 118 additions & 0 deletions app/src/test/java/helium314/keyboard/latin/SuggestTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,124 @@ class SuggestTest {
assertEquals("word'", result.mWord)
}

private fun createMockFacilitatorWithValidWords(vararg validWords: String): DictionaryFacilitator {
val mock = org.mockito.Mockito.mock(DictionaryFacilitator::class.java)
org.mockito.Mockito.`when`(mock.mainLocale).thenReturn(Locale.ENGLISH)
validWords.forEach { word ->
org.mockito.Mockito.`when`(mock.isValidSpellingWord(word)).thenReturn(true)
}
return mock
}

/**
* Test helper for concatenated word splitting
* @param input The typed word with accidental bottom-row char instead of space (e.g., "hellobthere")
* @param validWords Words to mark as valid in the mock dictionary
* @param expectedSuggestionCount Expected number of suggestions added (0 or 1)
* @param expectedSuggestion The expected suggestion text if count > 0 (e.g., "hello there")
* @param firstOccurrence Position of typed word in existing suggestions (-1 = not found/invalid, >=0 = already valid)
*/
private fun testConcatenatedSplit(input: String, validWords: Array<String>,
expectedSuggestionCount: Int, expectedSuggestion: String? = null,
firstOccurrence: Int = -1) {
val mockFacilitator = createMockFacilitatorWithValidWords(*validWords)
val testSuggest = Suggest(mockFacilitator)
val suggestions = ArrayList<SuggestedWordInfo>()
testSuggest.tryAddConcatenatedWordSuggestions(input, suggestions, firstOccurrence)

assertEquals(expectedSuggestionCount, suggestions.size)
if (expectedSuggestion != null) {
assertEquals(expectedSuggestion, suggestions[0].mWord)
}
}

@Test fun `all bottom row chars trigger split`() {
testConcatenatedSplit("hellobthere", arrayOf("hello", "there"), 1, "hello there")
testConcatenatedSplit("goodntimes", arrayOf("good", "times"), 1, "good times")
testConcatenatedSplit("lovevlife", arrayOf("love", "life"), 1, "love life")
testConcatenatedSplit("bigcdog", arrayOf("big", "dog"), 1, "big dog")
testConcatenatedSplit("somemday", arrayOf("some", "day"), 1, "some day")
}

@Test fun `concatenated words with multiple possible splits - only first valid`() {
testConcatenatedSplit("hellomworld", arrayOf("hello", "world"), 1, "hello world")
}

@Test fun `no split if typed word already in dictionary`() {
// "hellobthere" is already valid (e.g., custom dictionary compound word)
// firstOccurrence=0 means it's found in suggestions at position 0
testConcatenatedSplit("hellobthere", arrayOf("hello", "there", "hellobthere"), 0, firstOccurrence = 0)
}

@Test fun `no split if only one part is valid word`() {
// "hello" is valid but "there" is not (e.g., typing in mixed languages)
testConcatenatedSplit("hellobthere", arrayOf("hello"), 0)
}

@Test fun `minimum word length boundaries`() {
// Works: 2 chars on each side (minimum)
testConcatenatedSplit("atbcat", arrayOf("at", "cat"), 1, "at cat")
testConcatenatedSplit("catbat", arrayOf("cat", "at"), 1, "cat at")

// Fails: less than 2 chars before or after split
testConcatenatedSplit("abcat", arrayOf("a", "cat"), 0)
testConcatenatedSplit("catba", arrayOf("cat", "a"), 0)
}

@Test fun `no split for strings of bottom row chars only`() {
testConcatenatedSplit("bvncm", arrayOf("b", "v", "n", "c", "m"), 0)
}

@Test fun `no split for very short strings`() {
testConcatenatedSplit("ab", arrayOf("a", "b"), 0)
testConcatenatedSplit("abc", arrayOf("a", "b", "c"), 0)
testConcatenatedSplit("abcd", arrayOf("ab", "cd"), 0)
}

@Test fun `split requires exactly 2 chars on each side minimum`() {
testConcatenatedSplit("thebcat", arrayOf("the", "cat"), 1, "the cat")
}

@Test fun `no false positive - words containing bottom row chars are not split`() {
// "abacus" contains 'c' but should not split to "aba us"
testConcatenatedSplit("abacus", arrayOf("abacus", "aba", "us"), 0, firstOccurrence = 0)
}

@Test fun `no false positive - abacus not split when valid`() {
testConcatenatedSplit("abacus", arrayOf("abacus"), 0, firstOccurrence = 0)
}

@Test fun `no false positive - banish contains ban but should not split`() {
testConcatenatedSplit("banish", arrayOf("banish", "ban", "ish"), 0, firstOccurrence = 0)
}

@Test fun `no false positive - combat contains com and bat`() {
testConcatenatedSplit("combat", arrayOf("combat", "com", "bat"), 0, firstOccurrence = 0)
}

@Test fun `no false positive - mania contains bottom row chars`() {
testConcatenatedSplit("mania", arrayOf("mania", "ma", "ia"), 0, firstOccurrence = 0)
}

@Test fun `split momscabacus to moms abacus`() {
testConcatenatedSplit("momscabacus", arrayOf("moms", "abacus"), 1, "moms abacus")
}

@Test fun `split bannmermaids to ban mermaids`() {
testConcatenatedSplit("bannmermaids", arrayOf("ban", "mermaids"), 1, "ban mermaids")
}

@Test fun `split beetlevmania to beetle mania`() {
testConcatenatedSplit("beetlevmania", arrayOf("beetle", "mania"), 1, "beetle mania")
}

@Test fun `only first split for multiple concatenated words`() {
// "thebboyboughtnthembasketball" would ideally be "the boy bought the basketball"
// but algorithm only splits at first valid bottom-row char, giving "the boyboughtnthembasketball"
testConcatenatedSplit("thebboyboughtnthembasketball",
arrayOf("the", "boyboughtnthembasketball"), 1, "the boyboughtnthembasketball")
}

private fun shouldBeAutoCorrected(word: String, // typed word
suggestions: List<SuggestedWordInfo>, // suggestions ordered by score, including suggestion for typed word if in dictionary
firstSuggestionForEmpty: SuggestedWordInfo?, // first suggestion if typed word would be empty (null if none)
Expand Down
Loading