From a8bffd42eab3b442fcb8d0f3802730d3ae9c8e1d Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 11 Jun 2026 16:31:24 -0500 Subject: [PATCH 1/2] Code cleanup, release prep --- .../java/com/urik/keyboard/UrikApplication.kt | 30 ++ .../urik/keyboard/UrikInputMethodService.kt | 10 +- .../com/urik/keyboard/di/DatabaseModule.kt | 8 +- .../com/urik/keyboard/di/KeyboardModule.kt | 3 + .../keyboard/dictionary/BareFormRemovelist.kt | 58 ++++ .../keyboard/dictionary/UrikDictionary.kt | 17 +- .../keyboard/service/AutoCorrectionEngine.kt | 8 +- .../keyboard/service/BlacklistRepository.kt | 46 +++ .../service/CustomKeyMappingService.kt | 3 +- .../keyboard/service/FatFingerExpander.kt | 1 + .../keyboard/service/ImeStateCoordinator.kt | 1 - .../keyboard/service/InputStateManager.kt | 15 + .../com/urik/keyboard/service/OutputBridge.kt | 2 +- .../keyboard/service/SpaceInputHandler.kt | 20 +- .../keyboard/service/SpellCheckManager.kt | 43 ++- .../keyboard/service/SuggestionPipeline.kt | 18 +- .../keyboard/service/TextInputProcessor.kt | 16 +- .../keyboard/service/WordLearningEngine.kt | 2 +- .../keyboard/settings/SettingsRepository.kt | 14 +- .../layoutmapper/LayoutMapperViewModel.kt | 2 +- .../components/KeyboardLayoutManager.kt | 34 ++- .../keyboard/components/SwipeKeyboardView.kt | 36 ++- .../keyboard/utils/SelectionStateTracker.kt | 7 +- .../di/DatabaseModuleCorruptionTest.kt | 5 +- .../dictionary/BareFormRemovelistTest.kt | 29 ++ .../keyboard/dictionary/UrikDictionaryTest.kt | 57 ++++ .../InputProcessingIntegrationTest.kt | 90 +++++- .../integration/SwipeInputIntegrationTest.kt | 14 + .../service/AutoCorrectionEngineTest.kt | 10 +- .../service/BlacklistRepositoryTest.kt | 83 +++++ .../service/ClipboardMonitorServiceTest.kt | 2 +- .../service/ImeStateCoordinatorTest.kt | 7 +- .../keyboard/service/InputStateManagerTest.kt | 24 ++ .../service/NonLetterInputHandlerTest.kt | 2 +- .../service/OutputBridgeWithFakeIcTest.kt | 28 ++ .../keyboard/service/SpaceInputHandlerTest.kt | 95 +++++- .../SpellCheckManagerJapaneseSpatialTest.kt | 6 + .../service/SpellCheckManagerSpatialTest.kt | 6 + .../keyboard/service/SpellCheckManagerTest.kt | 134 ++++++-- .../service/SuggestionPipelineTest.kt | 38 ++- .../service/TextInputProcessorTest.kt | 32 ++ .../SwipeKeyboardViewOverlayTouchTest.kt | 286 ++++++++++++++++++ .../utils/SelectionStateTrackerTest.kt | 2 +- gradle/libs.versions.toml | 4 +- 44 files changed, 1222 insertions(+), 126 deletions(-) create mode 100644 app/src/main/java/com/urik/keyboard/dictionary/BareFormRemovelist.kt create mode 100644 app/src/main/java/com/urik/keyboard/service/BlacklistRepository.kt create mode 100644 app/src/test/java/com/urik/keyboard/dictionary/BareFormRemovelistTest.kt create mode 100644 app/src/test/java/com/urik/keyboard/service/BlacklistRepositoryTest.kt create mode 100644 app/src/test/java/com/urik/keyboard/ui/keyboard/components/SwipeKeyboardViewOverlayTouchTest.kt diff --git a/app/src/main/java/com/urik/keyboard/UrikApplication.kt b/app/src/main/java/com/urik/keyboard/UrikApplication.kt index be8e7830..0396da8c 100644 --- a/app/src/main/java/com/urik/keyboard/UrikApplication.kt +++ b/app/src/main/java/com/urik/keyboard/UrikApplication.kt @@ -1,7 +1,10 @@ package com.urik.keyboard import android.app.Application +import android.database.sqlite.SQLiteDatabaseCorruptException +import com.urik.keyboard.data.database.KeyboardDatabase import com.urik.keyboard.di.ApplicationScope +import com.urik.keyboard.di.DatabaseModule import com.urik.keyboard.service.ClipboardMonitorService import com.urik.keyboard.settings.SettingsRepository import com.urik.keyboard.utils.ErrorLogger @@ -11,6 +14,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import net.zetetic.database.sqlcipher.SQLiteNotADatabaseException /** * SQLCipher is loaded before any database access (must precede Room init). @@ -43,6 +47,10 @@ class UrikApplication : Application() { context = mapOf("thread" to thread.name) ) + if (isDatabaseCorruptionException(throwable)) { + recoverFromDatabaseCorruption() + } + previousHandler?.uncaughtException(thread, throwable) } @@ -71,6 +79,28 @@ class UrikApplication : Application() { observeClipboardSettings() } + /** + * Room opens its connection lazily on a background coroutine, so SQLCipher corruption + * errors can arrive here uncaught instead of via [DatabaseModule.provideKeyboardDatabase]'s + * recovery path. Deleting the database files lets the next launch recreate it cleanly. + */ + private fun isDatabaseCorruptionException(throwable: Throwable): Boolean = generateSequence(throwable) { it.cause } + .any { it is SQLiteNotADatabaseException || it is SQLiteDatabaseCorruptException } + + private fun recoverFromDatabaseCorruption() { + try { + KeyboardDatabase.resetInstance() + DatabaseModule.deleteDatabaseFiles(applicationContext) + } catch (e: Exception) { + ErrorLogger.logException( + component = "Application", + severity = ErrorLogger.Severity.CRITICAL, + exception = e, + context = mapOf("phase" to "uncaught_corruption_recovery_failed") + ) + } + } + private fun observeClipboardSettings() { applicationScope.launch { settingsRepository.settings diff --git a/app/src/main/java/com/urik/keyboard/UrikInputMethodService.kt b/app/src/main/java/com/urik/keyboard/UrikInputMethodService.kt index 7a7fcc2f..2b3536db 100644 --- a/app/src/main/java/com/urik/keyboard/UrikInputMethodService.kt +++ b/app/src/main/java/com/urik/keyboard/UrikInputMethodService.kt @@ -75,7 +75,6 @@ import com.urik.keyboard.ui.keyboard.components.SwipeDetector import com.urik.keyboard.ui.keyboard.components.SwipeKeyboardView import com.urik.keyboard.utils.BackspaceUtils import com.urik.keyboard.utils.CacheMemoryManager -import com.urik.keyboard.utils.CursorEditingUtils import com.urik.keyboard.utils.ErrorLogger import com.urik.keyboard.utils.KanaTransformUtils import com.urik.keyboard.utils.KeyboardModeUtils @@ -238,8 +237,6 @@ open class UrikInputMethodService : private fun clearSecureFieldState() = imeStateCoordinator.clearSecureFieldState() - private fun isValidTextInput(text: String): Boolean = CursorEditingUtils.isValidTextInput(text) - private fun updateScriptContext(locale: ULocale) { val currentLayout = viewModel.layout.value val isRTL = currentLayout?.isRTL ?: false @@ -483,7 +480,6 @@ open class UrikInputMethodService : outputBridge = outputBridge, suggestionPipeline = suggestionPipeline, autoCorrectionEngine = autoCorrectionEngine, - textInputProcessor = textInputProcessor, swipeSpaceManager = swipeSpaceManager, swipeDetector = swipeDetector, candidateBarController = candidateBarController, @@ -1425,8 +1421,7 @@ open class UrikInputMethodService : textInputProcessor.removeSuggestion(suggestion) withContext(Dispatchers.Main) { - val currentSuggestions = inputState.pendingSuggestions.filter { it != suggestion } - inputState.pendingSuggestions = currentSuggestions + val currentSuggestions = inputState.removeSuggestionFromState(suggestion) if (currentSuggestions.isNotEmpty()) { candidateBarController.updateSuggestions(currentSuggestions) } else { @@ -1969,10 +1964,7 @@ open class UrikInputMethodService : } private companion object { - const val DOUBLE_TAP_SPACE_THRESHOLD_MS = 250L const val DOUBLE_SHIFT_THRESHOLD_MS = 400L - const val NON_SEQUENTIAL_JUMP_THRESHOLD = 5 - const val WORD_BOUNDARY_CONTEXT_LENGTH = 64 } } diff --git a/app/src/main/java/com/urik/keyboard/di/DatabaseModule.kt b/app/src/main/java/com/urik/keyboard/di/DatabaseModule.kt index d3ec681e..2de46ff1 100644 --- a/app/src/main/java/com/urik/keyboard/di/DatabaseModule.kt +++ b/app/src/main/java/com/urik/keyboard/di/DatabaseModule.kt @@ -68,7 +68,6 @@ object DatabaseModule { @ApplicationContext context: Context, securityManager: DatabaseSecurityManager ): KeyboardDatabase { - var passphrase: ByteArray? = null var alreadyLogged = false try { if (securityManager.shouldMigrateToEncrypted(context)) { @@ -86,7 +85,7 @@ object DatabaseModule { } } - passphrase = + val passphrase = try { securityManager.getDatabasePassphrase() } catch (e: Exception) { @@ -102,7 +101,6 @@ object DatabaseModule { val passphraseWasAvailable = passphrase != null val initialPassphrase = passphrase - passphrase = null return try { opener.open(context, initialPassphrase) } catch (e: Exception) { @@ -184,8 +182,6 @@ object DatabaseModule { initialPassphrase?.fill(0) } } catch (e: Exception) { - passphrase?.fill(0) - if (!alreadyLogged) { ErrorLogger.logException( component = "DatabaseModule", @@ -198,7 +194,7 @@ object DatabaseModule { } } - private fun deleteDatabaseFiles(context: Context) { + internal fun deleteDatabaseFiles(context: Context) { val dbPath = context.applicationContext.getDatabasePath(KeyboardDatabase.DATABASE_NAME) dbPath.delete() if (dbPath.exists()) { diff --git a/app/src/main/java/com/urik/keyboard/di/KeyboardModule.kt b/app/src/main/java/com/urik/keyboard/di/KeyboardModule.kt index 4149675e..801b9e1a 100644 --- a/app/src/main/java/com/urik/keyboard/di/KeyboardModule.kt +++ b/app/src/main/java/com/urik/keyboard/di/KeyboardModule.kt @@ -7,6 +7,7 @@ import com.urik.keyboard.data.database.KeyboardDatabase import com.urik.keyboard.data.database.LearnedWordDao import com.urik.keyboard.data.database.UserWordBigramDao import com.urik.keyboard.data.database.UserWordFrequencyDao +import com.urik.keyboard.service.BlacklistRepository import com.urik.keyboard.service.CharacterVariationService import com.urik.keyboard.service.DictionaryBackupManager import com.urik.keyboard.service.EmojiSearchManager @@ -142,6 +143,7 @@ object KeyboardModule { wordLearningEngine: WordLearningEngine, wordFrequencyRepository: WordFrequencyRepository, cacheMemoryManager: CacheMemoryManager, + blacklistRepository: BlacklistRepository, wordNormalizer: WordNormalizer, fatFingerExpander: FatFingerExpander ): SpellCheckManager = SpellCheckManager( @@ -151,6 +153,7 @@ object KeyboardModule { wordFrequencyRepository, wordNormalizer, cacheMemoryManager, + blacklistRepository, fatFingerExpander = fatFingerExpander ) diff --git a/app/src/main/java/com/urik/keyboard/dictionary/BareFormRemovelist.kt b/app/src/main/java/com/urik/keyboard/dictionary/BareFormRemovelist.kt new file mode 100644 index 00000000..b8a3b19b --- /dev/null +++ b/app/src/main/java/com/urik/keyboard/dictionary/BareFormRemovelist.kt @@ -0,0 +1,58 @@ +package com.urik.keyboard.dictionary + +/** + * Bare forms present in source frequency corpora only as encoding artifacts of + * apostrophe forms (e.g. "im" for "i'm", "jai" for "j'ai") plus OCR garbage + * (e.g. "weii" for "well"). Filtered out of dictionary lookups, candidates and + * completions at runtime so the apostrophe form wins spell check and autocorrect. + * + * All entries must be lowercase. + */ +object BareFormRemovelist { + private val REMOVELIST: Map> = mapOf( + "en" to setOf( + "dont", "wont", "cant", "didnt", "doesnt", "wasnt", "isnt", "arent", + "wouldnt", "couldnt", "shouldnt", "hadnt", "hasnt", "havent", + "thats", "whats", "whos", "hows", "heres", "theres", "wheres", + "hes", "shes", "youre", "theyre", "weve", "theyve", + "youll", "theyll", "itll", "youve", "youd", "hed", "shed", "wed", + "wouldve", "couldve", "shouldve", "werent", "aint", + "howd", "whatre", "lm", "ld", "lll", "lts", "lve", "weii", + "im", "ive", "theyd" + ), + "fr" to setOf( + "jai", "cest", "tai", "lai", "nai", "quil", "nest", + "aujourdhui", "taime", "cetait" + ), + "de" to setOf( + "gibts", + "gehts", + "habs", + "stimmts", + "bins", + "sies" + ), + "el" to setOf( + "μένα", "σένα", "κάντο", "σενα", "παρόλα", "βάλτο", "δώστο", + "κόφτο", "πάρτο", "γιαυτό", "βούλωστο", "σόλο", "απέξω", + "πάρτα", "δώστου", "πάρτον", "δώσμου", "πάρτην", "βάλτα", + "φέρτον", "απτο", "γιαυτο", "ρίξτου", "απτην", "ναναι", + "σευχαριστώ", "μαρέσει", "σαυτό", "απτη", "θαναι", "σαγαπώ", + "απτον", "σουπα", "γιαυτόν", "απαυτά", "απτα", "απότι", + "νασαι", "σαρέσει", "απαυτό", "γιαυτήν", "σαυτή", "μαυτό", + "σαγαπάω", "ναχει", "θαπρεπε", "σαυτόν", "απόλα", "ναμαι", + "γιαυτά", "απτους", "γιαυτή", "θαμαι", "γιαυτούς", "θαθελα", + "απτις", "απόσο", "οσι", "σαυτο", "μαρέσουν", "σόλους", + "θαρθει", "ναμαστε", "σταλήθεια", "μόλα", "αποτι", "θαταν", + "εφόσων", "τοχω", "γιαυτον", "θασαι", "μακούς", "γιαυτα", + "μαυτόν", "σαφήσω", "απαυτούς", "σαυτά", "θαχει", "γιαυτην", + "τοξερα", "μαυτή", "τόνομα", "θαχεις", "σέχω", "μαυτά", + "σέναν", "τοκανες" + ), + "cs" to setOf("dont", "its"), + "nl" to setOf("fotos", "autos"), + "it" to setOf("lho", "dacqua") + ) + + fun forLanguage(languageCode: String): Set = REMOVELIST[languageCode] ?: emptySet() +} diff --git a/app/src/main/java/com/urik/keyboard/dictionary/UrikDictionary.kt b/app/src/main/java/com/urik/keyboard/dictionary/UrikDictionary.kt index 00429e2e..afb91813 100644 --- a/app/src/main/java/com/urik/keyboard/dictionary/UrikDictionary.kt +++ b/app/src/main/java/com/urik/keyboard/dictionary/UrikDictionary.kt @@ -4,7 +4,7 @@ package com.urik.keyboard.dictionary import java.io.InputStream -class UrikDictionary(inputStream: InputStream) { +class UrikDictionary(inputStream: InputStream, private val removedWords: Set = emptySet()) { private val data: ByteArray = inputStream.readBytes() val wordCount: Int @@ -33,7 +33,10 @@ class UrikDictionary(inputStream: InputStream) { return UrikFormat.dequantizeFreq(b) } + private fun isRemoved(word: String): Boolean = removedWords.isNotEmpty() && word.lowercase() in removedWords + private fun getFreqByte(word: String): Int? { + if (isRemoved(word)) return null var stateOffset = stateTableOffset for ((idx, ch) in word.withIndex()) { val arc = findArc(stateOffset, ch) ?: return null @@ -67,7 +70,10 @@ class UrikDictionary(inputStream: InputStream) { val isFinalDawg = (stateHeader and 0x80) != 0 if (isFinalDawg && auto.isAccepting(autoState)) { - results.add(path.toString() to autoState.row.last()) + val word = path.toString() + if (!isRemoved(word)) { + results.add(word to autoState.row.last()) + } } var arcOffset = stateAbsOffset + 1 @@ -113,7 +119,12 @@ class UrikDictionary(inputStream: InputStream) { val arcCount = stateHeader and 0x7F val isFinal = (stateHeader and 0x80) != 0 - if (isFinal) results.add(path.toString() to UrikFormat.dequantizeFreq(incomingFreqByte)) + if (isFinal) { + val word = path.toString() + if (!isRemoved(word)) { + results.add(word to UrikFormat.dequantizeFreq(incomingFreqByte)) + } + } if (results.size >= maxResults) return var arcOffset = stateAbsOffset + 1 diff --git a/app/src/main/java/com/urik/keyboard/service/AutoCorrectionEngine.kt b/app/src/main/java/com/urik/keyboard/service/AutoCorrectionEngine.kt index 0d8125c5..c26d3cc2 100644 --- a/app/src/main/java/com/urik/keyboard/service/AutoCorrectionEngine.kt +++ b/app/src/main/java/com/urik/keyboard/service/AutoCorrectionEngine.kt @@ -8,8 +8,8 @@ import javax.inject.Singleton sealed class AutocorrectDecision { data object None : AutocorrectDecision() data class Correct(val suggestion: String) : AutocorrectDecision() - data object Pause : AutocorrectDecision() - data object ContractionBypass : AutocorrectDecision() + data class Pause(val suggestions: List) : AutocorrectDecision() + data class ContractionBypass(val suggestions: List) : AutocorrectDecision() data class Suggestions(val list: List) : AutocorrectDecision() } @@ -45,7 +45,7 @@ constructor(private val textInputProcessor: TextInputProcessor) { textInputProcessor.hasDominantContractionForm(buffer) if (bypassForContraction) { - return AutocorrectDecision.ContractionBypass + return AutocorrectDecision.ContractionBypass(textInputProcessor.getSuggestions(buffer)) } if (isValid) { @@ -79,7 +79,7 @@ constructor(private val textInputProcessor: TextInputProcessor) { val suggestions = textInputProcessor.getSuggestions(buffer) if (pauseOnMisspelledWord) { - return AutocorrectDecision.Pause + return AutocorrectDecision.Pause(suggestions) } if (autocorrectionEnabled && suggestions.isNotEmpty() && lastAutocorrection == null) { diff --git a/app/src/main/java/com/urik/keyboard/service/BlacklistRepository.kt b/app/src/main/java/com/urik/keyboard/service/BlacklistRepository.kt new file mode 100644 index 00000000..086a4ba1 --- /dev/null +++ b/app/src/main/java/com/urik/keyboard/service/BlacklistRepository.kt @@ -0,0 +1,46 @@ +package com.urik.keyboard.service + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first + +private val Context.blacklistDataStore by preferencesDataStore(name = "blacklist_words") + +/** + * Persists user-rejected suggestion words so removals survive IME process death. + * Global across languages. + */ +@Singleton +class BlacklistRepository internal constructor(private val dataStore: DataStore) { + @Inject + constructor( + @ApplicationContext context: Context + ) : this(context.blacklistDataStore) + + private object PreferenceKeys { + val BLACKLISTED_WORDS = stringSetPreferencesKey("blacklisted_words") + } + + suspend fun getAll(): Set = dataStore.data.first()[PreferenceKeys.BLACKLISTED_WORDS] ?: emptySet() + + suspend fun add(word: String) { + dataStore.edit { preferences -> + val current = preferences[PreferenceKeys.BLACKLISTED_WORDS] ?: emptySet() + preferences[PreferenceKeys.BLACKLISTED_WORDS] = current + word + } + } + + suspend fun remove(word: String) { + dataStore.edit { preferences -> + val current = preferences[PreferenceKeys.BLACKLISTED_WORDS] ?: emptySet() + preferences[PreferenceKeys.BLACKLISTED_WORDS] = current - word + } + } +} diff --git a/app/src/main/java/com/urik/keyboard/service/CustomKeyMappingService.kt b/app/src/main/java/com/urik/keyboard/service/CustomKeyMappingService.kt index 82ffe919..9d135183 100644 --- a/app/src/main/java/com/urik/keyboard/service/CustomKeyMappingService.kt +++ b/app/src/main/java/com/urik/keyboard/service/CustomKeyMappingService.kt @@ -74,12 +74,13 @@ constructor( if (raw.isBlank()) return emptyList() return raw - .split(LONG_PRESS_DELIMITER) + .splitToSequence(LONG_PRESS_DELIMITER) .map { it.trim() } .filter { it.isNotBlank() } .map { Normalizer.normalize(it, Normalizer.Form.NFC) } .distinct() .take(MAX_CUSTOM_SYMBOLS) + .toList() } } } diff --git a/app/src/main/java/com/urik/keyboard/service/FatFingerExpander.kt b/app/src/main/java/com/urik/keyboard/service/FatFingerExpander.kt index 42b3a9f4..3de799d8 100644 --- a/app/src/main/java/com/urik/keyboard/service/FatFingerExpander.kt +++ b/app/src/main/java/com/urik/keyboard/service/FatFingerExpander.kt @@ -1,6 +1,7 @@ package com.urik.keyboard.service import android.graphics.PointF +import com.urik.keyboard.service.FatFingerExpander.Companion.ADJACENT_KEY_THRESHOLD_MULTIPLIER import javax.inject.Inject import javax.inject.Singleton import kotlin.math.sqrt diff --git a/app/src/main/java/com/urik/keyboard/service/ImeStateCoordinator.kt b/app/src/main/java/com/urik/keyboard/service/ImeStateCoordinator.kt index 93e0bf32..283f0c48 100644 --- a/app/src/main/java/com/urik/keyboard/service/ImeStateCoordinator.kt +++ b/app/src/main/java/com/urik/keyboard/service/ImeStateCoordinator.kt @@ -24,7 +24,6 @@ class ImeStateCoordinator( outputBridge.finishComposingText() spellCheckManager.clearCaches() - spellCheckManager.clearBlacklist() textInputProcessor.clearCaches() wordLearningEngine.clearCurrentLanguageCache() diff --git a/app/src/main/java/com/urik/keyboard/service/InputStateManager.kt b/app/src/main/java/com/urik/keyboard/service/InputStateManager.kt index 4e2cd713..f5553d19 100644 --- a/app/src/main/java/com/urik/keyboard/service/InputStateManager.kt +++ b/app/src/main/java/com/urik/keyboard/service/InputStateManager.kt @@ -245,6 +245,21 @@ class InputStateManager( } } + /** Removes [suggestion] from pending, raw, and word-state suggestion lists. Returns the updated pending list. */ + fun removeSuggestionFromState(suggestion: String): List { + val updatedPending = pendingSuggestions.filter { it != suggestion } + pendingSuggestions = updatedPending + currentRawSuggestions = currentRawSuggestions.filter { + !it.word.equals(suggestion, ignoreCase = true) + } + wordState = wordState.copy( + suggestions = wordState.suggestions.filter { + !it.word.equals(suggestion, ignoreCase = true) + } + ) + return updatedPending + } + fun clearBigramPredictions() { if (isShowingBigramPredictions) { isShowingBigramPredictions = false diff --git a/app/src/main/java/com/urik/keyboard/service/OutputBridge.kt b/app/src/main/java/com/urik/keyboard/service/OutputBridge.kt index 3dec93ed..b52be1b3 100644 --- a/app/src/main/java/com/urik/keyboard/service/OutputBridge.kt +++ b/app/src/main/java/com/urik/keyboard/service/OutputBridge.kt @@ -119,7 +119,7 @@ class OutputBridge( } fun attemptRecompositionAtCursor(cursorPosition: Int) { - if (state.requiresDirectCommit || state.isUrlOrEmailField) return + if (state.requiresDirectCommit || state.isUrlOrEmailField || state.isSuggestionsDisabled) return if (state.displayBuffer.isNotEmpty()) return val textBefore = safeGetTextBeforeCursor(WORD_BOUNDARY_CONTEXT_LENGTH) diff --git a/app/src/main/java/com/urik/keyboard/service/SpaceInputHandler.kt b/app/src/main/java/com/urik/keyboard/service/SpaceInputHandler.kt index 5b09ca52..fca856ee 100644 --- a/app/src/main/java/com/urik/keyboard/service/SpaceInputHandler.kt +++ b/app/src/main/java/com/urik/keyboard/service/SpaceInputHandler.kt @@ -13,7 +13,6 @@ class SpaceInputHandler( private val outputBridge: OutputBridge, private val suggestionPipeline: SuggestionPipeline, private val autoCorrectionEngine: AutoCorrectionEngine, - private val textInputProcessor: TextInputProcessor, private val swipeSpaceManager: SwipeSpaceManager, private val swipeDetector: SwipeDetector, private val candidateBarController: CandidateBarController, @@ -110,10 +109,9 @@ class SpaceInputHandler( } is AutocorrectDecision.ContractionBypass -> { - val suggestions = textInputProcessor.getSuggestions(inputState.displayBuffer) val displaySuggestions = suggestionPipeline.storeAndCapitalizeSuggestions( - suggestions, + decision.suggestions, inputState.isCurrentWordAtSentenceStart ) val originalWord = inputState.displayBuffer @@ -148,10 +146,9 @@ class SpaceInputHandler( } is AutocorrectDecision.Pause -> { - val suggestions = textInputProcessor.getSuggestions(inputState.displayBuffer) val displaySuggestions = suggestionPipeline.storeAndCapitalizeSuggestions( - suggestions, + decision.suggestions, inputState.isCurrentWordAtSentenceStart ) inputState.spellConfirmationState = SpellConfirmationState.AWAITING_CONFIRMATION @@ -169,11 +166,17 @@ class SpaceInputHandler( is AutocorrectDecision.Correct -> { val originalWord = inputState.displayBuffer val rawCorrected = decision.suggestion - val correctedWord = if (inputState.isCurrentWordAtSentenceStart) { - rawCorrected.replaceFirstChar { it.uppercaseChar() } + val pronounLang = languageManager.currentLanguage.value.split("-").first() + val pronounCorrected = if (pronounLang == "en") { + EnglishPronounCorrection.capitalize(rawCorrected.lowercase()) ?: rawCorrected } else { rawCorrected } + val correctedWord = if (inputState.isCurrentWordAtSentenceStart) { + pronounCorrected.replaceFirstChar { it.uppercaseChar() } + } else { + pronounCorrected + } inputState.isActivelyEditing = true suggestionPipeline.recordWordUsage(correctedWord) outputBridge.beginBatchEdit() @@ -204,10 +207,9 @@ class SpaceInputHandler( } is AutocorrectDecision.Suggestions -> { - val suggestions = textInputProcessor.getSuggestions(inputState.displayBuffer) val displaySuggestions = suggestionPipeline.storeAndCapitalizeSuggestions( - suggestions, + decision.list, inputState.isCurrentWordAtSentenceStart ) val originalWord = inputState.displayBuffer diff --git a/app/src/main/java/com/urik/keyboard/service/SpellCheckManager.kt b/app/src/main/java/com/urik/keyboard/service/SpellCheckManager.kt index 35912670..709d01b7 100644 --- a/app/src/main/java/com/urik/keyboard/service/SpellCheckManager.kt +++ b/app/src/main/java/com/urik/keyboard/service/SpellCheckManager.kt @@ -3,6 +3,7 @@ package com.urik.keyboard.service import android.content.Context +import com.urik.keyboard.dictionary.BareFormRemovelist import com.urik.keyboard.dictionary.LevenshteinAutomaton import com.urik.keyboard.dictionary.UrikDictionary import com.urik.keyboard.settings.KeyboardSettings @@ -55,6 +56,7 @@ constructor( private val wordFrequencyRepository: com.urik.keyboard.data.WordFrequencyRepository, private val wordNormalizer: WordNormalizer, cacheMemoryManager: CacheMemoryManager, + private val blacklistRepository: BlacklistRepository, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, private val fatFingerExpander: FatFingerExpander = FatFingerExpander() ) : MemoryPressureSubscriber { @@ -106,6 +108,7 @@ constructor( val success = try { withContext(ioDispatcher) { + loadBlacklist() initializeUrik() } true @@ -178,6 +181,10 @@ constructor( false } + private suspend fun loadBlacklist() { + blacklistedWords.addAll(blacklistRepository.getAll()) + } + private fun initializeUrik() { currentLanguage = getCurrentLanguage() val dict = loadUrikDictionary(currentLanguage) @@ -198,7 +205,7 @@ constructor( private fun loadUrikDictionary(languageCode: String): UrikDictionary? = try { val stream = context.assets.open("dictionaries/$languageCode.urik") - UrikDictionary(stream) + UrikDictionary(stream, BareFormRemovelist.forLanguage(languageCode)) } catch (_: Exception) { null } @@ -977,6 +984,19 @@ constructor( val cacheKey = buildCacheKey(normalizedWord, currentLang) dictionaryCache.invalidate(cacheKey) suggestionCache.invalidateAll() + + initScope.launch(ioDispatcher) { + try { + blacklistRepository.add(normalizedWord) + } catch (e: Exception) { + ErrorLogger.logException( + component = "SpellCheckManager", + severity = ErrorLogger.Severity.LOW, + exception = e, + context = mapOf("operation" to "blacklistSuggestion.persist") + ) + } + } } catch (e: Exception) { ErrorLogger.logException( component = "SpellCheckManager", @@ -999,6 +1019,19 @@ constructor( val cacheKey = buildCacheKey(normalizedWord, currentLang) dictionaryCache.invalidate(cacheKey) suggestionCache.invalidateAll() + + initScope.launch(ioDispatcher) { + try { + blacklistRepository.remove(normalizedWord) + } catch (e: Exception) { + ErrorLogger.logException( + component = "SpellCheckManager", + severity = ErrorLogger.Severity.LOW, + exception = e, + context = mapOf("operation" to "removeFromBlacklist.persist") + ) + } + } } } catch (e: Exception) { ErrorLogger.logException( @@ -1023,10 +1056,6 @@ constructor( false } - fun clearBlacklist() { - blacklistedWords.clear() - } - override fun onMemoryPressure(level: Int) { when (level) { android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL, @@ -1216,7 +1245,7 @@ constructor( var bestScore = 0.0 if (skipBudget == 1) { - for (skipPos in 0 until longer.length) { + for (skipPos in longer.indices) { val score = scoreWithSkips(shorter, longer, keyPositions, twoSigmaSquared, skipPos, -1) if (score > bestScore) bestScore = score } @@ -1355,7 +1384,7 @@ constructor( const val SPATIAL_PROXIMITY_WEIGHT = 0.35 const val PROXIMITY_SIGMA_MULTIPLIER = 2.0 - const val MINIMUM_LENGTH_RATIO = 0.6 + const val MINIMUM_LENGTH_RATIO = 0.75 const val LOOKAHEAD_BASE_WEIGHT = 0.5 const val LOOKAHEAD_DECAY = 0.7 diff --git a/app/src/main/java/com/urik/keyboard/service/SuggestionPipeline.kt b/app/src/main/java/com/urik/keyboard/service/SuggestionPipeline.kt index 1da1f8ef..1d35a594 100644 --- a/app/src/main/java/com/urik/keyboard/service/SuggestionPipeline.kt +++ b/app/src/main/java/com/urik/keyboard/service/SuggestionPipeline.kt @@ -413,14 +413,16 @@ class SuggestionPipeline( .forLanguage(host.currentLanguage()) ?.getCandidates(hiraganaBuffer, host.currentLanguage()) ?: emptyList() - val conversionCandidates = rawCandidates.map { candidate -> - SpellingSuggestion( - word = candidate.surface, - confidence = candidate.frequency.toDouble(), - ranking = 0, - source = candidate.source - ) - } + val conversionCandidates = rawCandidates + .map { candidate -> + SpellingSuggestion( + word = candidate.surface, + confidence = candidate.frequency.toDouble(), + ranking = 0, + source = candidate.source + ) + } + .filter { !spellCheckManager.isWordBlacklisted(it.word) } val dictCompletions = if (host.showSuggestions()) { spellCheckManager.getSpellingSuggestionsWithConfidence(hiraganaBuffer) diff --git a/app/src/main/java/com/urik/keyboard/service/TextInputProcessor.kt b/app/src/main/java/com/urik/keyboard/service/TextInputProcessor.kt index f06b82be..46c80e8e 100644 --- a/app/src/main/java/com/urik/keyboard/service/TextInputProcessor.kt +++ b/app/src/main/java/com/urik/keyboard/service/TextInputProcessor.kt @@ -34,7 +34,7 @@ data class WordState( sealed class ProcessingResult { data class Success(val wordState: WordState, val shouldHighlight: Boolean = false) : ProcessingResult() - data class Error(val exception: Throwable, val fallbackState: WordState = WordState()) : ProcessingResult() + data class Error(val exception: Throwable) : ProcessingResult() } private data class ProcessingCache(val normalized: String, val graphemeCount: Int, val timestamp: Long) @@ -127,8 +127,7 @@ constructor( withContext(Dispatchers.Default) { if (!isValidWordInput(word)) { return@withContext ProcessingResult.Error( - IllegalArgumentException("Invalid word input: $word"), - WordState() + IllegalArgumentException("Invalid word input: $word") ) } @@ -201,10 +200,13 @@ constructor( shouldHighlight = shouldHighlight ) } catch (e: Exception) { - return ProcessingResult.Error( - e, - WordState(buffer = word, normalizedBuffer = word.lowercase()) + ErrorLogger.logException( + component = "TextInputProcessor", + severity = ErrorLogger.Severity.HIGH, + exception = e, + context = mapOf("operation" to "processWordInternal") ) + return ProcessingResult.Error(e) } } @@ -354,7 +356,7 @@ constructor( suspend fun removeSuggestion(word: String): Result = withContext(Dispatchers.Default) { return@withContext try { val result = spellCheckManager.removeSuggestion(word) - invalidateWord(word) + suggestionCache.invalidateAll() result } catch (e: Exception) { Result.failure(e) diff --git a/app/src/main/java/com/urik/keyboard/service/WordLearningEngine.kt b/app/src/main/java/com/urik/keyboard/service/WordLearningEngine.kt index 24f62031..269b99ae 100644 --- a/app/src/main/java/com/urik/keyboard/service/WordLearningEngine.kt +++ b/app/src/main/java/com/urik/keyboard/service/WordLearningEngine.kt @@ -52,7 +52,7 @@ data class LearningStats( ) @Singleton -class WordLearningEngine constructor( +class WordLearningEngine( private val learnedWordDao: LearnedWordDao, private val userWordFrequencyDao: UserWordFrequencyDao, private val userWordBigramDao: UserWordBigramDao, diff --git a/app/src/main/java/com/urik/keyboard/settings/SettingsRepository.kt b/app/src/main/java/com/urik/keyboard/settings/SettingsRepository.kt index d5b2818b..c32b1c17 100644 --- a/app/src/main/java/com/urik/keyboard/settings/SettingsRepository.kt +++ b/app/src/main/java/com/urik/keyboard/settings/SettingsRepository.kt @@ -12,6 +12,7 @@ import androidx.datastore.preferences.preferencesDataStore import androidx.room.withTransaction import com.urik.keyboard.data.database.KeyboardDatabase import com.urik.keyboard.model.KeyboardDisplayMode +import com.urik.keyboard.settings.SettingsRepository.Companion.EXPORT_SET_DELIMITER import com.urik.keyboard.utils.CacheMemoryManager import com.urik.keyboard.utils.ErrorLogger import dagger.hilt.android.qualifiers.ApplicationContext @@ -622,21 +623,24 @@ constructor( dataStore.edit { mutablePrefs -> prefs.forEach { (keyName, value) -> - when { - keyName in boolLookup -> boolLookup[keyName]?.let { + when (keyName) { + in boolLookup -> boolLookup[keyName]?.let { mutablePrefs[it] = value.toBoolean() } - keyName in intLookup -> intLookup[keyName]?.let { + + in intLookup -> intLookup[keyName]?.let { value.toIntOrNull()?.let { v -> mutablePrefs[it] = v } } - keyName in setLookup -> setLookup[keyName]?.let { + + in setLookup -> setLookup[keyName]?.let { mutablePrefs[it] = if (value.isEmpty()) { emptySet() } else { value.split(EXPORT_SET_DELIMITER).toSet() } } - keyName in stringLookup -> stringLookup[keyName]?.let { + + in stringLookup -> stringLookup[keyName]?.let { mutablePrefs[it] = value } } diff --git a/app/src/main/java/com/urik/keyboard/settings/layoutmapper/LayoutMapperViewModel.kt b/app/src/main/java/com/urik/keyboard/settings/layoutmapper/LayoutMapperViewModel.kt index 5b2d229c..2422a642 100644 --- a/app/src/main/java/com/urik/keyboard/settings/layoutmapper/LayoutMapperViewModel.kt +++ b/app/src/main/java/com/urik/keyboard/settings/layoutmapper/LayoutMapperViewModel.kt @@ -71,7 +71,7 @@ constructor(private val repository: CustomKeyMappingRepository) : ViewModel() { val encoded = rawInput .trim() - .split("\\s+".toRegex()) + .splitToSequence("\\s+".toRegex()) .filter { it.isNotBlank() } .map { Normalizer.normalize(it, Normalizer.Form.NFC) } .distinct() diff --git a/app/src/main/java/com/urik/keyboard/ui/keyboard/components/KeyboardLayoutManager.kt b/app/src/main/java/com/urik/keyboard/ui/keyboard/components/KeyboardLayoutManager.kt index b3b1bcc2..9bf007dc 100644 --- a/app/src/main/java/com/urik/keyboard/ui/keyboard/components/KeyboardLayoutManager.kt +++ b/app/src/main/java/com/urik/keyboard/ui/keyboard/components/KeyboardLayoutManager.kt @@ -712,21 +712,20 @@ class KeyboardLayoutManager( } private fun preallocateLongPressRunnable(button: Button, key: KeyboardKey) { - when { - key is KeyboardKey.Character && - ( - key.type == KeyboardKey.KeyType.LETTER || - key.type == KeyboardKey.KeyType.NUMBER || - key.type == KeyboardKey.KeyType.SYMBOL - ) -> buttonLongPressRunnables[button] = Runnable { + when (key) { + is KeyboardKey.Character if + key.type == KeyboardKey.KeyType.LETTER || + key.type == KeyboardKey.KeyType.NUMBER || + key.type == KeyboardKey.KeyType.SYMBOL + -> buttonLongPressRunnables[button] = Runnable { characterLongPressFired.add(button) longPressConsumedButtons.add(button) performContextualHaptic(key) handleCharacterLongPress(key, button, button) } - key is KeyboardKey.Character && - key.type == KeyboardKey.KeyType.PUNCTUATION && + is KeyboardKey.Character if + key.type == KeyboardKey.KeyType.PUNCTUATION && longPressPunctuationMode == LongPressPunctuationMode.PERIOD && key.value == "." -> buttonLongPressRunnables[button] = Runnable { @@ -735,7 +734,7 @@ class KeyboardLayoutManager( handlePunctuationLongPress(key, button) } - key is KeyboardKey.Character && key.value == "," -> + is KeyboardKey.Character if key.value == "," -> buttonLongPressRunnables[button] = Runnable { touchDispatcher.commaLongPressFired = true longPressConsumedButtons.add(button) @@ -744,8 +743,8 @@ class KeyboardLayoutManager( onShowInputMethodPicker() } - key is KeyboardKey.Action && - key.action == KeyboardKey.ActionType.SPACE && + is KeyboardKey.Action if + key.action == KeyboardKey.ActionType.SPACE && longPressPunctuationMode == LongPressPunctuationMode.SPACEBAR -> buttonLongPressRunnables[button] = Runnable { longPressConsumedButtons.add(button) @@ -753,8 +752,8 @@ class KeyboardLayoutManager( handleSpaceLongPress(button) } - key is KeyboardKey.Action && - key.action == KeyboardKey.ActionType.SHIFT && + is KeyboardKey.Action if + key.action == KeyboardKey.ActionType.SHIFT && !showLanguageSwitchKey && activeLanguages.size > 1 -> buttonLongPressRunnables[button] = Runnable { @@ -764,7 +763,7 @@ class KeyboardLayoutManager( handleShiftLongPress(button) } - key is KeyboardKey.Action && key.action == KeyboardKey.ActionType.MODE_SWITCH_SYMBOLS -> + is KeyboardKey.Action if key.action == KeyboardKey.ActionType.MODE_SWITCH_SYMBOLS -> buttonLongPressRunnables[button] = Runnable { symbolsLongPressFired.add(button) longPressConsumedButtons.add(button) @@ -772,6 +771,8 @@ class KeyboardLayoutManager( performContextualHaptic(null) onSymbolsLongPress() } + + else -> {} } } @@ -1180,8 +1181,11 @@ class KeyboardLayoutManager( KeyboardKey.ActionType.SMALL_KANA -> "小" KeyboardKey.ActionType.NEXT_CANDIDATE -> "次候補" + KeyboardKey.ActionType.COMMIT_CANDIDATE -> "確定" + KeyboardKey.ActionType.HANDAKUTEN -> "゜" + KeyboardKey.ActionType.EMOJI -> "" else -> { diff --git a/app/src/main/java/com/urik/keyboard/ui/keyboard/components/SwipeKeyboardView.kt b/app/src/main/java/com/urik/keyboard/ui/keyboard/components/SwipeKeyboardView.kt index 1ed10c4e..4dfa0bcb 100644 --- a/app/src/main/java/com/urik/keyboard/ui/keyboard/components/SwipeKeyboardView.kt +++ b/app/src/main/java/com/urik/keyboard/ui/keyboard/components/SwipeKeyboardView.kt @@ -1182,9 +1182,26 @@ constructor( } } + private fun flushInFlightGestureState() { + gestureHandler.cancel() + isSwipeActive = false + hasTouchStart = false + swipeOverlay.endSwipe() + + val now = System.currentTimeMillis() + val cancelEvent = MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL, 0f, 0f, 0) + try { + swipeDetector?.handleTouchEvent(cancelEvent) { x, y -> layoutEngine.findKeyAt(x, y) } + } finally { + cancelEvent.recycle() + } + } + private fun showRemovalConfirmation(suggestion: String) { if (isDestroyed || confirmationOverlay != null) return + flushInFlightGestureState() + pendingRemovalSuggestion = suggestion confirmationOverlay = @@ -1501,7 +1518,8 @@ constructor( private fun findKeyboardView(): ViewGroup? { if (isDestroyed) return null - val overlayViews = setOf(swipeOverlay, suggestionBar, emojiPickerContainer, emojiSearchContainer) + val overlayViews = + setOf(swipeOverlay, suggestionBar, emojiPickerContainer, emojiSearchContainer, confirmationOverlay) for (i in 0 until childCount) { val child = getChildAt(i) if (child !in overlayViews && child is ViewGroup) { @@ -1535,6 +1553,10 @@ constructor( private fun createKeyboardLayout(layout: KeyboardLayout, state: KeyboardState) { if (isDestroyed) return + if (confirmationOverlay != null) { + hideRemovalConfirmation() + } + if (isShowingEmojiPicker) { hideEmojiPicker() } @@ -1678,7 +1700,7 @@ constructor( private fun mapKeyPositions() { if (isDestroyed || layoutEngine.keyViews.isEmpty()) return - val rawPositions = mutableMapOf() + val rawPositions = mutableMapOf() layoutEngine.keyViews.forEach { button -> if (isDestroyed) return@forEach button.getLocationInWindow(cachedLocationArray) @@ -1691,7 +1713,7 @@ constructor( } override fun dispatchTouchEvent(ev: MotionEvent): Boolean { - if (!isDestroyed && (gestureHandler.isActive || isSwipeActive)) { + if (!isDestroyed && confirmationOverlay == null && (gestureHandler.isActive || isSwipeActive)) { return onTouchEvent(ev) } return super.dispatchTouchEvent(ev) @@ -1700,6 +1722,10 @@ constructor( override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { if (isDestroyed) return false + if (confirmationOverlay != null) { + return false + } + if (isTouchInEmojiPicker(ev.x, ev.y)) { return false } @@ -1779,6 +1805,10 @@ constructor( requireInitialized() if (isDestroyed) return false + if (confirmationOverlay != null) { + return false + } + if (isTouchInEmojiPicker(event.x, event.y)) { return false } diff --git a/app/src/main/java/com/urik/keyboard/utils/SelectionStateTracker.kt b/app/src/main/java/com/urik/keyboard/utils/SelectionStateTracker.kt index 52219b5e..e7ebbcfa 100644 --- a/app/src/main/java/com/urik/keyboard/utils/SelectionStateTracker.kt +++ b/app/src/main/java/com/urik/keyboard/utils/SelectionStateTracker.kt @@ -106,10 +106,7 @@ class SelectionStateTracker { return SelectionChangeResult.Sequential } lastKnownValidPosition = newCursor - return SelectionChangeResult.CursorLeftComposingRegion( - composingStart = previousState.composingStart, - composingEnd = previousState.composingEnd - ) + return SelectionChangeResult.CursorLeftComposingRegion } lastKnownValidPosition = newCursor @@ -250,7 +247,7 @@ sealed class SelectionChangeResult { data object ComposingRegionLost : SelectionChangeResult() - data class CursorLeftComposingRegion(val composingStart: Int, val composingEnd: Int) : SelectionChangeResult() + data object CursorLeftComposingRegion : SelectionChangeResult() data class NonSequentialJump(val previousPosition: Int, val newPosition: Int, val distance: Int) : SelectionChangeResult() diff --git a/app/src/test/java/com/urik/keyboard/di/DatabaseModuleCorruptionTest.kt b/app/src/test/java/com/urik/keyboard/di/DatabaseModuleCorruptionTest.kt index 7a226726..fb7d15fd 100644 --- a/app/src/test/java/com/urik/keyboard/di/DatabaseModuleCorruptionTest.kt +++ b/app/src/test/java/com/urik/keyboard/di/DatabaseModuleCorruptionTest.kt @@ -7,6 +7,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +@Suppress("KotlinConstantConditions") class DatabaseModuleCorruptionTest { @Test fun `SQLiteNotADatabaseException is-a SQLiteException`() { @@ -23,7 +24,7 @@ class DatabaseModuleCorruptionTest { @Test fun `multi-catch guard covers SQLiteNotADatabaseException`() { val ex = SQLiteNotADatabaseException("file is not a database") - var caught = false + var caught: Boolean try { throw ex @@ -41,7 +42,7 @@ class DatabaseModuleCorruptionTest { @Test fun `multi-catch guard covers SQLiteDatabaseCorruptException`() { val ex = SQLiteDatabaseCorruptException("database disk image is malformed") - var caught = false + var caught: Boolean try { throw ex diff --git a/app/src/test/java/com/urik/keyboard/dictionary/BareFormRemovelistTest.kt b/app/src/test/java/com/urik/keyboard/dictionary/BareFormRemovelistTest.kt new file mode 100644 index 00000000..125cb98d --- /dev/null +++ b/app/src/test/java/com/urik/keyboard/dictionary/BareFormRemovelistTest.kt @@ -0,0 +1,29 @@ +package com.urik.keyboard.dictionary + +import org.junit.Assert.assertTrue +import org.junit.Test + +class BareFormRemovelistTest { + @Test + fun `english removelist contains contraction bare forms`() { + val en = BareFormRemovelist.forLanguage("en") + assertTrue("im" in en) + assertTrue("ive" in en) + assertTrue("dont" in en) + assertTrue("theyd" in en) + } + + @Test + fun `unknown language returns empty set`() { + assertTrue(BareFormRemovelist.forLanguage("xx").isEmpty()) + } + + @Test + fun `all entries are lowercase`() { + for (lang in listOf("en", "fr", "de", "el", "cs", "nl", "it")) { + for (word in BareFormRemovelist.forLanguage(lang)) { + assertTrue("$lang entry not lowercase: $word", word == word.lowercase()) + } + } + } +} diff --git a/app/src/test/java/com/urik/keyboard/dictionary/UrikDictionaryTest.kt b/app/src/test/java/com/urik/keyboard/dictionary/UrikDictionaryTest.kt index 38eda263..2bc3e813 100644 --- a/app/src/test/java/com/urik/keyboard/dictionary/UrikDictionaryTest.kt +++ b/app/src/test/java/com/urik/keyboard/dictionary/UrikDictionaryTest.kt @@ -125,6 +125,63 @@ class UrikDictionaryTest { assertTrue(dict.lookup("кот")) } + private fun buildRemovelistDict(removedWords: Set): UrikDictionary { + val words = listOf( + "cant" to 3936L, + "can't" to 1510L, + "canto" to 100L, + "hello" to 1_000_000L + ) + return UrikDictionary(buildTestUrik(words).inputStream(), removedWords) + } + + @Test + fun `removed word lookup returns false`() { + val filtered = buildRemovelistDict(setOf("cant")) + assertFalse(filtered.lookup("cant")) + assertTrue(filtered.lookup("can't")) + assertTrue(filtered.lookup("hello")) + } + + @Test + fun `removed word getFrequency returns 0`() { + val filtered = buildRemovelistDict(setOf("cant")) + assertEquals(0L, filtered.getFrequency("cant")) + assertTrue(filtered.getFrequency("can't") > 0L) + } + + @Test + fun `removed word excluded from candidates`() { + val filtered = buildRemovelistDict(setOf("cant")) + val candidates = filtered.getCandidates("cant", maxEditDistance = 2).map { it.first } + assertFalse("cant" in candidates) + assertTrue("can't" in candidates) + } + + @Test + fun `removed word excluded from prefix completions`() { + val filtered = buildRemovelistDict(setOf("cant")) + val words = filtered.getWordsWithPrefix("can", maxResults = 10).map { it.first } + assertFalse("cant" in words) + assertTrue("can't" in words) + assertTrue("canto" in words) + } + + @Test + fun `removal matches case insensitively`() { + val cased = UrikDictionary( + buildTestUrik(listOf("Cant" to 100L)).inputStream(), + setOf("cant") + ) + assertFalse(cased.lookup("Cant")) + } + + @Test + fun `empty removelist leaves dictionary untouched`() { + val unfiltered = buildRemovelistDict(emptySet()) + assertTrue(unfiltered.lookup("cant")) + } + @Test fun `getCandidates unicode substitution`() { val candidates = dict.getCandidates("кат", maxEditDistance = 1) diff --git a/app/src/test/java/com/urik/keyboard/integration/InputProcessingIntegrationTest.kt b/app/src/test/java/com/urik/keyboard/integration/InputProcessingIntegrationTest.kt index c605ee95..a0c5f371 100644 --- a/app/src/test/java/com/urik/keyboard/integration/InputProcessingIntegrationTest.kt +++ b/app/src/test/java/com/urik/keyboard/integration/InputProcessingIntegrationTest.kt @@ -4,9 +4,11 @@ package com.urik.keyboard.integration import android.content.Context import android.content.res.AssetManager +import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.room.Room import com.urik.keyboard.data.WordFrequencyRepository import com.urik.keyboard.data.database.KeyboardDatabase +import com.urik.keyboard.service.BlacklistRepository import com.urik.keyboard.service.InputMethod import com.urik.keyboard.service.LanguageManager import com.urik.keyboard.service.ProcessingResult @@ -18,13 +20,19 @@ import com.urik.keyboard.settings.KeyboardSettings import com.urik.keyboard.settings.SettingsRepository import com.urik.keyboard.utils.CacheMemoryManager import java.io.ByteArrayInputStream +import java.io.File +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -44,6 +52,7 @@ import org.robolectric.RuntimeEnvironment @RunWith(RobolectricTestRunner::class) class InputProcessingIntegrationTest { private lateinit var context: Context + private lateinit var mockContext: Context private lateinit var database: KeyboardDatabase private lateinit var cacheMemoryManager: CacheMemoryManager private lateinit var settingsRepository: SettingsRepository @@ -51,6 +60,7 @@ class InputProcessingIntegrationTest { private lateinit var wordLearningEngine: WordLearningEngine private lateinit var spellCheckManager: SpellCheckManager private lateinit var textInputProcessor: TextInputProcessor + private lateinit var blacklistRepository: BlacklistRepository private val testDispatcher = UnconfinedTestDispatcher() private val testDictionary = @@ -79,7 +89,7 @@ class InputProcessingIntegrationTest { } } } - val mockContext = spy(context) + mockContext = spy(context) whenever(mockContext.assets).thenReturn(mockAssets) database = @@ -126,6 +136,14 @@ class InputProcessingIntegrationTest { testDispatcher ) + blacklistRepository = + BlacklistRepository( + PreferenceDataStoreFactory.create( + scope = CoroutineScope(testDispatcher + SupervisorJob()), + produceFile = { File(context.cacheDir, "blacklist_test.preferences_pb") } + ) + ) + spellCheckManager = SpellCheckManager( mockContext, @@ -134,6 +152,7 @@ class InputProcessingIntegrationTest { wordFrequencyRepository, wordNormalizer, cacheMemoryManager, + blacklistRepository, testDispatcher ) @@ -337,6 +356,75 @@ class InputProcessingIntegrationTest { assertTrue("Frequency should increment through DAO transaction", word!!.frequency > 1) } + @Test + fun `removed suggestion stays absent after retyping and after SpellCheckManager restart`() = + runTest(testDispatcher) { + val before = textInputProcessor.getSuggestions("helo") + assertTrue("'hello' should be suggested before removal", before.any { it.word == "hello" }) + + val removeResult = textInputProcessor.removeSuggestion("hello") + assertTrue(removeResult.isSuccess) + + withContext(Dispatchers.Default) { + withTimeout(5_000) { + while (!blacklistRepository.getAll().contains("hello")) { + delay(10) + } + } + } + + val afterRemoval = textInputProcessor.getSuggestions("helo") + assertFalse( + "'hello' must not be suggested again after removal", + afterRemoval.any { it.word == "hello" } + ) + + val wordNormalizer = WordNormalizer() + val wordFrequencyRepository = + WordFrequencyRepository( + database, + database.userWordFrequencyDao(), + database.userWordBigramDao(), + wordNormalizer, + cacheMemoryManager, + testDispatcher, + testDispatcher + ) + val restartedSpellCheckManager = + SpellCheckManager( + mockContext, + languageManager, + wordLearningEngine, + wordFrequencyRepository, + wordNormalizer, + cacheMemoryManager, + blacklistRepository, + testDispatcher + ) + val restartedTextInputProcessor = + TextInputProcessor( + restartedSpellCheckManager, + settingsRepository, + cacheMemoryManager + ) + + assertTrue( + "Restarted manager should finish initialization and know dictionary words", + restartedSpellCheckManager.isWordInDictionary("test") + ) + + assertTrue( + "Blacklist must persist across SpellCheckManager restarts", + restartedSpellCheckManager.isWordBlacklisted("hello") + ) + + val afterRestart = restartedTextInputProcessor.getSuggestions("helo") + assertFalse( + "'hello' must remain absent after restart", + afterRestart.any { it.word == "hello" } + ) + } + @Test fun `database errors propagate gracefully through all layers`() = runTest(testDispatcher) { database.close() diff --git a/app/src/test/java/com/urik/keyboard/integration/SwipeInputIntegrationTest.kt b/app/src/test/java/com/urik/keyboard/integration/SwipeInputIntegrationTest.kt index b2b9ab5e..05085907 100644 --- a/app/src/test/java/com/urik/keyboard/integration/SwipeInputIntegrationTest.kt +++ b/app/src/test/java/com/urik/keyboard/integration/SwipeInputIntegrationTest.kt @@ -4,9 +4,11 @@ package com.urik.keyboard.integration import android.content.Context import android.content.res.AssetManager +import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.room.Room import com.urik.keyboard.data.WordFrequencyRepository import com.urik.keyboard.data.database.KeyboardDatabase +import com.urik.keyboard.service.BlacklistRepository import com.urik.keyboard.service.InputMethod import com.urik.keyboard.service.LanguageManager import com.urik.keyboard.service.ProcessingResult @@ -23,8 +25,11 @@ import com.urik.keyboard.ui.keyboard.components.SwipeDetector import com.urik.keyboard.ui.keyboard.components.ZipfCheck import com.urik.keyboard.utils.CacheMemoryManager import java.io.ByteArrayInputStream +import java.io.File +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain @@ -139,6 +144,14 @@ class SwipeInputIntegrationTest { testDispatcher ) + val blacklistRepository = + BlacklistRepository( + PreferenceDataStoreFactory.create( + scope = CoroutineScope(testDispatcher + SupervisorJob()), + produceFile = { File(context.cacheDir, "blacklist_test.preferences_pb") } + ) + ) + spellCheckManager = SpellCheckManager( mockContext, @@ -147,6 +160,7 @@ class SwipeInputIntegrationTest { wordFrequencyRepository, wordNormalizer, cacheMemoryManager, + blacklistRepository, testDispatcher ) diff --git a/app/src/test/java/com/urik/keyboard/service/AutoCorrectionEngineTest.kt b/app/src/test/java/com/urik/keyboard/service/AutoCorrectionEngineTest.kt index 01a1eccd..998947fe 100644 --- a/app/src/test/java/com/urik/keyboard/service/AutoCorrectionEngineTest.kt +++ b/app/src/test/java/com/urik/keyboard/service/AutoCorrectionEngineTest.kt @@ -7,6 +7,8 @@ import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever class AutoCorrectionEngineTest { @@ -130,8 +132,10 @@ class AutoCorrectionEngineTest { @Test fun `decide returns ContractionBypass when valid word has dominant contraction form`() = runTest { + val suggestion = SpellingSuggestion("don't", 1.0, 0) whenever(mockTextInputProcessor.validateWord(any())).thenReturn(true) whenever(mockTextInputProcessor.hasDominantContractionForm(any())).thenReturn(true) + whenever(mockTextInputProcessor.getSuggestions(any())).thenReturn(listOf(suggestion)) val result = engine.decide( buffer = "dont", @@ -143,12 +147,15 @@ class AutoCorrectionEngineTest { nextChar = " " ) assert(result is AutocorrectDecision.ContractionBypass) + assertEquals(listOf(suggestion), (result as AutocorrectDecision.ContractionBypass).suggestions) + verify(mockTextInputProcessor, times(1)).getSuggestions(any()) } @Test fun `decide returns Pause when invalid word and pauseOnMisspelledWord is true`() = runTest { + val suggestion = SpellingSuggestion("the", 1.0, 0) whenever(mockTextInputProcessor.validateWord(any())).thenReturn(false) - whenever(mockTextInputProcessor.getSuggestions(any())).thenReturn(emptyList()) + whenever(mockTextInputProcessor.getSuggestions(any())).thenReturn(listOf(suggestion)) val result = engine.decide( buffer = "teh", @@ -160,6 +167,7 @@ class AutoCorrectionEngineTest { nextChar = " " ) assert(result is AutocorrectDecision.Pause) + assertEquals(listOf(suggestion), (result as AutocorrectDecision.Pause).suggestions) } @Test diff --git a/app/src/test/java/com/urik/keyboard/service/BlacklistRepositoryTest.kt b/app/src/test/java/com/urik/keyboard/service/BlacklistRepositoryTest.kt new file mode 100644 index 00000000..0c3efe7c --- /dev/null +++ b/app/src/test/java/com/urik/keyboard/service/BlacklistRepositoryTest.kt @@ -0,0 +1,83 @@ +package com.urik.keyboard.service + +import android.content.Context +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.test.core.app.ApplicationProvider +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class BlacklistRepositoryTest { + private lateinit var repository: BlacklistRepository + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + val dataStore = + PreferenceDataStoreFactory.create( + scope = CoroutineScope(UnconfinedTestDispatcher() + SupervisorJob()), + produceFile = { File(context.cacheDir, "blacklist_test.preferences_pb") } + ) + repository = BlacklistRepository(dataStore) + } + + @Test + fun `getAll returns empty set when nothing blacklisted`() = runTest { + val words = repository.getAll() + + assertTrue(words.isEmpty()) + } + + @Test + fun `add persists word`() = runTest { + repository.add("hello") + + val words = repository.getAll() + + assertEquals(setOf("hello"), words) + } + + @Test + fun `add is idempotent for duplicate word`() = runTest { + repository.add("hello") + repository.add("hello") + + val words = repository.getAll() + + assertEquals(setOf("hello"), words) + } + + @Test + fun `remove deletes word`() = runTest { + repository.add("hello") + repository.add("world") + + repository.remove("hello") + + val words = repository.getAll() + assertEquals(setOf("world"), words) + } + + @Test + fun `remove for missing word does nothing`() = runTest { + repository.add("world") + + repository.remove("missing") + + val words = repository.getAll() + assertEquals(setOf("world"), words) + } +} diff --git a/app/src/test/java/com/urik/keyboard/service/ClipboardMonitorServiceTest.kt b/app/src/test/java/com/urik/keyboard/service/ClipboardMonitorServiceTest.kt index 5b908b5c..d825f4cb 100644 --- a/app/src/test/java/com/urik/keyboard/service/ClipboardMonitorServiceTest.kt +++ b/app/src/test/java/com/urik/keyboard/service/ClipboardMonitorServiceTest.kt @@ -98,7 +98,7 @@ class ClipboardMonitorServiceTest { fun `repository delegation passes truncated text content`() = runTest { val captured = mutableListOf() whenever(repo.addItem(any())).thenAnswer { invocation -> - captured.add(invocation.getArgument(0)) + captured.add(invocation.getArgument(0)) Result.success(Unit) } diff --git a/app/src/test/java/com/urik/keyboard/service/ImeStateCoordinatorTest.kt b/app/src/test/java/com/urik/keyboard/service/ImeStateCoordinatorTest.kt index f7fc7d0e..5764e988 100644 --- a/app/src/test/java/com/urik/keyboard/service/ImeStateCoordinatorTest.kt +++ b/app/src/test/java/com/urik/keyboard/service/ImeStateCoordinatorTest.kt @@ -2,8 +2,10 @@ package com.urik.keyboard.service import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify class ImeStateCoordinatorTest { @@ -62,10 +64,11 @@ class ImeStateCoordinatorTest { } @Test - fun `clearSecureFieldState clears spellCheckManager caches`() { + fun `clearSecureFieldState clears spellCheckManager caches without touching blacklist`() { coordinator.clearSecureFieldState() verify(mockSpellCheckManager).clearCaches() - verify(mockSpellCheckManager).clearBlacklist() + verify(mockSpellCheckManager, never()).blacklistSuggestion(any()) + verify(mockSpellCheckManager, never()).removeFromBlacklist(any()) } @Test diff --git a/app/src/test/java/com/urik/keyboard/service/InputStateManagerTest.kt b/app/src/test/java/com/urik/keyboard/service/InputStateManagerTest.kt index ade82e0e..b42e5bae 100644 --- a/app/src/test/java/com/urik/keyboard/service/InputStateManagerTest.kt +++ b/app/src/test/java/com/urik/keyboard/service/InputStateManagerTest.kt @@ -260,4 +260,28 @@ class InputStateManagerTest { fun `isSuggestionsDisabled initialises false`() { assertFalse(stateManager.isSuggestionsDisabled) } + + @Test + fun `removeSuggestionFromState removes word from pending, raw, and word state suggestions`() { + stateManager.pendingSuggestions = listOf("Hello", "World") + stateManager.currentRawSuggestions = listOf( + SpellingSuggestion(word = "hello", confidence = 0.9, ranking = 0), + SpellingSuggestion(word = "world", confidence = 0.8, ranking = 1) + ) + stateManager.wordState = stateManager.wordState.copy( + suggestions = listOf( + SpellingSuggestion(word = "hello", confidence = 0.9, ranking = 0), + SpellingSuggestion(word = "world", confidence = 0.8, ranking = 1) + ) + ) + + val remaining = stateManager.removeSuggestionFromState("Hello") + + assertEquals(listOf("World"), remaining) + assertEquals(listOf("World"), stateManager.pendingSuggestions) + assertFalse(stateManager.currentRawSuggestions.any { it.word.equals("hello", ignoreCase = true) }) + assertFalse(stateManager.wordState.suggestions.any { it.word.equals("hello", ignoreCase = true) }) + assertTrue(stateManager.currentRawSuggestions.any { it.word.equals("world", ignoreCase = true) }) + assertTrue(stateManager.wordState.suggestions.any { it.word.equals("world", ignoreCase = true) }) + } } diff --git a/app/src/test/java/com/urik/keyboard/service/NonLetterInputHandlerTest.kt b/app/src/test/java/com/urik/keyboard/service/NonLetterInputHandlerTest.kt index 539e87fe..f9a297e1 100644 --- a/app/src/test/java/com/urik/keyboard/service/NonLetterInputHandlerTest.kt +++ b/app/src/test/java/com/urik/keyboard/service/NonLetterInputHandlerTest.kt @@ -93,7 +93,7 @@ class NonLetterInputHandlerTest { realInputState.displayBuffer = "hello" handler.handle(".") testDispatcher.scheduler.advanceUntilIdle() - assert(getCurrentSettingsCalls.size >= 1) + assert(getCurrentSettingsCalls.isNotEmpty()) } @Test diff --git a/app/src/test/java/com/urik/keyboard/service/OutputBridgeWithFakeIcTest.kt b/app/src/test/java/com/urik/keyboard/service/OutputBridgeWithFakeIcTest.kt index c02e9820..1528f383 100644 --- a/app/src/test/java/com/urik/keyboard/service/OutputBridgeWithFakeIcTest.kt +++ b/app/src/test/java/com/urik/keyboard/service/OutputBridgeWithFakeIcTest.kt @@ -75,4 +75,32 @@ class OutputBridgeWithFakeIcTest { assertEquals("hell", fakeIc.textBuffer.toString()) assertEquals(4, fakeIc.selectionStart) } + + @Test + fun `attemptRecompositionAtCursor does nothing when suggestions disabled`() { + val realState = InputStateManager( + viewCallback = mock(), + onShiftStateChanged = {}, + isCapsLockOn = { false }, + cancelDebounceJob = {} + ) + realState.isSuggestionsDisabled = true + + val bridge = OutputBridge( + state = realState, + swipeDetector = mockSwipeDetector, + swipeSpaceManager = mockSwipeSpaceManager, + icProvider = { fakeIc } + ) + + fakeIc.textBuffer.append("Th") + fakeIc.selectionStart = 2 + + bridge.attemptRecompositionAtCursor(2) + + assertEquals(-1, fakeIc.composingStart) + assertEquals(-1, fakeIc.composingEnd) + assertEquals("", realState.displayBuffer) + assertEquals(-1, realState.composingRegionStart) + } } diff --git a/app/src/test/java/com/urik/keyboard/service/SpaceInputHandlerTest.kt b/app/src/test/java/com/urik/keyboard/service/SpaceInputHandlerTest.kt index 9c6bf5ca..dc2db2dc 100644 --- a/app/src/test/java/com/urik/keyboard/service/SpaceInputHandlerTest.kt +++ b/app/src/test/java/com/urik/keyboard/service/SpaceInputHandlerTest.kt @@ -3,14 +3,21 @@ package com.urik.keyboard.service import com.urik.keyboard.settings.KeyboardSettings import com.urik.keyboard.ui.keyboard.components.SwipeDetector import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.mockito.Mockito.mock import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever @OptIn(ExperimentalCoroutinesApi::class) class SpaceInputHandlerTest { @@ -22,7 +29,6 @@ class SpaceInputHandlerTest { private lateinit var mockOutputBridge: OutputBridge private lateinit var mockSuggestionPipeline: SuggestionPipeline private lateinit var mockAutoCorrectionEngine: AutoCorrectionEngine - private lateinit var mockTextInputProcessor: TextInputProcessor private lateinit var mockSwipeSpaceManager: SwipeSpaceManager private lateinit var mockSwipeDetector: SwipeDetector private lateinit var mockCandidateBarController: CandidateBarController @@ -44,7 +50,6 @@ class SpaceInputHandlerTest { mockOutputBridge = mock(OutputBridge::class.java) mockSuggestionPipeline = mock(SuggestionPipeline::class.java) mockAutoCorrectionEngine = mock(AutoCorrectionEngine::class.java) - mockTextInputProcessor = mock(TextInputProcessor::class.java) mockSwipeSpaceManager = mock(SwipeSpaceManager::class.java) mockSwipeDetector = mock(SwipeDetector::class.java) mockCandidateBarController = mock(CandidateBarController::class.java) @@ -54,7 +59,6 @@ class SpaceInputHandlerTest { outputBridge = mockOutputBridge, suggestionPipeline = mockSuggestionPipeline, autoCorrectionEngine = mockAutoCorrectionEngine, - textInputProcessor = mockTextInputProcessor, swipeSpaceManager = mockSwipeSpaceManager, swipeDetector = mockSwipeDetector, candidateBarController = mockCandidateBarController, @@ -110,4 +114,89 @@ class SpaceInputHandlerTest { org.mockito.Mockito.verify(mockOutputBridge, org.mockito.Mockito.times(2)).sendSpace() org.mockito.Mockito.verify(mockOutputBridge, org.mockito.Mockito.never()).commitText(". ", 1) } + + private suspend fun stubDecisionPath(decision: AutocorrectDecision, buffer: String) { + realInputState.displayBuffer = buffer + whenever(mockLanguageManager.currentLanguage).thenReturn(MutableStateFlow("en")) + whenever(mockOutputBridge.safeGetTextBeforeCursor(any(), any())).thenReturn(buffer) + whenever(mockOutputBridge.safeGetTextAfterCursor(any(), any())).thenReturn("") + whenever(mockSuggestionPipeline.storeAndCapitalizeSuggestions(any(), any())) + .thenReturn(decisionSuggestionWords(decision)) + whenever( + mockAutoCorrectionEngine.decide(any(), any(), any(), any(), anyOrNull(), any(), any()) + ).thenReturn(decision) + whenever(mockSuggestionPipeline.learnWordAndInvalidateCache(any(), any())).thenReturn(true) + } + + private fun decisionSuggestionWords(decision: AutocorrectDecision): List = when (decision) { + is AutocorrectDecision.Pause -> decision.suggestions.map { it.word } + is AutocorrectDecision.ContractionBypass -> decision.suggestions.map { it.word } + is AutocorrectDecision.Suggestions -> decision.list.map { it.word } + else -> emptyList() + } + + @Test + fun `handle Pause decision reuses precomputed suggestions without recomputing`() = testScope.runTest { + val suggestion = SpellingSuggestion("hello", 1.0, 0) + stubDecisionPath(AutocorrectDecision.Pause(listOf(suggestion)), "helo") + + handler.handle() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(SpellConfirmationState.AWAITING_CONFIRMATION, realInputState.spellConfirmationState) + assertEquals(listOf("hello"), realInputState.pendingSuggestions) + verify(mockSuggestionPipeline).storeAndCapitalizeSuggestions(eq(listOf(suggestion)), any()) + verify(mockCandidateBarController).updateSuggestions(listOf("hello")) + } + + @Test + fun `handle ContractionBypass decision reuses precomputed suggestions without recomputing`() = testScope.runTest { + val suggestion = SpellingSuggestion("don't", 1.0, 0) + stubDecisionPath(AutocorrectDecision.ContractionBypass(listOf(suggestion)), "dont") + + handler.handle() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf("don't"), realInputState.pendingSuggestions) + assertEquals("dont", realInputState.postCommitReplacementState?.originalWord) + verify(mockSuggestionPipeline).storeAndCapitalizeSuggestions(eq(listOf(suggestion)), any()) + verify(mockCandidateBarController).updateSuggestions(listOf("don't")) + } + + @Test + fun `handle Correct decision applies English pronoun capitalization to corrected word`() = testScope.runTest { + stubDecisionPath(AutocorrectDecision.Correct("i'm"), "im") + + handler.handle() + testDispatcher.scheduler.advanceUntilIdle() + + verify(mockOutputBridge).commitText("I'm ", 1) + assertEquals("I'm", realInputState.lastAutocorrection?.correctedWord) + assertEquals("im", realInputState.lastAutocorrection?.originalTypedWord) + } + + @Test + fun `handle Correct decision commits non-pronoun correction unchanged`() = testScope.runTest { + stubDecisionPath(AutocorrectDecision.Correct("hello"), "helo") + + handler.handle() + testDispatcher.scheduler.advanceUntilIdle() + + verify(mockOutputBridge).commitText("hello ", 1) + assertEquals("hello", realInputState.lastAutocorrection?.correctedWord) + } + + @Test + fun `handle Suggestions decision reuses precomputed list without recomputing`() = testScope.runTest { + val suggestion = SpellingSuggestion("hello", 1.0, 0) + stubDecisionPath(AutocorrectDecision.Suggestions(listOf(suggestion)), "helo") + + handler.handle() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf("hello"), realInputState.pendingSuggestions) + assertEquals("helo", realInputState.postCommitReplacementState?.originalWord) + verify(mockSuggestionPipeline).storeAndCapitalizeSuggestions(eq(listOf(suggestion)), any()) + verify(mockCandidateBarController).updateSuggestions(listOf("hello")) + } } diff --git a/app/src/test/java/com/urik/keyboard/service/SpellCheckManagerJapaneseSpatialTest.kt b/app/src/test/java/com/urik/keyboard/service/SpellCheckManagerJapaneseSpatialTest.kt index 7a6464e3..6aed909d 100644 --- a/app/src/test/java/com/urik/keyboard/service/SpellCheckManagerJapaneseSpatialTest.kt +++ b/app/src/test/java/com/urik/keyboard/service/SpellCheckManagerJapaneseSpatialTest.kt @@ -53,6 +53,7 @@ class SpellCheckManagerJapaneseSpatialTest { private lateinit var wordLearningEngine: WordLearningEngine private lateinit var wordFrequencyRepository: WordFrequencyRepository private lateinit var cacheMemoryManager: CacheMemoryManager + private lateinit var blacklistRepository: BlacklistRepository private lateinit var wordNormalizer: WordNormalizer private lateinit var keyPositionsFlow: MutableStateFlow> @@ -97,6 +98,10 @@ class SpellCheckManagerJapaneseSpatialTest { onBlocking { getFrequencies(any(), any()) } doReturn emptyMap() } + blacklistRepository = mock { + onBlocking { getAll() } doReturn emptySet() + } + val suggestionCache = ManagedCache>( name = "test_suggestions", maxSize = 500, @@ -140,6 +145,7 @@ class SpellCheckManagerJapaneseSpatialTest { wordLearningEngine = wordLearningEngine, wordFrequencyRepository = wordFrequencyRepository, cacheMemoryManager = cacheMemoryManager, + blacklistRepository = blacklistRepository, ioDispatcher = testDispatcher, wordNormalizer = wordNormalizer ) diff --git a/app/src/test/java/com/urik/keyboard/service/SpellCheckManagerSpatialTest.kt b/app/src/test/java/com/urik/keyboard/service/SpellCheckManagerSpatialTest.kt index ed995119..f3edcb35 100644 --- a/app/src/test/java/com/urik/keyboard/service/SpellCheckManagerSpatialTest.kt +++ b/app/src/test/java/com/urik/keyboard/service/SpellCheckManagerSpatialTest.kt @@ -47,6 +47,7 @@ class SpellCheckManagerSpatialTest { private lateinit var wordLearningEngine: WordLearningEngine private lateinit var wordFrequencyRepository: WordFrequencyRepository private lateinit var cacheMemoryManager: CacheMemoryManager + private lateinit var blacklistRepository: BlacklistRepository private lateinit var wordNormalizer: WordNormalizer private lateinit var keyPositionsFlow: MutableStateFlow> @@ -99,6 +100,10 @@ class SpellCheckManagerSpatialTest { onBlocking { getFrequencies(any(), any()) } doReturn emptyMap() } + blacklistRepository = mock { + onBlocking { getAll() } doReturn emptySet() + } + val suggestionCache = ManagedCache>( name = "test_suggestions", maxSize = 500, @@ -143,6 +148,7 @@ class SpellCheckManagerSpatialTest { wordLearningEngine = wordLearningEngine, wordFrequencyRepository = wordFrequencyRepository, cacheMemoryManager = cacheMemoryManager, + blacklistRepository = blacklistRepository, ioDispatcher = testDispatcher, wordNormalizer = wordNormalizer ) diff --git a/app/src/test/java/com/urik/keyboard/service/SpellCheckManagerTest.kt b/app/src/test/java/com/urik/keyboard/service/SpellCheckManagerTest.kt index 5326f4dd..73692e85 100644 --- a/app/src/test/java/com/urik/keyboard/service/SpellCheckManagerTest.kt +++ b/app/src/test/java/com/urik/keyboard/service/SpellCheckManagerTest.kt @@ -55,6 +55,7 @@ class SpellCheckManagerTest { private lateinit var wordLearningEngine: WordLearningEngine private lateinit var wordFrequencyRepository: WordFrequencyRepository private lateinit var cacheMemoryManager: CacheMemoryManager + private lateinit var blacklistRepository: BlacklistRepository private lateinit var wordNormalizer: WordNormalizer private lateinit var suggestionCache: ManagedCache> private lateinit var dictionaryCache: ManagedCache @@ -110,6 +111,13 @@ class SpellCheckManagerTest { this 5000 that's 3000 thats 50 + yall 75 + y'all 15000 + maam 10 + ma'am 48000 + cmon 15 + c'mon 6000 + ill 5000 join 500 in 8000 best 300000 @@ -143,6 +151,11 @@ class SpellCheckManagerTest { onBlocking { getFrequencies(any(), any()) } doReturn emptyMap() } + blacklistRepository = + mock { + onBlocking { getAll() } doReturn emptySet() + } + suggestionCache = ManagedCache( name = "test_suggestions", @@ -198,6 +211,7 @@ class SpellCheckManagerTest { wordLearningEngine = wordLearningEngine, wordFrequencyRepository = wordFrequencyRepository, cacheMemoryManager = cacheMemoryManager, + blacklistRepository = blacklistRepository, ioDispatcher = testDispatcher, wordNormalizer = wordNormalizer ) @@ -490,6 +504,23 @@ class SpellCheckManagerTest { ) } + @Test + fun `getSpellingSuggestionsWithConfidence penalizes short single-deletion candidates`() = runTest { + whenever(wordLearningEngine.getSimilarLearnedWordsWithFrequency(any(), any(), any())) + .thenReturn(emptyList()) + + // "win" -> "in" is a single-character-deletion dictionary entry + // (ratio 2/3 ~= 0.667). It must be penalized below 0.2 confidence. + val suggestions = spellCheckManager.getSpellingSuggestionsWithConfidence("win") + val shortDeletion = suggestions.find { it.word == "in" } + + assertNotNull("'in' should appear as a candidate for 'win'", shortDeletion) + assertTrue( + "Short single-deletion candidate 'in' should be penalized, got confidence ${shortDeletion!!.confidence}", + shortDeletion.confidence < 0.2 + ) + } + @Test fun `queryCompletionSuggestions filters low frequency completions relative to typed prefix`() = runTest { whenever(wordLearningEngine.getSimilarLearnedWordsWithFrequency(any(), any(), any())) @@ -603,6 +634,41 @@ class SpellCheckManagerTest { assertEquals(null, dictionaryCache.getIfPresent("en_word")) } + @Test + fun `blacklistSuggestion persists word via blacklistRepository`() = runTest { + spellCheckManager.blacklistSuggestion("badword") + + verify(blacklistRepository).add("badword") + } + + @Test + fun `initialization loads persisted blacklist so word is blacklisted before any new call`() = runTest { + whenever(blacklistRepository.getAll()).thenReturn(setOf("oldbad")) + + val restartedManager = + SpellCheckManager( + context = context, + languageManager = languageManager, + wordLearningEngine = wordLearningEngine, + wordFrequencyRepository = wordFrequencyRepository, + cacheMemoryManager = cacheMemoryManager, + blacklistRepository = blacklistRepository, + ioDispatcher = testDispatcher, + wordNormalizer = wordNormalizer + ) + + assertTrue(restartedManager.isWordBlacklisted("oldbad")) + } + + @Test + fun `removeFromBlacklist deletes the persisted entry`() = runTest { + spellCheckManager.blacklistSuggestion("word") + + spellCheckManager.removeFromBlacklist("word") + + verify(blacklistRepository).remove("word") + } + @Test fun `blacklisted words filtered from suggestions`() = runTest { spellCheckManager.blacklistSuggestion("offensive") @@ -720,6 +786,7 @@ class SpellCheckManagerTest { wordFrequencyRepository, wordNormalizer, cacheMemoryManager, + blacklistRepository, testDispatcher ) val words = failingManager.getCommonWords() @@ -1092,6 +1159,7 @@ class SpellCheckManagerTest { wordFrequencyRepository = wordFrequencyRepository, wordNormalizer = wordNormalizer, cacheMemoryManager = cacheMemoryManager, + blacklistRepository = blacklistRepository, ioDispatcher = testDispatcher ) @@ -1125,6 +1193,7 @@ class SpellCheckManagerTest { wordFrequencyRepository = wordFrequencyRepository, wordNormalizer = wordNormalizer, cacheMemoryManager = cacheMemoryManager, + blacklistRepository = blacklistRepository, ioDispatcher = testDispatcher ) @@ -1181,6 +1250,7 @@ class SpellCheckManagerTest { wordFrequencyRepository = wordFrequencyRepository, wordNormalizer = wordNormalizer, cacheMemoryManager = cacheMemoryManager, + blacklistRepository = blacklistRepository, ioDispatcher = testDispatcher ) @@ -1269,19 +1339,28 @@ class SpellCheckManagerTest { @Test fun `hasDominantContractionForm returns true when contraction is 20x more frequent`() = runTest { spellCheckManager.isWordInDictionary("hello") - assertTrue(spellCheckManager.hasDominantContractionForm("hows")) + assertTrue(spellCheckManager.hasDominantContractionForm("yall")) } @Test - fun `hasDominantContractionForm returns true for cant vs can't`() = runTest { + fun `hasDominantContractionForm returns true at very high dominance ratio`() = runTest { spellCheckManager.isWordInDictionary("hello") - assertTrue(spellCheckManager.hasDominantContractionForm("cant")) + assertTrue(spellCheckManager.hasDominantContractionForm("maam")) } @Test - fun `hasDominantContractionForm returns true for wont vs won't`() = runTest { + fun `hasDominantContractionForm returns true at moderate dominance ratio`() = runTest { spellCheckManager.isWordInDictionary("hello") - assertTrue(spellCheckManager.hasDominantContractionForm("wont")) + assertTrue(spellCheckManager.hasDominantContractionForm("cmon")) + } + + @Test + fun `removelisted bare form is filtered from dictionary`() = runTest { + assertFalse(spellCheckManager.isWordInDictionary("cant")) + assertFalse(spellCheckManager.isWordInDictionary("hows")) + assertFalse(spellCheckManager.isWordInDictionary("thats")) + assertTrue(spellCheckManager.isWordInDictionary("can't")) + assertTrue(spellCheckManager.isWordInDictionary("how's")) } @Test @@ -1318,9 +1397,9 @@ class SpellCheckManagerTest { @Test fun `getDominantContractionForm returns contraction when dominant`() = runTest { spellCheckManager.isWordInDictionary("hello") - assertEquals("how's", spellCheckManager.getDominantContractionForm("hows")) - assertEquals("can't", spellCheckManager.getDominantContractionForm("cant")) - assertEquals("won't", spellCheckManager.getDominantContractionForm("wont")) + assertEquals("y'all", spellCheckManager.getDominantContractionForm("yall")) + assertEquals("ma'am", spellCheckManager.getDominantContractionForm("maam")) + assertEquals("c'mon", spellCheckManager.getDominantContractionForm("cmon")) } @Test @@ -1348,18 +1427,18 @@ class SpellCheckManagerTest { @Test fun `contraction distance reduction gives distance-2 contraction competitive confidence`() = runTest { spellCheckManager.isWordInDictionary("hello") - val suggestions = spellCheckManager.getSpellingSuggestionsWithConfidence("thts") + val suggestions = spellCheckManager.getSpellingSuggestionsWithConfidence("yll") - val thatsConfidence = suggestions.find { it.word == "that's" }?.confidence - val thisConfidence = suggestions.find { it.word == "this" }?.confidence + val yallConfidence = suggestions.find { it.word == "y'all" }?.confidence + val illConfidence = suggestions.find { it.word == "ill" }?.confidence - assertNotNull("that's should appear in suggestions for thts", thatsConfidence) - assertNotNull("this should appear in suggestions for thts", thisConfidence) + assertNotNull("y'all should appear in suggestions for yll", yallConfidence) + assertNotNull("ill should appear in suggestions for yll", illConfidence) - if (thatsConfidence != null && thisConfidence != null) { - val gap = thisConfidence - thatsConfidence + if (yallConfidence != null && illConfidence != null) { + val gap = illConfidence - yallConfidence assertTrue( - "that's should be within 0.05 of this (gap=$gap), proving distance reduction worked", + "y'all should be within 0.05 of ill (gap=$gap), proving distance reduction worked", gap < 0.05 ) } @@ -1414,15 +1493,15 @@ class SpellCheckManagerTest { spellCheckManager.isWordInDictionary("hello") assertTrue( - "With no user frequency, hows should have dominant contraction", - spellCheckManager.hasDominantContractionForm("hows") + "With no user frequency, yall should have dominant contraction", + spellCheckManager.hasDominantContractionForm("yall") ) - whenever(wordFrequencyRepository.getFrequency(eq("hows"), any())).thenReturn(15) + whenever(wordFrequencyRepository.getFrequency(eq("yall"), any())).thenReturn(15) assertFalse( - "With user frequency boosting hows, contraction dominance should be suppressed", - spellCheckManager.hasDominantContractionForm("hows") + "With user frequency boosting yall, contraction dominance should be suppressed", + spellCheckManager.hasDominantContractionForm("yall") ) } @@ -1430,11 +1509,11 @@ class SpellCheckManagerTest { fun `low user frequency does not suppress contraction dominance`() = runTest { spellCheckManager.isWordInDictionary("hello") - whenever(wordFrequencyRepository.getFrequency(eq("hows"), any())).thenReturn(1) + whenever(wordFrequencyRepository.getFrequency(eq("yall"), any())).thenReturn(1) assertTrue( "Low user frequency should not suppress contraction dominance", - spellCheckManager.hasDominantContractionForm("hows") + spellCheckManager.hasDominantContractionForm("yall") ) } @@ -1471,6 +1550,7 @@ class SpellCheckManagerTest { wordFrequencyRepository = wordFrequencyRepository, wordNormalizer = wordNormalizer, cacheMemoryManager = cacheMemoryManager, + blacklistRepository = blacklistRepository, ioDispatcher = testDispatcher ) @@ -1518,11 +1598,12 @@ class SpellCheckManagerTest { wordLearningEngine = wordLearningEngine, wordFrequencyRepository = wordFrequencyRepository, cacheMemoryManager = cacheMemoryManager, + blacklistRepository = blacklistRepository, ioDispatcher = neverDispatcher, wordNormalizer = wordNormalizer ) - kotlinx.coroutines.test.runTest(standardDispatcher) { + runTest(standardDispatcher) { launch { timeoutManager.generateSuggestions("hello") } advanceTimeBy(5001L) runCurrent() @@ -1546,6 +1627,7 @@ class SpellCheckManagerTest { wordFrequencyRepository = wordFrequencyRepository, wordNormalizer = wordNormalizer, cacheMemoryManager = cacheMemoryManager, + blacklistRepository = blacklistRepository, ioDispatcher = testDispatcher ) @@ -1574,6 +1656,7 @@ class SpellCheckManagerTest { wordLearningEngine = wordLearningEngine, wordFrequencyRepository = wordFrequencyRepository, cacheMemoryManager = cacheMemoryManager, + blacklistRepository = blacklistRepository, ioDispatcher = testDispatcher, wordNormalizer = wordNormalizer, fatFingerExpander = fatFingerExpander @@ -1615,6 +1698,7 @@ class SpellCheckManagerTest { wordLearningEngine = wordLearningEngine, wordFrequencyRepository = wordFrequencyRepository, cacheMemoryManager = cacheMemoryManager, + blacklistRepository = blacklistRepository, ioDispatcher = testDispatcher, wordNormalizer = wordNormalizer, fatFingerExpander = fatFingerExpander @@ -1648,6 +1732,7 @@ class SpellCheckManagerTest { wordLearningEngine = wordLearningEngine, wordFrequencyRepository = wordFrequencyRepository, cacheMemoryManager = cacheMemoryManager, + blacklistRepository = blacklistRepository, ioDispatcher = testDispatcher, wordNormalizer = wordNormalizer, fatFingerExpander = fatFingerExpander @@ -1717,6 +1802,7 @@ class SpellCheckManagerTest { wordLearningEngine = wordLearningEngine, wordFrequencyRepository = wordFrequencyRepository, cacheMemoryManager = cacheMemoryManager, + blacklistRepository = blacklistRepository, ioDispatcher = testDispatcher, wordNormalizer = wordNormalizer, fatFingerExpander = fatFingerExpander diff --git a/app/src/test/java/com/urik/keyboard/service/SuggestionPipelineTest.kt b/app/src/test/java/com/urik/keyboard/service/SuggestionPipelineTest.kt index 2b9f5f0d..95876847 100644 --- a/app/src/test/java/com/urik/keyboard/service/SuggestionPipelineTest.kt +++ b/app/src/test/java/com/urik/keyboard/service/SuggestionPipelineTest.kt @@ -122,7 +122,7 @@ class SuggestionPipelineTest { ) } - private inner class FakeSuggestionPipelineHost : SuggestionPipelineHost { + private class FakeSuggestionPipelineHost : SuggestionPipelineHost { override fun showSuggestions(): Boolean = true override fun effectiveSuggestionCount(): Int = 3 override fun getKeyboardState(): KeyboardState = KeyboardState() @@ -353,7 +353,7 @@ class SuggestionPipelineTest { assertEquals(listOf("Hello"), result) } - private inner class FakeJapanesePipelineHost : SuggestionPipelineHost { + private class FakeJapanesePipelineHost : SuggestionPipelineHost { override fun showSuggestions(): Boolean = true override fun effectiveSuggestionCount(): Int = 5 override fun getKeyboardState(): KeyboardState = KeyboardState() @@ -457,6 +457,40 @@ class SuggestionPipelineTest { assert(suggestions.contains("カ")) { "katakana カ must be present" } } + @Test + fun `requestJapaneseSuggestions excludes blacklisted conversion candidate but keeps hiragana and katakana`() = + runTest(testDispatcher) { + val japanesePipeline = SuggestionPipeline( + state = inputState, + outputBridge = outputBridge, + textInputProcessor = mockTextInputProcessor, + spellCheckManager = mockSpellCheckManager, + wordLearningEngine = mockWordLearningEngine, + wordFrequencyRepository = mockWordFrequencyRepository, + languageManager = mockLanguageManager, + caseTransformer = mockCaseTransformer, + scriptConverterRegistry = mockScriptConverterRegistry, + serviceScope = kotlinx.coroutines.CoroutineScope(testDispatcher), + host = FakeJapanesePipelineHost() + ) + japanesePipeline.setJapaneseLayout(true) + + val mockConverter = mock() + whenever(mockScriptConverterRegistry.forLanguage("ja")).thenReturn(mockConverter) + whenever(mockConverter.getCandidates("か", "ja")).thenReturn( + listOf(ConversionCandidate(surface = "化", reading = "か", frequency = 19992, source = "dictionary")) + ) + whenever(mockSpellCheckManager.getSpellingSuggestionsWithConfidence("か")).thenReturn(emptyList()) + whenever(mockSpellCheckManager.isWordBlacklisted("化")).thenReturn(true) + + inputState.updateDisplayBuffer("か") + japanesePipeline.requestSuggestions("か", InputMethod.TYPED) + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(false, capturedSuggestions.contains("化")) + assertEquals(listOf("か", "カ"), capturedSuggestions.take(2)) + } + @Test fun `requestSuggestions isSuggestionsDisabled emits no suggestions`() = runTest(testDispatcher) { inputState.isSuggestionsDisabled = true diff --git a/app/src/test/java/com/urik/keyboard/service/TextInputProcessorTest.kt b/app/src/test/java/com/urik/keyboard/service/TextInputProcessorTest.kt index 0a5452a9..0dd6d8ae 100644 --- a/app/src/test/java/com/urik/keyboard/service/TextInputProcessorTest.kt +++ b/app/src/test/java/com/urik/keyboard/service/TextInputProcessorTest.kt @@ -6,6 +6,7 @@ import android.icu.lang.UScript import android.icu.util.ULocale import com.urik.keyboard.settings.KeyboardSettings import com.urik.keyboard.settings.SettingsRepository +import com.urik.keyboard.utils.ErrorLogger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -359,6 +360,24 @@ class TextInputProcessorTest { verify(spellCheckManager).getSpellingSuggestionsWithConfidence("hel") } + @Test + fun `removeSuggestion drops the entire suggestion cache so removed word is not served stale`() = runTest { + whenever(spellCheckManager.getSpellingSuggestionsWithConfidence("helo")) + .thenReturn(createSuggestions("hello", "help")) + whenever(spellCheckManager.removeSuggestion("hello")).thenReturn(Result.success(true)) + + val before = processor.getSuggestions("helo") + assertTrue("hello" in before.map { it.word }) + + whenever(spellCheckManager.getSpellingSuggestionsWithConfidence("helo")) + .thenReturn(createSuggestions("help")) + + processor.removeSuggestion("hello") + + val after = processor.getSuggestions("helo") + assertFalse("hello" in after.map { it.word }) + } + @Test fun `validateWord returns true for dictionary word`() = runTest { whenever(spellCheckManager.isWordInDictionary("hello")).thenReturn(true) @@ -398,6 +417,19 @@ class TextInputProcessorTest { assertTrue(result is ProcessingResult.Error) } + @Test + fun `processing exception is logged to ErrorLogger`() = runTest { + val thrown = RuntimeException("Dictionary error") + whenever(spellCheckManager.isWordInDictionary(any())).thenThrow(thrown) + val countBefore = ErrorLogger.getErrorCount() + + val result = processor.processCharacterInput("o", "hello", InputMethod.TYPED) + + assertTrue(result is ProcessingResult.Error) + assertEquals(thrown, (result as ProcessingResult.Error).exception) + assertEquals(countBefore + 1, ErrorLogger.getErrorCount()) + } + @Test fun `getSuggestions handles exception gracefully`() = runTest { whenever(spellCheckManager.getSpellingSuggestionsWithConfidence(any())) diff --git a/app/src/test/java/com/urik/keyboard/ui/keyboard/components/SwipeKeyboardViewOverlayTouchTest.kt b/app/src/test/java/com/urik/keyboard/ui/keyboard/components/SwipeKeyboardViewOverlayTouchTest.kt new file mode 100644 index 00000000..446fc2a1 --- /dev/null +++ b/app/src/test/java/com/urik/keyboard/ui/keyboard/components/SwipeKeyboardViewOverlayTouchTest.kt @@ -0,0 +1,286 @@ +package com.urik.keyboard.ui.keyboard.components + +import android.os.Looper +import android.view.MotionEvent +import android.view.WindowManager +import android.widget.TextView +import com.urik.keyboard.R +import com.urik.keyboard.model.KeyboardKey +import com.urik.keyboard.model.KeyboardLayout +import com.urik.keyboard.model.KeyboardMode +import com.urik.keyboard.model.KeyboardState +import com.urik.keyboard.service.CharacterVariationService +import com.urik.keyboard.service.EmojiSearchManager +import com.urik.keyboard.service.LanguageManager +import com.urik.keyboard.service.RecentEmojiProvider +import com.urik.keyboard.service.SpellCheckManager +import com.urik.keyboard.service.WordLearningEngine +import com.urik.keyboard.theme.Default +import com.urik.keyboard.theme.KeyboardTheme +import com.urik.keyboard.theme.ThemeManager +import com.urik.keyboard.utils.CacheMemoryManager +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class SwipeKeyboardViewOverlayTouchTest { + private lateinit var view: SwipeKeyboardView + private lateinit var swipeDetector: SwipeDetector + + @Before + fun setup() { + val context = RuntimeEnvironment.getApplication() + + val languageManager = mock() + whenever(languageManager.currentLayoutLanguage).thenReturn(MutableStateFlow("en")) + + val themeManager = mock() + whenever(themeManager.currentTheme).thenReturn(MutableStateFlow(Default)) + + val cacheMemoryManager = CacheMemoryManager(context) + val jobField = CacheMemoryManager::class.java.getDeclaredField("memoryMonitoringJob") + jobField.isAccessible = true + (jobField.get(cacheMemoryManager) as? Job)?.cancel() + + val layoutManager = KeyboardLayoutManager( + context = context, + onKeyClick = {}, + onAcceleratedDeletionChanged = {}, + onSymbolsLongPress = {}, + characterVariationService = mock(), + languageManager = languageManager, + themeManager = themeManager, + cacheMemoryManager = cacheMemoryManager + ) + layoutManager.updateHapticSettings(enabled = false, amplitude = 0) + + swipeDetector = mock() + + view = SwipeKeyboardView(context) + view.initialize( + layoutManager, + swipeDetector, + mock(), + mock(), + themeManager, + languageManager, + mock(), + mock() + ) + + val layout = KeyboardLayout( + mode = KeyboardMode.LETTERS, + rows = listOf((0 until 9).map { KeyboardKey.Character("a", KeyboardKey.KeyType.LETTER) }) + ) + view.updateKeyboard(layout, KeyboardState()) + view.updateSuggestions(listOf("badword")) + } + + private fun findSuggestionView(): TextView { + val suggestionBarField = SwipeKeyboardView::class.java.getDeclaredField("suggestionBar") + suggestionBarField.isAccessible = true + val bar = suggestionBarField.get(view) as android.widget.LinearLayout + for (i in 0 until bar.childCount) { + val child = bar.getChildAt(i) + if (child is TextView && child.getTag(R.id.suggestion_text) == "badword") { + return child + } + } + error("suggestion view for 'badword' not found") + } + + private fun confirmationOverlay(): android.widget.FrameLayout? { + val field = SwipeKeyboardView::class.java.getDeclaredField("confirmationOverlay") + field.isAccessible = true + return field.get(view) as? android.widget.FrameLayout + } + + private fun showRemovalConfirmation() { + findSuggestionView().performLongClick() + assertNotNull("confirmationOverlay should be set after long press", confirmationOverlay()) + } + + private fun motionEvent(action: Int, x: Float = 50f, y: Float = 50f): MotionEvent = + MotionEvent.obtain(0L, 0L, action, x, y, 0) + + @Test + fun `onInterceptTouchEvent returns false and does not feed swipeDetector when overlay shown`() { + showRemovalConfirmation() + clearInvocations(swipeDetector) + + val down = motionEvent(MotionEvent.ACTION_DOWN) + val move = motionEvent(MotionEvent.ACTION_MOVE) + val up = motionEvent(MotionEvent.ACTION_UP) + try { + assertFalse(view.onInterceptTouchEvent(down)) + assertFalse(view.onInterceptTouchEvent(move)) + assertFalse(view.onInterceptTouchEvent(up)) + } finally { + down.recycle() + move.recycle() + up.recycle() + } + + verify(swipeDetector, never()).handleTouchEvent(any(), any()) + } + + @Test + fun `onTouchEvent returns false when overlay shown`() { + showRemovalConfirmation() + + val down = motionEvent(MotionEvent.ACTION_DOWN) + try { + assertFalse(view.onTouchEvent(down)) + } finally { + down.recycle() + } + } + + @Test + fun `dispatchTouchEvent routes to overlay children even with stale gesture state`() { + showRemovalConfirmation() + + val isSwipeActiveField = SwipeKeyboardView::class.java.getDeclaredField("isSwipeActive") + isSwipeActiveField.isAccessible = true + isSwipeActiveField.setBoolean(view, true) + + val windowManager = view.context.getSystemService(android.content.Context.WINDOW_SERVICE) as WindowManager + windowManager.addView(view, WindowManager.LayoutParams(1080, 2400)) + shadowOf(Looper.getMainLooper()).idle() + + val widthSpec = android.view.View.MeasureSpec.makeMeasureSpec(1080, android.view.View.MeasureSpec.EXACTLY) + val heightSpec = android.view.View.MeasureSpec.makeMeasureSpec(2400, android.view.View.MeasureSpec.EXACTLY) + view.measure(widthSpec, heightSpec) + view.layout(0, 0, 1080, 2400) + + val overlay = confirmationOverlay()!! + val removeButton = findButtonByText(overlay, "Remove") + + val viewLocation = IntArray(2) + view.getLocationOnScreen(viewLocation) + val buttonLocation = IntArray(2) + removeButton.getLocationOnScreen(buttonLocation) + val x = buttonLocation[0] - viewLocation[0] + removeButton.width / 2f + val y = buttonLocation[1] - viewLocation[1] + removeButton.height / 2f + + val down = motionEvent(MotionEvent.ACTION_DOWN, x, y) + val up = motionEvent(MotionEvent.ACTION_UP, x, y) + try { + view.dispatchTouchEvent(down) + assertTrue("Remove button should receive the DOWN through the overlay", removeButton.isPressed) + view.dispatchTouchEvent(up) + } finally { + down.recycle() + up.recycle() + } + shadowOf(Looper.getMainLooper()).idle() + + assertNull("confirmationOverlay should be cleared after Remove is tapped", confirmationOverlay()) + } + + @Test + fun `Cancel button click hides overlay`() { + showRemovalConfirmation() + + val overlay = confirmationOverlay()!! + val cancelButton = findButtonByText(overlay, "Cancel") + + cancelButton.performClick() + + assertNull("confirmationOverlay should be cleared after Cancel is clicked", confirmationOverlay()) + } + + @Test + fun `showRemovalConfirmation flushes in-flight gesture state`() { + val down = motionEvent(MotionEvent.ACTION_DOWN) + try { + view.onTouchEvent(down) + } finally { + down.recycle() + } + + showRemovalConfirmation() + + verify(swipeDetector).handleTouchEvent( + argThat { action == MotionEvent.ACTION_CANCEL }, + any() + ) + + val isSwipeActiveField = SwipeKeyboardView::class.java.getDeclaredField("isSwipeActive") + isSwipeActiveField.isAccessible = true + assertFalse(isSwipeActiveField.getBoolean(view)) + + val hasTouchStartField = SwipeKeyboardView::class.java.getDeclaredField("hasTouchStart") + hasTouchStartField.isAccessible = true + assertFalse(hasTouchStartField.getBoolean(view)) + } + + @Test + fun `updateKeyboard while overlay shown removes overlay and resets pending suggestion`() { + showRemovalConfirmation() + + val layout = KeyboardLayout( + mode = KeyboardMode.LETTERS, + rows = listOf((0 until 9).map { KeyboardKey.Character("a", KeyboardKey.KeyType.LETTER) }) + ) + view.updateKeyboard(layout, KeyboardState()) + + assertNull("confirmationOverlay should be cleared after keyboard rebuild", confirmationOverlay()) + + val pendingField = SwipeKeyboardView::class.java.getDeclaredField("pendingRemovalSuggestion") + pendingField.isAccessible = true + assertNull(pendingField.get(view)) + + val swipeOverlayField = SwipeKeyboardView::class.java.getDeclaredField("swipeOverlay") + swipeOverlayField.isAccessible = true + val swipeOverlay = swipeOverlayField.get(view) as android.view.View + assertTrue("swipeOverlay must remain attached after rebuild", swipeOverlay.parent === view) + } + + @Test + fun `showRemovalConfirmation works again after keyboard rebuild`() { + showRemovalConfirmation() + + val layout = KeyboardLayout( + mode = KeyboardMode.LETTERS, + rows = listOf((0 until 9).map { KeyboardKey.Character("a", KeyboardKey.KeyType.LETTER) }) + ) + view.updateKeyboard(layout, KeyboardState()) + view.updateSuggestions(listOf("badword")) + + showRemovalConfirmation() + } + + private fun findButtonByText(group: android.view.ViewGroup, text: String): android.widget.Button { + for (i in 0 until group.childCount) { + val child = group.getChildAt(i) + if (child is android.widget.Button && child.text.toString() == text) { + return child + } + if (child is android.view.ViewGroup) { + val found = runCatching { findButtonByText(child, text) }.getOrNull() + if (found != null) return found + } + } + error("Button with text '$text' not found") + } +} diff --git a/app/src/test/java/com/urik/keyboard/utils/SelectionStateTrackerTest.kt b/app/src/test/java/com/urik/keyboard/utils/SelectionStateTrackerTest.kt index c2cb2ead..d6d8db41 100644 --- a/app/src/test/java/com/urik/keyboard/utils/SelectionStateTrackerTest.kt +++ b/app/src/test/java/com/urik/keyboard/utils/SelectionStateTrackerTest.kt @@ -259,7 +259,7 @@ class SelectionStateTrackerTest { fun `requiresStateInvalidation returns true for destructive results`() { assertTrue(SelectionChangeResult.NonSequentialJump(0, 100, 100).requiresStateInvalidation()) assertTrue(SelectionChangeResult.ComposingRegionLost.requiresStateInvalidation()) - assertTrue(SelectionChangeResult.CursorLeftComposingRegion(0, 10).requiresStateInvalidation()) + assertTrue(SelectionChangeResult.CursorLeftComposingRegion.requiresStateInvalidation()) } @Test diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 762219f1..5a1bf1f0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,10 +7,10 @@ coreTesting = "2.2.0" datastorePreferences = "1.2.1" emoji2Emojipicker = "1.6.0" hiltAndroid = "2.59.2" -coreKtx = "1.18.0" +coreKtx = "1.19.0" testCoreKtx = "1.7.0" junit = "4.13.2" -kotlin = "2.3.21" +kotlin = "2.4.0" kotlinxCoroutinesAndroid = "1.11.0" kotlinxCoroutinesTest = "1.11.0" kotlinxSerializationJson = "1.11.0" From 3700a7985b640db76370a2e74592ec7585f04ed3 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 11 Jun 2026 16:47:01 -0500 Subject: [PATCH 2/2] Downgrade kotlin and coreKtx --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5a1bf1f0..762219f1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,10 +7,10 @@ coreTesting = "2.2.0" datastorePreferences = "1.2.1" emoji2Emojipicker = "1.6.0" hiltAndroid = "2.59.2" -coreKtx = "1.19.0" +coreKtx = "1.18.0" testCoreKtx = "1.7.0" junit = "4.13.2" -kotlin = "2.4.0" +kotlin = "2.3.21" kotlinxCoroutinesAndroid = "1.11.0" kotlinxCoroutinesTest = "1.11.0" kotlinxSerializationJson = "1.11.0"