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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100,002 changes: 100,002 additions & 0 deletions app/src/main/assets/dictionaries/en_US.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ class SettingsActivity : SimpleActivity() {
setupShowClipboardContent()
setupSentencesCapitalization()
setupShowNumbersRow()
setupShowSuggestions()
setupAutoCorrect()
setupVoiceInputMethod()

binding.apply {
Expand Down Expand Up @@ -283,4 +285,24 @@ class SettingsActivity : SimpleActivity() {
}
}
}

private fun setupShowSuggestions() {
binding.apply {
settingsShowSuggestions.isChecked = config.showSuggestions
settingsShowSuggestionsHolder.setOnClickListener {
settingsShowSuggestions.toggle()
config.showSuggestions = settingsShowSuggestions.isChecked
}
}
}

private fun setupAutoCorrect() {
binding.apply {
settingsAutoCorrect.isChecked = config.autoCorrectOnSpace
settingsAutoCorrectHolder.setOnClickListener {
settingsAutoCorrect.toggle()
config.autoCorrectOnSpace = settingsAutoCorrect.isChecked
}
}
}
}
8 changes: 8 additions & 0 deletions app/src/main/kotlin/org/fossify/keyboard/helpers/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,12 @@ class Config(context: Context) : BaseConfig(context) {
recentEmojis.add(0, emoji)
recentlyUsedEmojis = recentEmojis.take(RECENT_EMOJIS_LIMIT)
}

var showSuggestions: Boolean
get() = prefs.getBoolean(SHOW_SUGGESTIONS, true)
set(showSuggestions) = prefs.edit().putBoolean(SHOW_SUGGESTIONS, showSuggestions).apply()

var autoCorrectOnSpace: Boolean
get() = prefs.getBoolean(AUTO_CORRECT_ON_SPACE, true)
set(autoCorrectOnSpace) = prefs.edit().putBoolean(AUTO_CORRECT_ON_SPACE, autoCorrectOnSpace).apply()
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const val SHOW_NUMBERS_ROW = "show_numbers_row"
const val SELECTED_LANGUAGES = "selected_languages"
const val VOICE_INPUT_METHOD = "voice_input_method"
const val RECENTLY_USED_EMOJIS = "recently_used_emojis"
const val SHOW_SUGGESTIONS = "show_suggestions"
const val AUTO_CORRECT_ON_SPACE = "auto_correct_on_space"

// differentiate current and pinned clips at the keyboards' Clipboard section
const val ITEM_SECTION_LABEL = 0
Expand Down
151 changes: 151 additions & 0 deletions app/src/main/kotlin/org/fossify/keyboard/helpers/SpellChecker.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package org.fossify.keyboard.helpers

import android.content.Context
import android.os.Handler
import android.os.Looper
import org.fossify.commons.helpers.ensureBackgroundThread
import org.json.JSONArray
import org.json.JSONException
import java.io.IOException
import java.util.Collections
import kotlin.math.abs
import kotlin.math.min

private const val DEBOUNCE_DELAY_MS = 100L
private const val MAX_SUGGESTIONS = 3
private const val MAX_LENGTH_DIFF = 2
private const val MAX_EDIT_DISTANCE = 2
private const val MIN_WORD_LENGTH = 2
private const val CACHE_SIZE = 20

class SpellChecker(context: Context) {
@Volatile
private var dictionary: HashMap<String, Int>? = null
private val mainHandler = Handler(Looper.getMainLooper())
private var pendingRunnable: Runnable? = null
@Suppress("MagicNumber")
private val cache: MutableMap<String, List<String>> = Collections.synchronizedMap(
object : LinkedHashMap<String, List<String>>(CACHE_SIZE, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, List<String>>) = size > CACHE_SIZE
}
)

var onSuggestionsReady: ((List<String>) -> Unit)? = null

init {
ensureBackgroundThread {
dictionary = loadDictionary(context)
}
}

fun checkWord(word: String) {
pendingRunnable?.let { mainHandler.removeCallbacks(it) }

pendingRunnable = Runnable {
ensureBackgroundThread {
val suggestions = findSuggestions(word)
mainHandler.post {
onSuggestionsReady?.invoke(suggestions)
}
}
}

mainHandler.postDelayed(pendingRunnable!!, DEBOUNCE_DELAY_MS)
}

fun clear() {
pendingRunnable?.let { mainHandler.removeCallbacks(it) }
mainHandler.post {
onSuggestionsReady?.invoke(emptyList())
}
}

fun isValidWord(word: String): Boolean {
return dictionary?.containsKey(word.lowercase()) ?: true
}

fun destroy() {
pendingRunnable?.let { mainHandler.removeCallbacks(it) }
onSuggestionsReady = null
}

private fun loadDictionary(context: Context): HashMap<String, Int> {
val map = HashMap<String, Int>()
try {
val inputStream = context.assets.open("dictionaries/en_US.json")
val jsonString = inputStream.bufferedReader().use { it.readText() }
val jsonArray = JSONArray(jsonString)

for (i in 0 until jsonArray.length()) {
val entry = jsonArray.getJSONArray(i)
val word = entry.getString(0)
val rank = jsonArray.length() - i
map[word] = rank
}
} catch (_: IOException) {
} catch (_: JSONException) {
}
return map
}

private fun findSuggestions(input: String): List<String> {
val dict = dictionary ?: return emptyList()
if (input.length < MIN_WORD_LENGTH) return emptyList()

val inputLower = input.lowercase()

cache[inputLower]?.let { return it }

val prefixMatches = dict.keys
.filter { word -> word.startsWith(inputLower) && word != inputLower }
.sortedByDescending { dict[it] ?: 0 }
.take(MAX_SUGGESTIONS)

if (prefixMatches.isNotEmpty()) {
cache[inputLower] = prefixMatches
return prefixMatches
}

val firstChar = inputLower[0]
val inputLen = inputLower.length

val levenshteinMatches = dict.keys
.filter { word ->
word[0] == firstChar && abs(word.length - inputLen) <= MAX_LENGTH_DIFF
}
.map { word ->
word to levenshteinDistance(inputLower, word)
}
.filter { it.second <= MAX_EDIT_DISTANCE }
.sortedWith(compareBy({ it.second }, { -(dict[it.first] ?: 0) }))
.take(MAX_SUGGESTIONS)
.map { it.first }

cache[inputLower] = levenshteinMatches
return levenshteinMatches
}

private fun levenshteinDistance(a: String, b: String, maxDistance: Int = MAX_EDIT_DISTANCE): Int {
val m = a.length
val n = b.length
val dp = Array(m + 1) { IntArray(n + 1) }

for (i in 0..m) dp[i][0] = i
for (j in 0..n) dp[0][j] = j

for (i in 1..m) {
var rowMin = Int.MAX_VALUE
for (j in 1..n) {
val cost = if (a[i - 1] == b[j - 1]) 0 else 1
dp[i][j] = min(
min(dp[i - 1][j] + 1, dp[i][j - 1] + 1),
dp[i - 1][j - 1] + cost
)
rowMin = min(rowMin, dp[i][j])
}
if (rowMin > maxDistance) return maxDistance + 1
}

return dp[m][n]
}
}
Loading
Loading