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
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ dependencies {
implementation("dev.rikka.shizuku:api:13.1.5")
implementation("dev.rikka.shizuku:provider:13.1.5")
testImplementation(libs.junit)
testImplementation(libs.robolectric)
testImplementation("org.mockito:mockito-core:5.11.0")
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,71 @@ class AutoReplaceController(
}
// #endregion

data class ReplaceResult(val replaced: Boolean, val committed: Boolean)
public data class ReplaceResult(val replaced: Boolean, val committed: Boolean)

companion object {
internal data class ApostropheSplit(val prefix: String, val root: String)

internal fun normalizeApostrophes(input: String): String {
return input
.replace("’", "'")
.replace("‘", "'")
.replace("ʼ", "'")
}

internal fun stripAccents(input: String): String {
return Normalizer.normalize(input, Normalizer.Form.NFD)
.replace("\\p{Mn}".toRegex(), "")
}

/**
* Split a word with a single apostrophe into prefix (with apostrophe) and root.
* Language-agnostic: only checks structure/length, not locale.
*/
internal fun splitApostropheWord(word: String): ApostropheSplit? {
val normalized = normalizeApostrophes(word)
val apostropheCount = normalized.count { it == '\'' }
if (apostropheCount != 1) return null
val idx = normalized.indexOf('\'')
if (idx <= 0 || idx >= normalized.lastIndex) return null

val prefix = normalized.substring(0, idx + 1)
val root = normalized.substring(idx + 1)
val prefixRaw = prefix.dropLast(1)

val isPrefixOk = prefixRaw.isNotEmpty() &&
prefixRaw.length <= 3 &&
prefixRaw.all { it.isLetter() }
val isRootOk = root.length >= 3 && root.all { it.isLetter() }
return if (isPrefixOk && isRootOk) ApostropheSplit(prefix, root) else null
}

internal fun isAccentOnlyVariant(input: String, candidate: String): Boolean {
if (input.equals(candidate, ignoreCase = true)) return false
val normalizedInput = stripAccents(input.lowercase())
val normalizedCandidate = stripAccents(candidate.lowercase())
return normalizedInput == normalizedCandidate
}

internal fun recomposeApostropheCandidate(
split: ApostropheSplit,
candidate: String
): String? {
val prefix = split.prefix
val normalizedCandidate = normalizeApostrophes(candidate)
val hasApostrophe = normalizedCandidate.contains('\'')
val matchesPrefix = normalizedCandidate.length >= prefix.length &&
normalizedCandidate.substring(0, prefix.length).equals(prefix, ignoreCase = true)

val rootPart = when {
matchesPrefix -> candidate.substring(prefix.length)
hasApostrophe -> return null // don't mix different apostrophe prefixes
else -> candidate
}
val recasedRoot = CasingHelper.applyCasing(rootPart, split.root, forceLeadingCapital = false)
return prefix + recasedRoot
}
}

// Track last replacement for undo
private data class LastReplacement(
Expand All @@ -46,68 +110,6 @@ class AutoReplaceController(
// Track rejected words to avoid auto-correcting them again
private val rejectedWords = mutableSetOf<String>()

private data class ApostropheSplit(val prefix: String, val root: String)

private fun normalizeApostrophes(input: String): String {
return input
.replace("’", "'")
.replace("‘", "'")
.replace("ʼ", "'")
}

private fun recomposeApostropheCandidate(
split: ApostropheSplit,
candidate: String
): String? {
val prefix = split.prefix
val normalizedCandidate = normalizeApostrophes(candidate)
val hasApostrophe = normalizedCandidate.contains('\'')
val matchesPrefix = normalizedCandidate.length >= prefix.length &&
normalizedCandidate.substring(0, prefix.length).equals(prefix, ignoreCase = true)

val rootPart = when {
matchesPrefix -> candidate.substring(prefix.length)
hasApostrophe -> return null // don't mix different apostrophe prefixes
else -> candidate
}
val recasedRoot = CasingHelper.applyCasing(rootPart, split.root, forceLeadingCapital = false)
return prefix + recasedRoot
}

/**
* Split a word with a single apostrophe into prefix (with apostrophe) and root.
* Language-agnostic: only checks structure/length, not locale.
*/
private fun splitApostropheWord(word: String): ApostropheSplit? {
val normalized = normalizeApostrophes(word)
val apostropheCount = normalized.count { it == '\'' }
if (apostropheCount != 1) return null
val idx = normalized.indexOf('\'')
if (idx <= 0 || idx >= normalized.lastIndex) return null

val prefix = normalized.substring(0, idx + 1)
val root = normalized.substring(idx + 1)
val prefixRaw = prefix.dropLast(1)

val isPrefixOk = prefixRaw.isNotEmpty() &&
prefixRaw.length <= 3 &&
prefixRaw.all { it.isLetter() }
val isRootOk = root.length >= 3 && root.all { it.isLetter() }
return if (isPrefixOk && isRootOk) ApostropheSplit(prefix, root) else null
}

private fun stripAccents(input: String): String {
return Normalizer.normalize(input, Normalizer.Form.NFD)
.replace("\\p{Mn}".toRegex(), "")
}

private fun isAccentOnlyVariant(input: String, candidate: String): Boolean {
if (input.equals(candidate, ignoreCase = true)) return false
val normalizedInput = stripAccents(input.lowercase())
val normalizedCandidate = stripAccents(candidate.lowercase())
return normalizedInput == normalizedCandidate
}

private fun hasTrailingHardBoundary(textBeforeCursor: String): Boolean {
var i = textBeforeCursor.length - 1
while (i >= 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,35 @@ import java.io.FileInputStream
import java.io.InputStream
import kotlin.math.pow

interface DictionaryRepository {
val isReady: Boolean
val isLoadStarted: Boolean
suspend fun loadIfNeeded()
suspend fun refreshUserEntries()
fun addUserEntryQuick(word: String)
fun removeUserEntry(word: String)
fun markUsed(word: String)
fun effectiveFrequency(entry: DictionaryEntry): Int
fun getExactWordFrequency(word: String): Int
fun lookupByPrefixMerged(prefix: String, maxSize: Int): List<DictionaryEntry>
fun symSpellLookup(term: String, maxSuggestions: Int): List<SymSpell.SuggestItem>
fun bestEntryForNormalized(normalized: String): DictionaryEntry?
fun topByNormalized(normalized: String, limit: Int = 5): List<DictionaryEntry>
fun isKnownWord(word: String): Boolean
fun ensureLoadScheduled(background: () -> Unit)
}

/**
* Loads and indexes lightweight dictionaries from assets and merges them with the user dictionary.
*/
class DictionaryRepository(
class AndroidDictionaryRepository(
private val context: Context,
private val assets: AssetManager,
private val userDictionaryStore: UserDictionaryStore,
private val baseLocale: Locale = Locale.ITALIAN,
private val cachePrefixLength: Int = 4,
debugLogging: Boolean = false
) {
) : DictionaryRepository {

companion object {
// Avoid concurrent heavy loads across repositories to reduce memory spikes.
Expand Down Expand Up @@ -93,18 +111,18 @@ class DictionaryRepository(
private val normalizedIndex: MutableMap<String, MutableList<DictionaryEntry>> = mutableMapOf()
@Volatile private var symSpell: SymSpell? = null
@Volatile private var symSpellBuilt: Boolean = false
@Volatile var isReady: Boolean = false
private set
@Volatile override var isReady: Boolean = false
internal set
@Volatile private var loadStarted: Boolean = false

val isLoadStarted: Boolean
override val isLoadStarted: Boolean
get() = loadStarted
private val tag = "DictionaryRepo"
private val debugLogging: Boolean = debugLogging
private val maxRawFrequency = 255.0
private val scaledFrequencyMax = 1600.0

suspend fun loadIfNeeded() {
override suspend fun loadIfNeeded() {
if (isReady) return
// Must not run on main thread
if (Looper.myLooper() == Looper.getMainLooper()) return
Expand Down Expand Up @@ -180,12 +198,12 @@ class DictionaryRepository(
}
}

fun ensureLoadScheduled(background: () -> Unit) {
override fun ensureLoadScheduled(background: () -> Unit) {
if (isReady || loadStarted) return
background()
}

suspend fun refreshUserEntries() {
override suspend fun refreshUserEntries() {
coroutineContext.ensureActive()
// Ensure dictionary base is loaded first (if not already)
if (!isReady && !loadStarted) {
Expand Down Expand Up @@ -217,7 +235,7 @@ class DictionaryRepository(
* Lightweight add: updates persistent store, then merges a single USER entry
* into the in-memory indices and SymSpell without rebuilding everything.
*/
fun addUserEntryQuick(word: String) {
override fun addUserEntryQuick(word: String) {
userDictionaryStore.addWord(context, word)
// Find latest frequency from snapshot; default to 1
val freq = userDictionaryStore.getSnapshot()
Expand All @@ -228,16 +246,16 @@ class DictionaryRepository(
addToSymSpell(listOf(entry))
}

fun removeUserEntry(word: String) {
override fun removeUserEntry(word: String) {
userDictionaryStore.removeWord(context, word)
// Caller should refresh asynchronously; keep legacy path noop here.
}

fun markUsed(word: String) {
override fun markUsed(word: String) {
userDictionaryStore.markUsed(context, word)
}

fun isKnownWord(word: String): Boolean {
override fun isKnownWord(word: String): Boolean {
if (!isReady) return false
val normalized = normalize(word)
return normalizedIndex[normalized]?.isNotEmpty() == true
Expand All @@ -248,7 +266,7 @@ class DictionaryRepository(
* restore a meaningful range for ranking and SymSpell. The exponent (<1)
* boosts mid/high values without making low values explode.
*/
fun effectiveFrequency(entry: DictionaryEntry): Int {
override fun effectiveFrequency(entry: DictionaryEntry): Int {
val raw = entry.frequency.coerceAtLeast(0).coerceAtMost(maxRawFrequency.toInt())
val normalized = raw / maxRawFrequency
val scaled = (normalized.pow(0.75) * scaledFrequencyMax).toInt()
Expand All @@ -260,7 +278,7 @@ class DictionaryRepository(
* Returns the maximum frequency if multiple entries exist (e.g., different sources).
* Returns 0 if the word doesn't exist.
*/
fun getExactWordFrequency(word: String): Int {
override fun getExactWordFrequency(word: String): Int {
if (!isReady) return 0
val normalized = normalize(word)
val bucket = normalizedIndex[normalized] ?: return 0
Expand Down Expand Up @@ -290,7 +308,7 @@ class DictionaryRepository(
* transpositions (e.g., "teh" -> "the", "caio" -> "ciao") that would otherwise live
* under a different prefix.
*/
fun lookupByPrefixMerged(prefix: String, maxSize: Int): List<DictionaryEntry> {
override fun lookupByPrefixMerged(prefix: String, maxSize: Int): List<DictionaryEntry> {
if (!isReady || prefix.isBlank()) return emptyList()
val normalizedPrefix = normalize(prefix)
val maxPrefixLength = normalizedPrefix.length.coerceAtMost(cachePrefixLength)
Expand All @@ -312,12 +330,12 @@ class DictionaryRepository(
return seen.values.toList()
}

fun symSpellLookup(term: String, maxSuggestions: Int): List<SymSpell.SuggestItem> {
override fun symSpellLookup(term: String, maxSuggestions: Int): List<SymSpell.SuggestItem> {
val engine = symSpell ?: return emptyList()
return engine.lookup(term, maxSuggestions)
}

fun bestEntryForNormalized(normalized: String): DictionaryEntry? {
override fun bestEntryForNormalized(normalized: String): DictionaryEntry? {
return normalizedIndex[normalized]?.maxByOrNull { effectiveFrequency(it) }
}

Expand All @@ -330,7 +348,7 @@ class DictionaryRepository(
* Returns top entries for a normalized term, sorted by frequency.
* Useful for single-character inputs to surface multiple variants (e.g., accented).
*/
fun topByNormalized(normalized: String, limit: Int = 5): List<DictionaryEntry> {
override fun topByNormalized(normalized: String, limit: Int): List<DictionaryEntry> {
if (!isReady) return emptyList()
return normalizedIndex[normalized]
?.sortedByDescending { effectiveFrequency(it) }
Expand Down Expand Up @@ -492,7 +510,7 @@ class DictionaryRepository(
}
}

private fun index(entries: List<DictionaryEntry>, keepExisting: Boolean = false) {
internal fun index(entries: List<DictionaryEntry>, keepExisting: Boolean = false) {
if (!keepExisting) {
prefixCache.clear()
normalizedIndex.clear()
Expand Down Expand Up @@ -597,7 +615,7 @@ class DictionaryRepository(
}
}

private fun normalize(word: String): String {
internal fun normalize(word: String): String {
val normalized = Normalizer.normalize(word.lowercase(baseLocale), Normalizer.Form.NFD)
// Remove combining marks (accents) explicitly - same logic as SuggestionEngine
val withoutAccents = normalized.replace("\\p{Mn}".toRegex(), "")
Expand All @@ -606,7 +624,7 @@ class DictionaryRepository(
return withoutAccents.replace("[^\\p{L}]".toRegex(), "")
}

private fun sortCachesByEffectiveFrequency() {
internal fun sortCachesByEffectiveFrequency() {
prefixCache.values.forEach { list ->
list.sortByDescending { effectiveFrequency(it) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class SuggestionController(
private val appContext = context.applicationContext
private val debugLogging: Boolean = debugLogging
private val userDictionaryStore = UserDictionaryStore()
private var dictionaryRepository = DictionaryRepository(appContext, assets, userDictionaryStore, baseLocale = currentLocale, debugLogging = debugLogging)
private var dictionaryRepository: DictionaryRepository = AndroidDictionaryRepository(appContext, assets, userDictionaryStore, baseLocale = currentLocale, debugLogging = debugLogging)
private var suggestionEngine = SuggestionEngine(dictionaryRepository, locale = currentLocale, debugLogging = debugLogging).apply {
setKeyboardLayout(keyboardLayoutProvider())
}
Expand Down Expand Up @@ -66,7 +66,7 @@ class SuggestionController(
currentLoadJob = null

currentLocale = newLocale
dictionaryRepository = DictionaryRepository(appContext, assets, userDictionaryStore, baseLocale = currentLocale, debugLogging = debugLogging)
dictionaryRepository = AndroidDictionaryRepository(appContext, assets, userDictionaryStore, baseLocale = currentLocale, debugLogging = debugLogging)
suggestionEngine = SuggestionEngine(dictionaryRepository, locale = currentLocale, debugLogging = debugLogging).apply {
setKeyboardLayout(keyboardLayoutProvider())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ class FullSuggestionsBar(

// Extract language code from locale (e.g., "it_IT" -> "it", "en_US" -> "en")
val langCode = locale.split("_")[0]
DictionaryRepository.hasDictionaryForLocale(context, langCode)
it.palsoftware.pastiera.core.suggestions.AndroidDictionaryRepository.hasDictionaryForLocale(context, langCode)
} catch (e: Exception) {
false
}
Expand Down
Loading