Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions app/src/main/java/com/urik/keyboard/UrikApplication.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -43,6 +47,10 @@ class UrikApplication : Application() {
context = mapOf("thread" to thread.name)
)

if (isDatabaseCorruptionException(throwable)) {
recoverFromDatabaseCorruption()
}

previousHandler?.uncaughtException(thread, throwable)
}

Expand Down Expand Up @@ -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
Expand Down
10 changes: 1 addition & 9 deletions app/src/main/java/com/urik/keyboard/UrikInputMethodService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -483,7 +480,6 @@ open class UrikInputMethodService :
outputBridge = outputBridge,
suggestionPipeline = suggestionPipeline,
autoCorrectionEngine = autoCorrectionEngine,
textInputProcessor = textInputProcessor,
swipeSpaceManager = swipeSpaceManager,
swipeDetector = swipeDetector,
candidateBarController = candidateBarController,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}

Expand Down
8 changes: 2 additions & 6 deletions app/src/main/java/com/urik/keyboard/di/DatabaseModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ object DatabaseModule {
@ApplicationContext context: Context,
securityManager: DatabaseSecurityManager
): KeyboardDatabase {
var passphrase: ByteArray? = null
var alreadyLogged = false
try {
if (securityManager.shouldMigrateToEncrypted(context)) {
Expand All @@ -86,7 +85,7 @@ object DatabaseModule {
}
}

passphrase =
val passphrase =
try {
securityManager.getDatabasePassphrase()
} catch (e: Exception) {
Expand All @@ -102,7 +101,6 @@ object DatabaseModule {

val passphraseWasAvailable = passphrase != null
val initialPassphrase = passphrase
passphrase = null
return try {
opener.open(context, initialPassphrase)
} catch (e: Exception) {
Expand Down Expand Up @@ -184,8 +182,6 @@ object DatabaseModule {
initialPassphrase?.fill(0)
}
} catch (e: Exception) {
passphrase?.fill(0)

if (!alreadyLogged) {
ErrorLogger.logException(
component = "DatabaseModule",
Expand All @@ -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()) {
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/com/urik/keyboard/di/KeyboardModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -142,6 +143,7 @@ object KeyboardModule {
wordLearningEngine: WordLearningEngine,
wordFrequencyRepository: WordFrequencyRepository,
cacheMemoryManager: CacheMemoryManager,
blacklistRepository: BlacklistRepository,
wordNormalizer: WordNormalizer,
fatFingerExpander: FatFingerExpander
): SpellCheckManager = SpellCheckManager(
Expand All @@ -151,6 +153,7 @@ object KeyboardModule {
wordFrequencyRepository,
wordNormalizer,
cacheMemoryManager,
blacklistRepository,
fatFingerExpander = fatFingerExpander
)

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Set<String>> = 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<String> = REMOVELIST[languageCode] ?: emptySet()
}
17 changes: 14 additions & 3 deletions app/src/main/java/com/urik/keyboard/dictionary/UrikDictionary.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = emptySet()) {
private val data: ByteArray = inputStream.readBytes()

val wordCount: Int
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SpellingSuggestion>) : AutocorrectDecision()
data class ContractionBypass(val suggestions: List<SpellingSuggestion>) : AutocorrectDecision()
data class Suggestions(val list: List<SpellingSuggestion>) : AutocorrectDecision()
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
46 changes: 46 additions & 0 deletions app/src/main/java/com/urik/keyboard/service/BlacklistRepository.kt
Original file line number Diff line number Diff line change
@@ -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<Preferences>) {
@Inject
constructor(
@ApplicationContext context: Context
) : this(context.blacklistDataStore)

private object PreferenceKeys {
val BLACKLISTED_WORDS = stringSetPreferencesKey("blacklisted_words")
}

suspend fun getAll(): Set<String> = 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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ class ImeStateCoordinator(
outputBridge.finishComposingText()

spellCheckManager.clearCaches()
spellCheckManager.clearBlacklist()
textInputProcessor.clearCaches()
wordLearningEngine.clearCurrentLanguageCache()

Expand Down
Loading