From faaf5820916f7dd67a904690928412316a0758c1 Mon Sep 17 00:00:00 2001 From: Lloyd Jackman <55206370+Lloyd-Jackman-UKPL@users.noreply.github.com> Date: Wed, 19 Aug 2026 06:44:01 +0000 Subject: [PATCH 1/3] feat: AI next-word suggestions (default off) AI next-word prediction source added to the suggestion strip, off by default. - AINextWordDictionary: async candidate source active only in next-word mode, cache-backed, never blocks the suggestion thread; AOSP suggestions stay primary. - AINextWordEngine + flavor factories: offline = on-device GGUF causal completion (reuses proofread ModelHolder, no network); standard = OpenAI-compatible chat; offlinelite = no-op. - Registered in DictionaryFacilitatorImpl gated by pref_ai_next_word; toggle in AI integration / advanced settings; unit tests for prompt + candidate parsing. --- .../latin/DictionaryFacilitatorImpl.kt | 19 +++ .../latin/dictionary/AINextWordDictionary.kt | 137 ++++++++++++++++++ .../keyboard/latin/dictionary/Dictionary.java | 3 + .../keyboard/latin/settings/Defaults.kt | 1 + .../keyboard/latin/settings/Settings.java | 1 + .../keyboard/latin/utils/AINextWordEngine.kt | 23 +++ .../keyboard/settings/SettingsContainer.kt | 1 + .../settings/screens/AIIntegrationScreen.kt | 4 +- .../settings/screens/AdvancedScreen.kt | 3 + app/src/main/res/values/strings.xml | 2 + .../latin/utils/AINextWordEngineFactory.kt | 136 +++++++++++++++++ .../latin/utils/AINextWordEngineFactory.kt | 28 ++++ .../latin/utils/AINextWordEngineFactory.kt | 111 ++++++++++++++ .../dictionary/AINextWordDictionaryTest.kt | 90 ++++++++++++ 14 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/helium314/keyboard/latin/dictionary/AINextWordDictionary.kt create mode 100644 app/src/main/java/helium314/keyboard/latin/utils/AINextWordEngine.kt create mode 100644 app/src/offline/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt create mode 100644 app/src/offlinelite/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt create mode 100644 app/src/standard/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt create mode 100644 app/src/test/java/helium314/keyboard/latin/dictionary/AINextWordDictionaryTest.kt diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt index 379ec0183..fa1e57e94 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt @@ -21,6 +21,7 @@ import helium314.keyboard.latin.common.StringUtils import helium314.keyboard.latin.common.decapitalize import helium314.keyboard.latin.common.mightBeEmoji import helium314.keyboard.latin.common.splitOnWhitespace +import helium314.keyboard.latin.dictionary.AINextWordDictionary import helium314.keyboard.latin.dictionary.AppsBinaryDictionary import helium314.keyboard.latin.dictionary.ContactsBinaryDictionary import helium314.keyboard.latin.dictionary.Dictionary @@ -33,6 +34,7 @@ import helium314.keyboard.latin.personalization.SessionWordBoost import helium314.keyboard.latin.personalization.UserHistoryDictionary import helium314.keyboard.latin.settings.Settings import helium314.keyboard.latin.settings.SettingsValuesForSuggestion +import helium314.keyboard.latin.utils.AINextWordEngineFactory import helium314.keyboard.latin.utils.Log import helium314.keyboard.latin.utils.SubtypeSettings import helium314.keyboard.latin.utils.SuggestionResults @@ -99,6 +101,10 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { // Limit parallelism to prevent excessive CPU usage during dictionary operations private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default.limitedParallelism(2)) + // Optional AI next-word dictionary (LeanType). Null when the feature is off or the engine + // cannot be built (factory gates on the pref / model availability). + private var aiNextWordDict: AINextWordDictionary? = null + override fun setValidSpellingWordReadCache(cache: LruCache) { mValidSpellingWordReadCache = cache } @@ -228,6 +234,10 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { mValidSpellingWordWriteCache?.evictAll() mValidSpellingWordReadCache?.evictAll() + + // AI next-word dictionary: rebuilt on every dictionary reset. Factory returns null when + // the feature is disabled or the engine cannot be built, making this a no-op by default. + aiNextWordDict = AINextWordEngineFactory.create(context)?.let { AINextWordDictionary(it, scope) } } /** creates dictionaryGroups for [newLocales] with given [newSubDictTypes], trying to re-use existing dictionaries. @@ -714,6 +724,15 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { } } } + // AI next-word candidates, only in prediction/next-word mode. The dictionary returns + // null (or filtered-away empties) unless it has cached LLM candidates, so it never + // blocks this pass. AOSP suggestions always come first (added above). + if (composedData.mTypedWord.isEmpty()) { + aiNextWordDict?.getSuggestions( + composedData, ngramContext, proximityInfoHandle, settingsValuesForSuggestion, + sessionId, weightForLocale, weightOfLangModelVsSpatialModel + )?.let { suggestions.addAll(it.filter { info -> info.word.isNotEmpty() }) } + } return suggestions } diff --git a/app/src/main/java/helium314/keyboard/latin/dictionary/AINextWordDictionary.kt b/app/src/main/java/helium314/keyboard/latin/dictionary/AINextWordDictionary.kt new file mode 100644 index 000000000..e9c331d69 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/dictionary/AINextWordDictionary.kt @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2026 LeanBitLab + * SPDX-License-Identifier: GPL-3.0-only + */ +package helium314.keyboard.latin.dictionary + +import helium314.keyboard.latin.NgramContext +import helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo +import helium314.keyboard.latin.common.ComposedData +import helium314.keyboard.latin.settings.SettingsValuesForSuggestion +import helium314.keyboard.latin.utils.AINextWordEngine +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * A [Dictionary] that adds LLM-driven next-word candidates supplied by an [AINextWordEngine]. + * + * - Only produces suggestions in prediction / next-word mode, i.e. when `composedData.mTypedWord` + * is empty. In completion mode it returns null and never interferes with the main dictionaries. + * - Never blocks the suggestion thread: getSuggestions() returns cached results synchronously or + * null, and kicks off an async fetch on its own [CoroutineScope]. + * - When the engine is not ready (pref off / model not loaded) it behaves as an empty dictionary. + * + * This class is pure JVM (no Android runtime types) so it can be unit-tested without instrumented + * APIs; the result cache uses a plain LRU [LinkedHashMap] guarded by a monitor. + */ +class AINextWordDictionary( + private val engine: AINextWordEngine, + private val scope: CoroutineScope +) : Dictionary(Dictionary.TYPE_AI_NEXT_WORD, null) { + + // accessOrder = true -> most-recently-used entries end up at the tail, so the head is LRU. + private val cache = LinkedHashMap>(32, 0.75f, true) + + override fun getSuggestions( + composedData: ComposedData, + ngramContext: NgramContext, + proximityInfoHandle: Long, + settingsValuesForSuggestion: SettingsValuesForSuggestion, + sessionId: Int, + weightForLocale: Float, + inOutWeightOfLangModelVsSpatialModel: FloatArray + ): ArrayList? { + // Only add AI candidates in next-word mode and when the engine is actually usable. + if (composedData.mTypedWord.isNotEmpty() || !engine.isReady()) return null + + val prompt = buildPrompt(ngramContext.extractPrevWordsContext()) + getCached(prompt)?.let { return it } + + // Trigger an async fetch; we return nothing to this pass so the suggestion thread is never + // blocked by network / on-device inference. + scope.launch { + val candidates = try { + engine.suggestNextWords(prompt) + } catch (e: Exception) { + emptyList() + } + val parsed = parseCandidates(candidates.joinToString(" ")) + if (parsed.isNotEmpty()) { + putCached(prompt, wrapSuggestions(parsed, ngramContext.extractPrevWordsContext())) + } + } + return null + } + + override fun isInDictionary(word: String): Boolean = false + + override fun isInitialized(): Boolean = engine.isReady() + + private fun getCached(prompt: String): ArrayList? = + synchronized(cache) { cache[prompt] } + + private fun putCached(prompt: String, value: ArrayList) { + synchronized(cache) { + cache[prompt] = value + while (cache.size > MAX_CACHED_PROMPTS) { + val eldest = cache.keys.firstOrNull() ?: break + cache.remove(eldest) + } + } + } + + private fun wrapSuggestions(words: List, prevContext: String): ArrayList = + ArrayList(words.size).apply { + words.forEachIndexed { i, word -> + add( + SuggestedWordInfo( + word, + prevContext, + BASE_SCORE + i, + SuggestedWordInfo.KIND_PREDICTION, + this@AINextWordDictionary, + SuggestedWordInfo.NOT_AN_INDEX, + SuggestedWordInfo.NOT_A_CONFIDENCE + ) + ) + } + } + + companion object { + private const val MAX_CACHED_PROMPTS = 64 + private const val BASE_SCORE = 500000 + } +} + +/** + * Builds the plain-text prompt handed to the LLM from the flat previous-words context string + * (e.g. produced by [NgramContext.extractPrevWordsContext]). Pure function so it can be + * unit-tested without Android. + */ +internal fun buildPrompt(context: String): String { + val trimmed = context.trim() + if (trimmed.isEmpty() || trimmed == BEGINNING_OF_SENTENCE_TAG) { + return "Complete the next word of this sentence, give just one or two words." + } + return "Complete the next word after \"$trimmed\". Give just one or two words." +} + +/** + * Splits raw LLM output into candidate words: tokenises, trims, drops empties/duplicates and + * caps the list at [MAX_CANDIDATES]. Pure function so it can be unit-tested without Android. + */ +internal fun parseCandidates(raw: String): List { + if (raw.isBlank()) return emptyList() + val result = LinkedHashSet() + // Split on whitespace and common punctuation bound; keep letters/apostrophes/hyphens. + for (token in raw.split(Regex("[\\s,;:!?\\.]+"))) { + val word = token.trim().trim('\'', '"', '(', ')', '[', ']') + if (word.isEmpty()) continue + if (word.any { it.isLetter() }) result.add(word) + if (result.size >= MAX_CANDIDATES) break + } + return result.toList() +} + +private const val BEGINNING_OF_SENTENCE_TAG = "" +private const val MAX_CANDIDATES = 3 diff --git a/app/src/main/java/helium314/keyboard/latin/dictionary/Dictionary.java b/app/src/main/java/helium314/keyboard/latin/dictionary/Dictionary.java index 3af1b590d..3d95293b8 100644 --- a/app/src/main/java/helium314/keyboard/latin/dictionary/Dictionary.java +++ b/app/src/main/java/helium314/keyboard/latin/dictionary/Dictionary.java @@ -55,6 +55,9 @@ public abstract class Dictionary { public static final String TYPE_USER = "user"; // User history dictionary internal to LatinIME. public static final String TYPE_USER_HISTORY = "history"; + // AI next-word prediction dictionary (LeanType). Only contributes suggestions in + // prediction/next-word mode and is a no-op when the feature is disabled. + public static final String TYPE_AI_NEXT_WORD = "ai_next_word"; public static final String TYPE_EMOJI = "emoji"; public final String mDictType; // The locale for this dictionary. May be null if unknown (phony dictionary for example). diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt index 54a21a39f..26a5d2e3f 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -198,6 +198,7 @@ object Defaults { const val PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE = "French" const val PREF_OFFLINE_KEEP_MODEL_LOADED = false const val PREF_AI_ALLOW_INSECURE_CONNECTIONS = false + const val PREF_AI_NEXT_WORD = false const val PREF_ENABLE_CLIPBOARD_HISTORY = true const val PREF_CLIPBOARD_HISTORY_RETENTION_TIME = 15 // minutes const val PREF_CLIPBOARD_HISTORY_PINNED_FIRST = true diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java index 092b5399e..cd0157dd2 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -195,6 +195,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_OFFLINE_MAX_TOKENS = "offline_max_tokens"; public static final String PREF_OFFLINE_KEEP_MODEL_LOADED = "offline_keep_model_loaded"; public static final String PREF_AI_ALLOW_INSECURE_CONNECTIONS = "ai_allow_insecure_connections"; + public static final String PREF_AI_NEXT_WORD = "pref_ai_next_word"; public static final String PREF_ENABLE_CLIPBOARD_HISTORY = "enable_clipboard_history"; public static final String PREF_SUGGEST_SCREENSHOTS = "suggest_screenshots"; diff --git a/app/src/main/java/helium314/keyboard/latin/utils/AINextWordEngine.kt b/app/src/main/java/helium314/keyboard/latin/utils/AINextWordEngine.kt new file mode 100644 index 000000000..86e2453d7 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/utils/AINextWordEngine.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2026 LeanBitLab + * SPDX-License-Identifier: GPL-3.0-only + */ +package helium314.keyboard.latin.utils + +/** + * Provider-agnostic contract for the AI next-word suggestion source. + * + * Implementations live per-flavor (standard = cloud, offline = on-device GGUF, + * offlinelite = stub) via [AINextWordEngineFactory]. + */ +interface AINextWordEngine { + /** Whether the engine is ready to produce candidate words right now. */ + fun isReady(): Boolean + + /** + * Produces the next-word continuation for the given plain-text prompt. + * Called from the AI next-word dictionary's own coroutine scope, never from + * the suggestion thread. Should return an empty list on any failure. + */ + suspend fun suggestNextWords(prompt: String): List +} diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt index d91e8127e..359860b05 100644 --- a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt +++ b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt @@ -176,6 +176,7 @@ object SettingsWithoutKey { const val CUSTOM_AI_KEYS = "custom_ai_keys" const val OFFLINE_KEEP_MODEL_LOADED = "offline_keep_model_loaded" const val AI_ALLOW_INSECURE_CONNECTIONS = "ai_allow_insecure_connections" + const val AI_NEXT_WORD = "pref_ai_next_word" const val TRANSLATION_ENGINE = "pref_translation_method" const val BACKGROUND_SERVICES = "background_services" diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt index 7f30ac2cd..0f21077fc 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt @@ -59,6 +59,7 @@ private fun StandardAIIntegrationScreen(onClickBack: () -> Unit) { // Always show provider selection add(SettingsWithoutKey.AI_PROVIDER) add(SettingsWithoutKey.TRANSLATION_ENGINE) + add(SettingsWithoutKey.AI_NEXT_WORD) // Custom AI Keys are only shown in the standard flavor (guaranteed by caller) add(SettingsWithoutKey.CUSTOM_AI_KEYS) @@ -99,7 +100,8 @@ private fun OfflineAIIntegrationScreen(onClickBack: () -> Unit) { val items = listOf( SettingsWithoutKey.CUSTOM_AI_KEYS, SettingsWithoutKey.OFFLINE_MODEL_PATH, - SettingsWithoutKey.OFFLINE_KEEP_MODEL_LOADED + SettingsWithoutKey.OFFLINE_KEEP_MODEL_LOADED, + SettingsWithoutKey.AI_NEXT_WORD ) SearchSettingsScreen( diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt index 4b0213996..8ea0f30c3 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt @@ -482,6 +482,9 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( Setting(context, SettingsWithoutKey.AI_ALLOW_INSECURE_CONNECTIONS, R.string.ai_allow_insecure_connections_title, R.string.ai_allow_insecure_connections_summary) { setting -> SwitchPreference(setting, Defaults.PREF_AI_ALLOW_INSECURE_CONNECTIONS) }, + Setting(context, SettingsWithoutKey.AI_NEXT_WORD, R.string.ai_next_word_title, R.string.ai_next_word_summary) { setting -> + SwitchPreference(setting, Defaults.PREF_AI_NEXT_WORD) + }, Setting(context, SettingsWithoutKey.TRANSLATION_ENGINE, R.string.translation_engine_title, R.string.translation_engine_summary) { setting -> ListPreference( setting = setting, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 22c958700..39b6b51f6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -543,6 +543,8 @@ Insecure HTTP connection blocked. Enable \'Allow Insecure Connections\' in AI settings. Allow insecure connections Allow HTTP connections and ignore SSL certificate errors. Warning: exposes input to local network eavesdropping. + AI next-word suggestions + Add AI-generated next-word candidates after an online or on-device model is configured. Default off; AOSP suggestions always come first. Layouts Theme & Custom Backgrounds Dictionaries & Typing History diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt b/app/src/offline/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt new file mode 100644 index 000000000..a0a43d6cb --- /dev/null +++ b/app/src/offline/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2026 LeanBitLab + * SPDX-License-Identifier: GPL-3.0-only + */ +package helium314.keyboard.latin.utils + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.flow.takeWhile +import org.nehuatl.llamacpp.LlamaHelper +import helium314.keyboard.latin.settings.Defaults +import helium314.keyboard.latin.settings.Settings + +/** + * Offline-flavor AI next-word engine factory. Produces causal next-word continuations using the + * same on-device GGUF LlamaHelper runtime that [ProofreadService.ModelHolder] manages for + * proofreading, so a model already loaded by the proofread UI is reused — no network. + */ +object AINextWordEngineFactory { + + fun create(context: Context): AINextWordEngine? { + if (!context.prefs().getBoolean(Settings.PREF_AI_NEXT_WORD, Defaults.PREF_AI_NEXT_WORD)) { + return null + } + // Engine is usable only once a GGUF model is loaded (offline flavor). + if (!ProofreadService.ModelHolder.isModelLoaded) { + return null + } + return OfflineNextWordEngine(context.applicationContext) + } +} + +private class OfflineNextWordEngine(private val context: Context) : AINextWordEngine { + + override fun isReady(): Boolean = ProofreadService.ModelHolder.isModelLoaded + + override suspend fun suggestNextWords(prompt: String): List = withContext(Dispatchers.IO) { + val helper = ProofreadService.ModelHolder.llamaHelper ?: return@withContext emptyList() + val result = try { + val completionText = completeWithParams(helper, prompt) + splitCandidates(completionText) + } catch (e: Exception) { + emptyList() + } + // Keep the model warm/per policy just like proofreading does. + ProofreadService.ModelHolder.scheduleUnload(context) + result + } + + /** Mirrors ProofreadService.predictWithParams + flow collection for proofreading. */ + private suspend fun completeWithParams(helper: LlamaHelper, prompt: String): String { + val currentContextField = + LlamaHelper::class.java.getDeclaredField("currentContext").apply { isAccessible = true } + val currentContext = currentContextField.get(helper) as? Int ?: return "" + + val llamaField = + LlamaHelper::class.java.getDeclaredField("llama\$delegate").apply { isAccessible = true } + val llama = llamaField.get(helper) as Lazy + + val tokenCountField = + LlamaHelper::class.java.getDeclaredField("tokenCount").apply { isAccessible = true } + tokenCountField.set(helper, 0) + val allTextField = + LlamaHelper::class.java.getDeclaredField("allText").apply { isAccessible = true } + allTextField.set(helper, "") + + val params = mutableMapOf( + "prompt" to prompt, + "emit_partial_completion" to true, + "temperature" to 0.2, + "top_p" to 0.9, + "top_k" to 40, + "min_p" to 0.05, + "n_predict" to 16, + "stop" to listOf("\n", ". ", ".") + ) + + // Emit Started so the collected flow carries the terminal event (mirrors + // ProofreadService.predictWithParams, which emits Started before launching). + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Started(prompt)) + + val completionJobField = + LlamaHelper::class.java.getDeclaredField("completionJob").apply { isAccessible = true } + val job = helper.scope.launch { + val startTime = System.currentTimeMillis() + try { + llama.value.launchCompletion(currentContext, params) + } catch (e: Throwable) { + helper.sharedFlow.tryEmit( + LlamaHelper.LLMEvent.Error("Next word completion failed: ${e.message}") + ) + return@launch + } + val allText = allTextField.get(helper) as String + val tokenCount = tokenCountField.get(helper) as Int + helper.sharedFlow.tryEmit( + LlamaHelper.LLMEvent.Done(allText, tokenCount, System.currentTimeMillis() - startTime) + ) + } + completionJobField.set(helper, job) + + val generated = StringBuilder() + // Collect from ModelHolder.llmFlow (the same buffered flow proofreading trusts). A timeout + // guards against a missing terminal event so this can never stall the suggestion pipeline. + withTimeoutOrNull(NEXT_WORD_TIMEOUT_MS) { + ProofreadService.ModelHolder.llmFlow.takeWhile { event -> + when (event) { + is LlamaHelper.LLMEvent.Ongoing -> { + generated.append(event.word) + true + } + is LlamaHelper.LLMEvent.Done -> false + is LlamaHelper.LLMEvent.Error -> false + else -> true + } + }.collect { } + } + return generated.toString() + } + + private fun splitCandidates(raw: String): List { + val out = LinkedHashSet() + for (token in raw.split(Regex("[\\s,;:!?\\.]+"))) { + val word = token.trim().trim('\'', '"') + if (word.isNotEmpty() && word.any { it.isLetter() }) out.add(word) + if (out.size >= 3) break + } + return out.toList() + } +} + +private const val NEXT_WORD_TIMEOUT_MS = 8000L diff --git a/app/src/offlinelite/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt b/app/src/offlinelite/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt new file mode 100644 index 000000000..4deaa07c6 --- /dev/null +++ b/app/src/offlinelite/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2026 LeanBitLab + * SPDX-License-Identifier: GPL-3.0-only + */ +package helium314.keyboard.latin.utils + +import android.content.Context +import helium314.keyboard.latin.settings.Defaults +import helium314.keyboard.latin.settings.Settings + +/** + * Offlinelite-flavor AI next-word engine factory. This flavor ships no AI, so the factory returns + * null. Kept with the same FQCN as the other flavors so the main source set compiles in all three. + */ +object AINextWordEngineFactory { + + fun create(context: Context): AINextWordEngine? { + if (!context.prefs().getBoolean(Settings.PREF_AI_NEXT_WORD, Defaults.PREF_AI_NEXT_WORD)) { + return null + } + return NoopNextWordEngine + } +} + +private object NoopNextWordEngine : AINextWordEngine { + override fun isReady(): Boolean = true + override suspend fun suggestNextWords(prompt: String): List = emptyList() +} diff --git a/app/src/standard/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt b/app/src/standard/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt new file mode 100644 index 000000000..12d400b34 --- /dev/null +++ b/app/src/standard/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2026 LeanBitLab + * SPDX-License-Identifier: GPL-3.0-only + */ +package helium314.keyboard.latin.utils + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL +import helium314.keyboard.latin.settings.Defaults +import helium314.keyboard.latin.settings.Settings + +/** + * Standard-flavor AI next-word engine factory. Uses the OpenAI-compatible chat-completion HTTP + * shape already used by the standard proofreading service (endpoint / token / model come from + * the same proofreading configuration) to produce a short next-word continuation. No network + * access happens on the suggestion thread. + */ +object AINextWordEngineFactory { + + fun create(context: Context): AINextWordEngine? { + if (!context.prefs().getBoolean(Settings.PREF_AI_NEXT_WORD, Defaults.PREF_AI_NEXT_WORD)) { + return null + } + return StandardNextWordEngine(context.applicationContext) + } +} + +private class StandardNextWordEngine(private val context: Context) : AINextWordEngine { + + private val service: ProofreadService by lazy { ProofreadService(context) } + + override fun isReady(): Boolean { + val provider = service.getProvider() + return when (provider) { + ProofreadService.AIProvider.GROQ -> !service.getGroqToken().isNullOrBlank() && + !service.getGroqModel().isBlank() + ProofreadService.AIProvider.OPENAI -> !service.getHuggingFaceToken().isNullOrBlank() && + !service.getHuggingFaceModel().isBlank() + else -> false + } + } + + override suspend fun suggestNextWords(prompt: String): List = withContext(Dispatchers.IO) { + if (!isReady()) return@withContext emptyList() + val isGroq = service.getProvider() == ProofreadService.AIProvider.GROQ + val model = if (isGroq) service.getGroqModel() else service.getHuggingFaceModel() + val token = if (isGroq) service.getGroqToken() else service.getHuggingFaceToken() + val endpoint = service.getHuggingFaceEndpoint() + try { + val url = URL(endpoint) + val connection = url.openConnection() as HttpURLConnection + try { + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("Authorization", "Bearer $token") + connection.setRequestProperty("User-Agent", "LeanType/1.0") + connection.doOutput = true + connection.connectTimeout = 5000 + connection.readTimeout = 10000 + + val messages = JSONArray().put( + JSONObject().put("role", "user").put("content", prompt) + ) + val body = JSONObject() + .put("model", model) + .put("messages", messages) + .put("temperature", 0.2) + .put("max_tokens", 16) + OutputStreamWriter(connection.outputStream).use { it.write(body.toString()) } + + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + return@withContext emptyList() + } + val content = parseContent(connection.inputStream.bufferedReader().use { it.readText() }) + splitCandidates(content) + } finally { + connection.disconnect() + } + } catch (e: Exception) { + emptyList() + } + } + + private fun parseContent(response: String): String { + return try { + val json = JSONObject(response) + val choices = json.optJSONArray("choices") + if (choices != null && choices.length() > 0) { + choices.getJSONObject(0).optJSONObject("message")?.optString("content", "") ?: "" + } else "" + } catch (e: Exception) { + "" + } + } + + private fun splitCandidates(raw: String): List { + val out = LinkedHashSet() + for (token in raw.split(Regex("[\\s,;:!?\\.]+"))) { + val word = token.trim().trim('\'', '"') + if (word.isNotEmpty() && word.any { it.isLetter() }) out.add(word) + if (out.size >= 3) break + } + return out.toList() + } +} diff --git a/app/src/test/java/helium314/keyboard/latin/dictionary/AINextWordDictionaryTest.kt b/app/src/test/java/helium314/keyboard/latin/dictionary/AINextWordDictionaryTest.kt new file mode 100644 index 000000000..373d4bb2f --- /dev/null +++ b/app/src/test/java/helium314/keyboard/latin/dictionary/AINextWordDictionaryTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2026 LeanBitLab + * SPDX-License-Identifier: GPL-3.0-only + */ +package helium314.keyboard.latin.dictionary + +import helium314.keyboard.latin.NgramContext +import helium314.keyboard.latin.utils.AINextWordEngine +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AINextWordDictionaryTest { + + private fun fakeEngine(): AINextWordEngine = object : AINextWordEngine { + override fun isReady(): Boolean = true + override suspend fun suggestNextWords(prompt: String): List = + listOf("world", "friend") + } + + private fun newDictionary() = + AINextWordDictionary(fakeEngine(), CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)) + + // ---------- buildPrompt ---------- + + @Test + fun buildPrompt_emptyContext_hasGenericInstruction() { + assertTrue(buildPrompt("").isNotBlank()) + assertTrue(buildPrompt("").contains("Complete")) + assertTrue(buildPrompt("").contains("Complete")) + } + + @Test + fun buildPrompt_withWords_embedsTheWords() { + val prompt = buildPrompt("the quick") + assertTrue(prompt.contains("the")) + assertTrue(prompt.contains("quick")) + } + + // ---------- parseCandidates ---------- + + @Test + fun parseCandidates_splitsAndCleans() { + val result = parseCandidates("hello world, again. \"friend\"") + assertEquals(listOf("hello", "world", "again"), result) + } + + @Test + fun parseCandidates_emptyInput_returnsEmpty() { + assertTrue(parseCandidates("").isEmpty()) + assertTrue(parseCandidates(" ").isEmpty()) + } + + @Test + fun parseCandidates_dedupes() { + val result = parseCandidates("the the the the") + assertEquals(1, result.size) + assertEquals("the", result[0]) + } + + @Test + fun parseCandidates_capsAtThree() { + val result = parseCandidates("one two three four five") + assertEquals(3, result.size) + assertEquals(listOf("one", "two", "three"), result) + } + + @Test + fun parseCandidates_dropsNonLetterTokens() { + val result = parseCandidates("123 !!! ...") + assertTrue(result.isEmpty()) + } + + // ---------- sanity ---------- + + @Test + fun dictionary_isNotReadyWhenEngineNotReady() { + val engine = object : AINextWordEngine { + override fun isReady(): Boolean = false + override suspend fun suggestNextWords(prompt: String): List = emptyList() + } + val dict = AINextWordDictionary(engine, CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)) + assertFalse(dict.isInitialized()) + assertFalse(dict.isInDictionary("hello")) + } +} From 5ff70bceb683ebd6919396ef104b726380da9e2c Mon Sep 17 00:00:00 2001 From: Lloyd Jackman <55206370+Lloyd-Jackman-UKPL@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:46:17 +0000 Subject: [PATCH 2/3] fix: rebuild AI next-word dict when the toggle changes Include the AI next-word pref in usesSameSettings so toggling it triggers a dictionary reset; previously the AI source would not (re)build until the IME restarted, so the feature appeared not to activate. --- .../helium314/keyboard/latin/DictionaryFacilitatorImpl.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt index fa1e57e94..8cbe0b1d9 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt @@ -68,6 +68,7 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { private var mContext: Context? = null private var mEnabledDictionariesState: Map = emptyMap() private var mLoadedDownloadPrefs: Map = emptyMap() + private var mLoadedAINextWord: Boolean? = null private var dictionaryGroups = listOf(DictionaryGroup()) @Volatile @@ -157,6 +158,10 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { if (currentPrefs != mEnabledDictionariesState || currentDownloadPrefs != mLoadedDownloadPrefs) { return false } + val aiNextWord = prefs.getBoolean(Settings.PREF_AI_NEXT_WORD, false) + if (aiNextWord != mLoadedAINextWord) { + return false + } } val ctx = mContext ?: return false val currentSuggestEmojis = Settings.getValues().mSuggestEmojis @@ -192,6 +197,9 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { .mapValues { it.value as? Boolean ?: true } mLoadedDownloadPrefs = prefs.all.filterKeys { it.startsWith("pref_dict_download_link_") } + // Track the AI next-word pref so the dictionary group is rebuilt when it is toggled. + mLoadedAINextWord = prefs.getBoolean(Settings.PREF_AI_NEXT_WORD, false) + // Initialize session word boost with context if not yet done if (sessionWordBoost == null) { sessionWordBoost = SessionWordBoost.getInstance(context) From 00c29da87fd7ba7fc67a1dd32bd43257d5f38dcd Mon Sep 17 00:00:00 2001 From: Lloyd Jackman <55206370+Lloyd-Jackman-UKPL@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:46:42 +0000 Subject: [PATCH 3/3] fix: harden and surface AI next-word suggestions for review Follow-up to the initial AI next-word feature (PR #422), validated on-device. Stability - Serialize all native llama.cpp completion calls (spelling + next-word) under a shared lock in ProofreadService and evaluate the llama instance on the caller thread; fixes SIGABRT/SIGSEGV (Scudo heap corruption) and overlapping native access. unloadModel() also serialized. - Guard clipboard/screenshot suggestion views against a null Keyboard/mIconsSet at startup (NPE crash after a screenshot). Surfacing - Clear the next-word suggestion cache before the AI refresh re-renders the strip, so freshly generated LLM candidates actually appear (they were being generated and cached but never shown because the AOSP-only list was short-circuited). - Raise the async completion timeout 8s -> 30s and route AI logging through the in-app logger so it shows up in About -> Save/Share log. Ranking / UX (build 7-9) - Lower AI BASE_SCORE 500000 -> 1000 and, in the facilitator, re-score AI words below the actual AOSP scores in the list so they always append as extra choices and can never displace/reorder the suggestion the user is about to tap. - Drop candidate repeats of any word already present in the current context. - Capitalise AI continuations when the text before the cursor ends a sentence (".", "!", "?"). Per-app context + privacy (build 5-6) - Persist a per-app text context (SharedPreferences, ai_app_context_) fed into the prompt, together with the per-app language hint. - Suppress recording and prompt augmentation under IME_FLAG_NO_PERSONALIZED_LEARNING or when suggestions are disabled. Tests: AINextWordDictionaryTest extended (prompt, parsing, dedupe, endsSentence); all pass. Offline build + packaging verified. --- .../keyboard/latin/ClipboardHistoryManager.kt | 8 ++ .../keyboard/latin/DictionaryFacilitator.java | 26 ++++ .../latin/DictionaryFacilitatorImpl.kt | 75 ++++++++++- .../helium314/keyboard/latin/LatinIME.java | 28 +++++ .../latin/dictionary/AINextWordDictionary.kt | 116 ++++++++++++++++-- .../keyboard/latin/inputlogic/InputLogic.java | 23 ++++ .../keyboard/latin/settings/Settings.java | 26 ++++ .../latin/utils/AINextWordEngineFactory.kt | 77 +++++++++--- .../keyboard/latin/utils/ProofreadService.kt | 19 ++- .../dictionary/AINextWordDictionaryTest.kt | 85 ++++++++++++- 10 files changed, 443 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt b/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt index d5f452355..81ef303ff 100644 --- a/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt +++ b/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt @@ -521,6 +521,11 @@ class ClipboardHistoryManager( fun getClipboardSuggestionView(editorInfo: EditorInfo?, parent: ViewGroup?): View? { clipboardSuggestionView = null + // Keyboard (and its icons set) may not be initialised yet (e.g. right after app start), + // and both the clipboard-content and screenshot views dereference it. Bail early to avoid + // an NPE instead of showing the suggestion. + if (latinIME.mKeyboardSwitcher.keyboard?.mIconsSet == null) return null + // check for screenshot first if enabled if (latinIME.mSettings.current.mSuggestScreenshots) { val screenshotView = getScreenshotSuggestionView(parent) @@ -583,6 +588,9 @@ class ClipboardHistoryManager( private fun getScreenshotSuggestionView(parent: ViewGroup?): View? { if (parent == null || dontShowCurrentSuggestion) return null + // Keyboard may not be initialised yet (e.g. right after app start); the view's close/paste + // icons come from its icons set, so bail out early rather than NPE. + if (latinIME.mKeyboardSwitcher.keyboard?.mIconsSet == null) return null val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { android.Manifest.permission.READ_MEDIA_IMAGES diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java index 691539c85..06a90bba8 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java @@ -170,4 +170,30 @@ default Map getAllMainDictionaryWordsWithFrequency() { default void forEachMainDictionaryWord(java.util.function.BiConsumer consumer) { } + + /** + * Registers a callback that should be run when the AI next-word dictionary has produced new + * LLM candidates in the background. The implementer should trigger a suggestion-strip refresh + * (e.g. LatinIME handler {@code postUpdateSuggestionStrip}) so the freshly cached candidates + * surface without an extra keystroke. Default no-op; only next-word-capable locales override. + */ + default void setNextWordRefreshListener(@Nullable Runnable listener) { + } + + /** + * Supplies the latest bounded text before the cursor, captured by the IME just before a + * next-word suggestion fetch. Used by the AI next-word dictionary to build a richer prompt + * (the actual sentence/message context) than the last few n-gram words alone. Default no-op. + */ + default void setAINextWordContextText(@Nullable String text) { + } + + /** + * Supplies per-app metadata for the AI next-word dictionary: the current app package name + * (context buffer key), the app's preferred keyboard language code (e.g. "en", "pl"), whether + * the current field is private/incognito (noLearning), and the persisted per-app context text. + */ + default void setAINextWordAppInfo(@Nullable String packageName, @Nullable String language, + boolean noLearning, @Nullable String persistedContext) { + } } diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt index 8cbe0b1d9..713144cd2 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt @@ -106,6 +106,10 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { // cannot be built (factory gates on the pref / model availability). private var aiNextWordDict: AINextWordDictionary? = null + // Runnable that LatinIME registers so we can ask it to redraw the suggestion strip once the + // AI next-word dictionary has cached fresh candidates (see setNextWordRefreshListener). + private var nextWordRefreshListener: (() -> Unit)? = null + override fun setValidSpellingWordReadCache(cache: LruCache) { mValidSpellingWordReadCache = cache } @@ -245,7 +249,39 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { // AI next-word dictionary: rebuilt on every dictionary reset. Factory returns null when // the feature is disabled or the engine cannot be built, making this a no-op by default. - aiNextWordDict = AINextWordEngineFactory.create(context)?.let { AINextWordDictionary(it, scope) } + aiNextWordDict = AINextWordEngineFactory.create(context)?.let { + AINextWordDictionary(it, scope).also { dict -> + // Fresh LLM candidates cached -> ask LatinIME to redraw the suggestion strip so + // they surface without another keystroke. + dict.onCandidatesReady = { nextWordRefreshListener?.invoke() } + } + } + Log.i(TAG, "resetDictionaries: AINextWord dict ${if (aiNextWordDict == null) "NOT created" else "created"} (pref=${prefs.getBoolean(Settings.PREF_AI_NEXT_WORD, false)})") + } + + override fun setNextWordRefreshListener(listener: Runnable?) { + nextWordRefreshListener = listener?.let { { it.run() } } + } + + @Volatile + private var aiNextWordContextText: String? = null + + override fun setAINextWordContextText(text: String?) { + aiNextWordContextText = text + } + + @Volatile private var aiNextWordAppPackage: String? = null + @Volatile private var aiNextWordAppLanguage: String? = null + @Volatile private var aiNextWordNoLearning: Boolean = false + @Volatile private var aiNextWordPersistedContext: String? = null + + override fun setAINextWordAppInfo( + packageName: String?, language: String?, noLearning: Boolean, persistedContext: String? + ) { + aiNextWordAppPackage = packageName + aiNextWordAppLanguage = language + aiNextWordNoLearning = noLearning + aiNextWordPersistedContext = persistedContext } /** creates dictionaryGroups for [newLocales] with given [newSubDictTypes], trying to re-use existing dictionaries. @@ -736,10 +772,37 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { // null (or filtered-away empties) unless it has cached LLM candidates, so it never // blocks this pass. AOSP suggestions always come first (added above). if (composedData.mTypedWord.isEmpty()) { - aiNextWordDict?.getSuggestions( - composedData, ngramContext, proximityInfoHandle, settingsValuesForSuggestion, - sessionId, weightForLocale, weightOfLangModelVsSpatialModel - )?.let { suggestions.addAll(it.filter { info -> info.word.isNotEmpty() }) } + aiNextWordDict?.let { dict -> + dict.prevTextForPrompt = aiNextWordContextText + dict.appPackageName = aiNextWordAppPackage + dict.appLanguageHint = aiNextWordAppLanguage + dict.noLearning = aiNextWordNoLearning + dict.appContext = if (aiNextWordNoLearning) null else aiNextWordPersistedContext + val aiInfos = dict.getSuggestions( + composedData, ngramContext, proximityInfoHandle, settingsValuesForSuggestion, + sessionId, weightForLocale, weightOfLangModelVsSpatialModel + )?.filter { it.word.isNotEmpty() } + if (!aiInfos.isNullOrEmpty()) { + // Rank AI words BELOW every AOSP suggestion in this list. AOSP next-word scores + // vary wildly (history ~700-800, user dict can be millions), so a fixed base is + // unreliable — instead key off the actual AOSP scores present so AI words ALWAYS + // append as extra choices and can never displace or reorder the AOSP suggestions + // the user is about to tap. mScore is final, so rebuild the infos with lower + // (strictly decreasing, hence unique within the TreeSet) scores. + val floor = ((suggestions.minOfOrNull { it.mScore } ?: AI_NEXT_WORD_BASE_SCORE) - 1) + aiInfos.forEachIndexed { i, info -> + val score = floor - i + suggestions.add( + if (info.mScore == score) info + else SuggestedWordInfo( + info.mWord, info.mPrevWordsContext, score, info.mKindAndFlags, + info.mSourceDict, info.mIndexOfTouchPointOfSecondWord, + info.mAutoCommitFirstWordConfidence + ) + ) + } + } + } } return suggestions } @@ -839,6 +902,8 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { companion object { private val TAG = DictionaryFacilitatorImpl::class.java.simpleName + // Fallback base when there are no AOSP suggestions to rank AI words below. + private const val AI_NEXT_WORD_BASE_SCORE = 1000 // HACK: This threshold is being used when adding a capitalized entry in the User History dictionary. private const val CAPITALIZED_FORM_MAX_PROBABILITY_FOR_INSERT = 140 diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index 89cad3b4d..ae8a05d62 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -586,6 +586,17 @@ public void onCreate() { mClipboardHistoryManager.onCreate(); mHandler.onCreate(); + // When the AI next-word dictionary caches fresh candidates, ask the suggestion strip to + // redraw so they surface without another keystroke (see AINextWordDictionary.onCandidatesReady). + // Also evict the next-word suggestions cache first: it may hold the AOSP-only list built + // before the async AI response landed, so getNextWordSuggestions() would otherwise return + // that stale list and the fresh AI words would never reach the strip. + mDictionaryFacilitator.setNextWordRefreshListener( + () -> { + mInputLogic.getSuggest().clearNextWordSuggestionsCache(); + mHandler.postUpdateSuggestionStrip(SuggestedWords.INPUT_STYLE_TYPING); + }); + // Register to receive ringer mode change. final IntentFilter filter = new IntentFilter(); filter.addAction(AudioManager.RINGER_MODE_CHANGED_ACTION); @@ -1206,6 +1217,23 @@ void onFinishInputInternal() { super.onFinishInput(); Log.i(TAG, "onFinishInput"); + // Persist per-app context for the AI next-word feature. NEVER records in private/incognito + // or anonymous fields (noLearning / no-suggestions), covering password and incognito input. + try { + final InputAttributes attrs = mSettings.getCurrent().mInputAttributes; + if (attrs.mShouldShowSuggestions && !attrs.mNoLearning) { + final String pkg = attrs.mTargetApplicationPackageName; + if (pkg != null) { + final android.view.inputmethod.InputConnection ic = getCurrentInputConnection(); + if (ic != null) { + final CharSequence text = ic.getTextBeforeCursor(200, 0); + mSettings.appendAppContext(pkg, text); + } + } + } + } catch (Exception ignored) { + } + mDictionaryFacilitator.onFinishInput(); final MainKeyboardView mainKeyboardView = mKeyboardSwitcher.getMainKeyboardView(); if (mainKeyboardView != null) { diff --git a/app/src/main/java/helium314/keyboard/latin/dictionary/AINextWordDictionary.kt b/app/src/main/java/helium314/keyboard/latin/dictionary/AINextWordDictionary.kt index e9c331d69..4777845dc 100644 --- a/app/src/main/java/helium314/keyboard/latin/dictionary/AINextWordDictionary.kt +++ b/app/src/main/java/helium314/keyboard/latin/dictionary/AINextWordDictionary.kt @@ -4,6 +4,7 @@ */ package helium314.keyboard.latin.dictionary +import helium314.keyboard.latin.utils.Log import helium314.keyboard.latin.NgramContext import helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo import helium314.keyboard.latin.common.ComposedData @@ -12,6 +13,8 @@ import helium314.keyboard.latin.utils.AINextWordEngine import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +private const val TAG = "AINextWord" + /** * A [Dictionary] that adds LLM-driven next-word candidates supplied by an [AINextWordEngine]. * @@ -29,6 +32,38 @@ class AINextWordDictionary( private val scope: CoroutineScope ) : Dictionary(Dictionary.TYPE_AI_NEXT_WORD, null) { + /** + * Invoked on the [scope] dispatcher after the async fetch has filled the cache with new + * LLM candidates. The suggestion strip must re-run getSuggestions() for these to surface, + * so whoever owns this dictionary should wire this to a strip refresh (postUpdateSuggestionStrip). + */ + @Volatile + var onCandidatesReady: (() -> Unit)? = null + + /** + * Latest bounded text before the cursor (set by the IME before each next-word fetch via the + * DictionaryFacilitator). Gives the model the actual sentence/message context instead of only + * the last few n-gram words. Read synchronously in getSuggestions() to build the prompt. + */ + @Volatile + var prevTextForPrompt: String? = null + + /** Current app package name — key for the persisted per-app context buffer. */ + @Volatile + var appPackageName: String? = null + + /** The app's preferred keyboard language code (e.g. "en", "pl") to keep the model consistent. */ + @Volatile + var appLanguageHint: String? = null + + /** True when the current field is private/incognito — suppresses per-app context use. */ + @Volatile + var noLearning: Boolean = false + + /** Persisted recent text from this app (survives restarts); null when private. */ + @Volatile + var appContext: String? = null + // accessOrder = true -> most-recently-used entries end up at the tail, so the head is LRU. private val cache = LinkedHashMap>(32, 0.75f, true) @@ -42,10 +77,31 @@ class AINextWordDictionary( inOutWeightOfLangModelVsSpatialModel: FloatArray ): ArrayList? { // Only add AI candidates in next-word mode and when the engine is actually usable. - if (composedData.mTypedWord.isNotEmpty() || !engine.isReady()) return null + if (composedData.mTypedWord.isNotEmpty() || !engine.isReady()) { + Log.d(TAG, "getSuggestions SKIP typedWord='${composedData.mTypedWord}' engineReady=${engine.isReady()}") + return null + } - val prompt = buildPrompt(ngramContext.extractPrevWordsContext()) - getCached(prompt)?.let { return it } + // Prefer the real text-before-cursor (whole sentence/message context) when available; + // fall back to the local n-gram window otherwise. Prepend any persisted per-app context + // when present and the field is not private. + val base = prevTextForPrompt?.takeIf { it.isNotBlank() } + ?: ngramContext.extractPrevWordsContext() + val fullContext = if (!noLearning && !appContext.isNullOrBlank()) "${appContext!!.trim()} $base" else base + // If the text before the cursor already ends a sentence (".", "!", "?"), the next word + // starts a brand-new sentence -> capitalise every AI continuation so it displays and + // inserts correctly at the start of a sentence. + val capitaliseNext = endsSentence(fullContext) + val prompt = buildPrompt(fullContext, appLanguageHint) + getCached(prompt)?.let { + Log.d(TAG, "getSuggestions CACHE-HIT prompt='$prompt' -> ${it.size} words") + return it + } + Log.d(TAG, "getSuggestions CACHE-MISS, launching async fetch prompt='$prompt'") + + // Tokenise the context so we can drop candidate repeats (issue 2): never suggest a word + // that already appears in the current sentence/context (e.g. type "dog" -> don't re-suggest "dog"). + val contextWordSet = wordTokenSet(fullContext) // Trigger an async fetch; we return nothing to this pass so the suggestion thread is never // blocked by network / on-device inference. @@ -53,11 +109,21 @@ class AINextWordDictionary( val candidates = try { engine.suggestNextWords(prompt) } catch (e: Exception) { + Log.e(TAG, "suggestNextWords threw", e) emptyList() } val parsed = parseCandidates(candidates.joinToString(" ")) - if (parsed.isNotEmpty()) { - putCached(prompt, wrapSuggestions(parsed, ngramContext.extractPrevWordsContext())) + .filterNot { contextWordSet.contains(it.lowercase()) } + Log.d(TAG, "async fetch done: engineCandidates=$candidates parsed=$parsed (repeats filtered from context words)") + // Capitalise each continuation at the start of a sentence (after ".", "!", "?"). + val toShow = if (capitaliseNext) + parsed.map { it.replaceFirstChar { c -> c.uppercase() } } + else parsed + if (toShow.isNotEmpty()) { + putCached(prompt, wrapSuggestions(toShow, ngramContext.extractPrevWordsContext())) + // Notify the owner that fresh candidates are available so the suggestion strip + // can re-run getSuggestions() and surface them without another keystroke. + onCandidatesReady?.invoke() } } return null @@ -99,21 +165,36 @@ class AINextWordDictionary( companion object { private const val MAX_CACHED_PROMPTS = 64 - private const val BASE_SCORE = 500000 + // Base score for AI next-word candidates. Kept modest so AI words offer EXTRA choices that + // rank BELOW good AOSP next-word suggestions, instead of barging to the front and displacing + // the suggestion the user is about to tap. (SuggestionResults is a TreeSet sorted by score.) + private const val BASE_SCORE = 1000 } } /** - * Builds the plain-text prompt handed to the LLM from the flat previous-words context string - * (e.g. produced by [NgramContext.extractPrevWordsContext]). Pure function so it can be - * unit-tested without Android. + * True when [text] already ends with an end-of-sentence mark (".", "!", "?") after trimming — + * i.e. the very next word starts a new sentence and should be capitalised. Pure function so it can + * be unit-tested without Android. + */ +internal fun endsSentence(text: String): Boolean = + text.trim().let { it.isNotEmpty() && it.last() in ".!?" } + +/** + * Builds the plain-text prompt handed to the LLM from the text before the cursor (or the flat + * previous-words context from [NgramContext.extractPrevWordsContext] as a fallback). Pure + * function so it can be unit-tested without Android. Deliberately directive so a small instruct + * model replies with ONE natural continuation word rather than a whole sentence. */ -internal fun buildPrompt(context: String): String { +internal fun buildPrompt(context: String, language: String? = null): String { val trimmed = context.trim() + // Keep the model consistent with the app's keyboard language, but only when it's a real + // language hint (not the default "en"). + val langLine = if (!language.isNullOrBlank() && !language.equals("en", ignoreCase = true)) " Continue in $language." else "" if (trimmed.isEmpty() || trimmed == BEGINNING_OF_SENTENCE_TAG) { - return "Complete the next word of this sentence, give just one or two words." + return "I am starting a new message. Output only a single likely next word.$langLine" } - return "Complete the next word after \"$trimmed\". Give just one or two words." + return "Continue this text with exactly one natural next word, outputting only that one word:$langLine\n\"$trimmed\"\nNext word:" } /** @@ -135,3 +216,14 @@ internal fun parseCandidates(raw: String): List { private const val BEGINNING_OF_SENTENCE_TAG = "" private const val MAX_CANDIDATES = 3 + +/** Tokenises [text] into its lowercase word set (letters/digits/apostrophes), used to drop + * candidate repeats that already appear in the context. Pure function for unit testing. */ +internal fun wordTokenSet(text: String): Set { + val out = HashSet() + for (token in text.split(Regex("[^\\p{L}\\p{N}']+"))) { + val w = token.trim().lowercase() + if (w.isNotEmpty()) out.add(w) + } + return out +} diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java index 50f2041cd..ca844d6e2 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -51,6 +51,7 @@ import helium314.keyboard.latin.common.StringUtilsKt; import helium314.keyboard.latin.common.SuggestionSpanUtilsKt; import helium314.keyboard.latin.define.DebugFlags; +import helium314.keyboard.latin.InputAttributes; import helium314.keyboard.latin.settings.Settings; import helium314.keyboard.latin.settings.SettingsValues; import helium314.keyboard.latin.settings.SpacingAndPunctuations; @@ -3367,6 +3368,28 @@ && isInlineEmojiSearchAction()) { mWordComposer.adviseCapitalizedModeBeforeFetchingSuggestions( getActualCapsMode(settingsValues, KeyboardSwitcher.getInstance().getKeyboardShiftMode())); try { + // Snapshot the current text before the cursor so the AI next-word dictionary can build + // its prompt from real sentence/message context (bounded) rather than only the last few + // n-gram words. ~1000 chars covers the current sentence + a buffer on any practical edit. + final CharSequence aiContext = mConnection.getTextBeforeCursor(1000, 0); + mDictionaryFacilitator.setAINextWordContextText( + aiContext == null ? null : aiContext.toString()); + // Per-app metadata for the AI dict: package (context key), language, private gate, and + // the persisted per-app context buffer. Skip enrichment entirely on private fields. + final InputAttributes attrs = settingsValues.mInputAttributes; + final String pkg = attrs.mTargetApplicationPackageName; + final boolean noLearning = attrs.mNoLearning || !attrs.mShouldShowSuggestions; + String language = null; + String persistedContext = null; + if (pkg != null && !noLearning) { + try { + final Locale loc = mDictionaryFacilitator.getMainLocale(); + if (loc != null) language = loc.getLanguage(); + persistedContext = Settings.getInstance().getAppContext(pkg); + } catch (Exception ignored) { + } + } + mDictionaryFacilitator.setAINextWordAppInfo(pkg, language, noLearning, persistedContext); final SuggestedWords suggestedWords = mSuggest.getSuggestedWords(mWordComposer, getNgramContextFromNthPreviousWordForSuggestion( settingsValues.mSpacingAndPunctuations, diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java index cd0157dd2..292cf55c2 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -692,6 +692,32 @@ public RichInputMethodSubtype getSubtypeForApp(String packageName) { return subtype != null ? RichInputMethodSubtype.Companion.get(subtype) : null; } + private static final String PREF_APP_CONTEXT_PREFIX = "ai_app_context_"; + private static final int MAX_APP_CONTEXT_SEGMENT_CHARS = 200; + private static final int MAX_APP_CONTEXT_TOTAL_CHARS = 600; + + /** Appends [text] (e.g. the last field the user left in [packageName]) to that app's AI + * next-word context buffer. Persisted to prefs so it survives process and device restarts. + * Callers MUST gate this on the field not being private (noLearning). */ + public void appendAppContext(String packageName, CharSequence text) { + if (packageName == null || text == null) return; + String segment = text.toString().trim(); + if (segment.length() < 4) return; // too trivial to be useful context + if (segment.length() > MAX_APP_CONTEXT_SEGMENT_CHARS) + segment = segment.substring(segment.length() - MAX_APP_CONTEXT_SEGMENT_CHARS); + String prev = mPrefs.getString(PREF_APP_CONTEXT_PREFIX + packageName, ""); + String combined = prev.isEmpty() ? segment : prev + " " + segment; + if (combined.length() > MAX_APP_CONTEXT_TOTAL_CHARS) + combined = combined.substring(combined.length() - MAX_APP_CONTEXT_TOTAL_CHARS); + mPrefs.edit().putString(PREF_APP_CONTEXT_PREFIX + packageName, combined).apply(); + } + + /** Returns the persisted AI next-word context buffer for [packageName], or empty string. */ + public String getAppContext(String packageName) { + if (packageName == null) return ""; + return mPrefs.getString(PREF_APP_CONTEXT_PREFIX + packageName, ""); + } + private boolean isSubtypePerApp() { return mPrefs.getBoolean(PREF_SAVE_SUBTYPE_PER_APP, Defaults.PREF_SAVE_SUBTYPE_PER_APP); } diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt b/app/src/offline/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt index a0a43d6cb..84a1ec169 100644 --- a/app/src/offline/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt +++ b/app/src/offline/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt @@ -15,6 +15,8 @@ import org.nehuatl.llamacpp.LlamaHelper import helium314.keyboard.latin.settings.Defaults import helium314.keyboard.latin.settings.Settings +private const val TAG = "AINextWord" + /** * Offline-flavor AI next-word engine factory. Produces causal next-word continuations using the * same on-device GGUF LlamaHelper runtime that [ProofreadService.ModelHolder] manages for @@ -26,29 +28,53 @@ object AINextWordEngineFactory { if (!context.prefs().getBoolean(Settings.PREF_AI_NEXT_WORD, Defaults.PREF_AI_NEXT_WORD)) { return null } - // Engine is usable only once a GGUF model is loaded (offline flavor). - if (!ProofreadService.ModelHolder.isModelLoaded) { - return null - } + // Engine is created whenever the pref is on. Readiness is gated dynamically via + // isReady() (which reflects ModelHolder.isModelLoaded), so the AI dictionary exists from + // the toggle and becomes live the moment a GGUF model is loaded for proofreading — no + // dependency on model being loaded at dict-build time. return OfflineNextWordEngine(context.applicationContext) } } private class OfflineNextWordEngine(private val context: Context) : AINextWordEngine { + // Guards against launching two llama completions on the same native context at once (which + // crashes the process). Only one next-word completion runs at a time; concurrent ones are + // dropped, not queued. + private val completionInFlight = java.util.concurrent.atomic.AtomicBoolean(false) + override fun isReady(): Boolean = ProofreadService.ModelHolder.isModelLoaded override suspend fun suggestNextWords(prompt: String): List = withContext(Dispatchers.IO) { - val helper = ProofreadService.ModelHolder.llamaHelper ?: return@withContext emptyList() - val result = try { - val completionText = completeWithParams(helper, prompt) - splitCandidates(completionText) - } catch (e: Exception) { - emptyList() + Log.i(TAG, "AINextWord: suggestNextWords prompt='$prompt' modelLoaded=${ProofreadService.ModelHolder.isModelLoaded} path=${ProofreadService.ModelHolder.currentModelPath}") + // Serialize completions: two launchCompletion calls on the SAME native LlamaHelper context + // concurrently crash the process. If a previous next-word completion is still running, drop + // this request instead of colliding with it. + if (!completionInFlight.compareAndSet(false, true)) { + Log.w(TAG, "AINextWord: previous completion still running, dropping request") + return@withContext emptyList() + } + try { + val helper = ProofreadService.ModelHolder.llamaHelper ?: run { + Log.w(TAG, "AINextWord: llamaHelper is null, returning empty") + return@withContext emptyList() + } + val result = try { + val completionText = completeWithParams(helper, prompt) + val words = splitCandidates(completionText) + Log.i(TAG, "AINextWord: completion='$completionText' -> candidates=$words") + words + } catch (e: Exception) { + Log.e(TAG, "AINextWord: completion failed", e) + emptyList() + } + Log.i(TAG, "AINextWord: returning ${result.size} candidates") + // Keep the model warm/per policy just like proofreading does. + ProofreadService.ModelHolder.scheduleUnload(context) + result + } finally { + completionInFlight.set(false) } - // Keep the model warm/per policy just like proofreading does. - ProofreadService.ModelHolder.scheduleUnload(context) - result } /** Mirrors ProofreadService.predictWithParams + flow collection for proofreading. */ @@ -59,7 +85,10 @@ private class OfflineNextWordEngine(private val context: Context) : AINextWordEn val llamaField = LlamaHelper::class.java.getDeclaredField("llama\$delegate").apply { isAccessible = true } - val llama = llamaField.get(helper) as Lazy + // Initialise the native wrapper on THIS (calling) thread, mirroring ProofreadService + // predictWithParams. Touching .value lazily on a worker coroutine thread can mis-init + // the native llama context and crash. + val llama = (llamaField.get(helper) as Lazy).value val tokenCountField = LlamaHelper::class.java.getDeclaredField("tokenCount").apply { isAccessible = true } @@ -88,7 +117,12 @@ private class OfflineNextWordEngine(private val context: Context) : AINextWordEn val job = helper.scope.launch { val startTime = System.currentTimeMillis() try { - llama.value.launchCompletion(currentContext, params) + // Serialize against every other native completion (proofreading + next-word + // share this one LlamaHelper context). Concurrent generation on the same + // context corrupts the native heap / null-derefs inside doCompletion. + synchronized(ProofreadService.ModelHolder.completionLock) { + llama.launchCompletion(currentContext, params) + } } catch (e: Throwable) { helper.sharedFlow.tryEmit( LlamaHelper.LLMEvent.Error("Next word completion failed: ${e.message}") @@ -104,9 +138,8 @@ private class OfflineNextWordEngine(private val context: Context) : AINextWordEn completionJobField.set(helper, job) val generated = StringBuilder() - // Collect from ModelHolder.llmFlow (the same buffered flow proofreading trusts). A timeout - // guards against a missing terminal event so this can never stall the suggestion pipeline. - withTimeoutOrNull(NEXT_WORD_TIMEOUT_MS) { + val start = System.currentTimeMillis() + val finished = withTimeoutOrNull(NEXT_WORD_TIMEOUT_MS) { ProofreadService.ModelHolder.llmFlow.takeWhile { event -> when (event) { is LlamaHelper.LLMEvent.Ongoing -> { @@ -118,6 +151,12 @@ private class OfflineNextWordEngine(private val context: Context) : AINextWordEn else -> true } }.collect { } + } != null + val elapsed = System.currentTimeMillis() - start + if (!finished) { + Log.w(TAG, "AINextWord: completion TIMED OUT after ${elapsed}ms (max $NEXT_WORD_TIMEOUT_MS), partial='$generated'") + } else { + Log.i(TAG, "AINextWord: completion finished in ${elapsed}ms, text='$generated'") } return generated.toString() } @@ -133,4 +172,4 @@ private class OfflineNextWordEngine(private val context: Context) : AINextWordEn } } -private const val NEXT_WORD_TIMEOUT_MS = 8000L +private const val NEXT_WORD_TIMEOUT_MS = 30000L diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt index 286371d5c..5d04ca629 100644 --- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -56,6 +56,14 @@ class ProofreadService(private val context: Context) { private const val UNLOAD_DELAY_MS = 10 * 60 * 1000L // 10 minutes private val loadMutex = Mutex() + // Guards ALL native llama completions (proofreading + AI next-word) so two can never + // run concurrently on the single shared LlamaHelper context, and unload can never run + // while a completion is mid-flight. llama.cpp is NOT thread-safe for simultaneous + // generation on the same context: concurrent access corrupts the native heap + // ("Scudo invalid chunk state" SIGABRT) and null-derefs (SIGSEGV) inside + // doCompletion — exactly the crashes seen on the AI next-word path. + val completionLock = Any() + // Flow for LLM events val llmFlow = MutableSharedFlow( extraBufferCapacity = 64, @@ -90,7 +98,10 @@ class ProofreadService(private val context: Context) { @Synchronized fun unloadModel() { try { - llamaHelper?.release() + // Don't release the native context while a completion is running on it. + synchronized(completionLock) { + llamaHelper?.release() + } } catch (e: Exception) { Log.w(TAG, "Error unloading llama model", e) } @@ -572,7 +583,11 @@ class ProofreadService(private val context: Context) { val job = helper.scope.launch { val startTime = System.currentTimeMillis() try { - llama.launchCompletion(currentContext, params) + // Serialize the native generation against every other completion + // (proofreading + next-word share this one context). + synchronized(ModelHolder.completionLock) { + llama.launchCompletion(currentContext, params) + } } catch (e: Throwable) { Log.e(TAG, "Completion failed", e) helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Error("Completion failed: ${e.message}")) diff --git a/app/src/test/java/helium314/keyboard/latin/dictionary/AINextWordDictionaryTest.kt b/app/src/test/java/helium314/keyboard/latin/dictionary/AINextWordDictionaryTest.kt index 373d4bb2f..ef4ae8546 100644 --- a/app/src/test/java/helium314/keyboard/latin/dictionary/AINextWordDictionaryTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/dictionary/AINextWordDictionaryTest.kt @@ -4,7 +4,12 @@ */ package helium314.keyboard.latin.dictionary +import android.text.TextUtils +import android.util.Log import helium314.keyboard.latin.NgramContext +import helium314.keyboard.latin.common.ComposedData +import helium314.keyboard.latin.common.InputPointers +import helium314.keyboard.latin.settings.SettingsValuesForSuggestion import helium314.keyboard.latin.utils.AINextWordEngine import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -13,7 +18,13 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.Mockito.mockStatic +import org.mockito.junit.MockitoJUnitRunner +import java.util.concurrent.atomic.AtomicInteger +@RunWith(MockitoJUnitRunner::class) class AINextWordDictionaryTest { private fun fakeEngine(): AINextWordEngine = object : AINextWordEngine { @@ -30,8 +41,15 @@ class AINextWordDictionaryTest { @Test fun buildPrompt_emptyContext_hasGenericInstruction() { assertTrue(buildPrompt("").isNotBlank()) - assertTrue(buildPrompt("").contains("Complete")) - assertTrue(buildPrompt("").contains("Complete")) + assertTrue(buildPrompt("").contains("word")) + assertTrue(buildPrompt("").contains("word")) + } + + @Test + fun buildPrompt_withWords_isDirectiveSingleWord() { + val prompt = buildPrompt("I took the dog for a") + assertTrue(prompt.contains("I took the dog for a")) + assertTrue(prompt.contains("one")) // "exactly one natural next word" } @Test @@ -75,6 +93,15 @@ class AINextWordDictionaryTest { assertTrue(result.isEmpty()) } + @Test + fun wordTokenSet_tokenizesToLowercaseWords() { + assertEquals( + setOf("i", "took", "the", "dog", "for", "a", "big"), + wordTokenSet("I took the dog for a — big!") + ) + assertFalse(wordTokenSet("hello world").contains("dog")) + } + // ---------- sanity ---------- @Test @@ -87,4 +114,58 @@ class AINextWordDictionaryTest { assertFalse(dict.isInitialized()) assertFalse(dict.isInDictionary("hello")) } + + // ---------- refresh signal ---------- + + @Test + fun getSuggestions_inPredictionMode_firesOnCandidatesReadyWhenCandidatesCached() { + val callbacks = AtomicInteger(0) + val dict = newDictionary() + dict.onCandidatesReady = { callbacks.incrementAndGet() } + + // Mock android Log + TextUtils, both unmocked statics in plain JVM tests. + Mockito.mockStatic(Log::class.java).use { _ -> + Mockito.mockStatic(TextUtils::class.java).use { textUtils -> + textUtils.`when` { + TextUtils.join( + Mockito.any(), + Mockito.anyList>() + ) + }.thenReturn("the quick") + + // Prediction mode: empty typed word + a real previous-words context. + val composed = ComposedData(InputPointers(4), false /* isBatchMode */, "" /* mTypedWord */) + val ngram = NgramContext(NgramContext.WordInfo("the"), NgramContext.WordInfo("quick")) + val settings = SettingsValuesForSuggestion(false, false, "TOUCH") + + val results = dict.getSuggestions( + composed, ngram, 0L /* proximityInfoHandle */, settings, + 0 /* sessionId */, 1.0f /* weightForLocale */, floatArrayOf(0.5f, 0.5f) + ) + + // First pass is a cache miss (returns null) but kicks off the async fetch, which caches + // the fake engine's candidates and must have fired the refresh callback. + assertTrue(results == null || results.isEmpty()) + } + } + // With Dispatchers.Unconfined the fetch completes synchronously, so the callback fired. + assertEquals(1, callbacks.get()) + } + + // ---------- sentence-start capitalisation ---------- + + @Test + fun endsSentence_afterFullStop_isTrue() { + assertTrue(endsSentence("Take the dog for a walk.")) + assertTrue(endsSentence("Why?\n")) + assertTrue(endsSentence("Wow! ")) + } + + @Test + fun endsSentence_midSentence_isFalse() { + assertFalse(endsSentence("Take the dog for a")) + assertFalse(endsSentence("take the dog for a walk")) + assertFalse(endsSentence("")) + assertFalse(endsSentence(" ")) + } }