Initial commit: HeliBoard + Gemma 4 on-device AI correction
Integrates LiteRT-LM (Google) with Gemma 4 E2B-it for on-device spell/grammar correction in HeliBoard. After sentence-ending punctuation, the model checks the sentence and shows a correction in the suggestion strip. Tapping it replaces the original text — fully offline, no cloud API. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+26
@@ -0,0 +1,26 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle/
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
build/
|
||||||
|
/captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
||||||
|
# Gradle wrapper jar (commit only the .properties file)
|
||||||
|
gradle/wrapper/gradle-wrapper.jar
|
||||||
|
gradlew
|
||||||
|
gradlew.bat
|
||||||
|
|
||||||
|
# HeliBoard sources are cloned by setup_heliboard.sh — not tracked here
|
||||||
|
heliboard/
|
||||||
|
|
||||||
|
# AI model files are too large for git — push to device via ADB
|
||||||
|
*.litertlm
|
||||||
|
*.task
|
||||||
|
|
||||||
|
# Claude internal files
|
||||||
|
.claude/
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# AIKeyboard — HeliBoard + Gemma 4 On-Device AI Correction
|
||||||
|
|
||||||
|
HeliBoard fork that adds on-device spell and grammar correction powered by
|
||||||
|
**Gemma 4 E2B-it** running locally via Google's **LiteRT-LM** SDK.
|
||||||
|
|
||||||
|
After you finish typing a sentence (`.`, `!`, `?`), the model silently checks
|
||||||
|
it and — if it finds an error — shows the corrected sentence in the suggestion
|
||||||
|
strip. Tap it once to replace the original text. Everything runs on-device,
|
||||||
|
no network, no cloud API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
```
|
||||||
|
User types "guten rag."
|
||||||
|
↓
|
||||||
|
InputLogic detects sentence-ending punctuation
|
||||||
|
↓
|
||||||
|
AiTriggerHook (500 ms debounce) extracts last sentence
|
||||||
|
↓
|
||||||
|
AiCorrectionEngine.correctSentence()
|
||||||
|
→ LiteRT-LM Engine (GPU first, CPU fallback)
|
||||||
|
→ Gemma 4 E2B-it .litertlm model
|
||||||
|
↓
|
||||||
|
AiSuggestionManager.postSuggestion("Guten Tag.", "guten rag.")
|
||||||
|
↓
|
||||||
|
Suggestion strip shows "Guten Tag." in italic/accent color
|
||||||
|
↓
|
||||||
|
User taps → original text replaced with correction
|
||||||
|
```
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|------|------|
|
||||||
|
| `app/…/ai/AiCorrectionEngine.kt` | LiteRT-LM wrapper; GPU→CPU fallback |
|
||||||
|
| `app/…/ai/AiTriggerHook.kt` | Sentence detection + debounce |
|
||||||
|
| `app/…/ai/AiSuggestionManager.kt` | Posts corrected text to suggestion strip |
|
||||||
|
| `patches/0001-InputLogic-ai-hook.patch` | Hook in `InputLogic.java` after `commitCodePoint` |
|
||||||
|
| `patches/0002-LatinIME-ai-lifecycle.patch` | AI object lifecycle in `LatinIME.java` |
|
||||||
|
| `patches/0003-SuggestionStrip-ai-style.patch` | Italic + accent color for AI suggestions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Android device with **ARM64** (arm64-v8a), Android 11+ (API 31)
|
||||||
|
- Android Studio **Ladybug** or newer / Gradle 8+
|
||||||
|
- NDK **28.0.13004108**
|
||||||
|
- **Gemma 4 E2B-it** model in `.litertlm` format (~2.6 GB)
|
||||||
|
Download: <https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Clone and prepare HeliBoard sources
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone http://172.17.2.68:3001/nova/AIKeyboard.git
|
||||||
|
cd AIKeyboard
|
||||||
|
bash setup_heliboard.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`setup_heliboard.sh` clones HeliBoard from GitHub and applies the three AI
|
||||||
|
integration patches automatically.
|
||||||
|
|
||||||
|
### 2. Push the model to the device
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb push gemma-4-E2B-it.litertlm /data/local/tmp/gemma-4-E2B-it.litertlm
|
||||||
|
adb shell chmod 644 /data/local/tmp/gemma-4-E2B-it.litertlm
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Build and install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew installDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable **AIKeyboard** as your input method in Android Settings → System →
|
||||||
|
Languages & input → On-screen keyboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Patch details
|
||||||
|
|
||||||
|
The three patches modify HeliBoard source files in `heliboard/`:
|
||||||
|
|
||||||
|
**0001 — InputLogic hook**
|
||||||
|
After every committed code point, checks `AiTriggerHook.isSentenceEnder()`.
|
||||||
|
On a sentence-ender, reads up to 500 chars before the cursor and calls
|
||||||
|
`onSentenceEndDetected()`.
|
||||||
|
|
||||||
|
**0002 — LatinIME lifecycle**
|
||||||
|
Instantiates `AiCorrectionEngine`, `AiSuggestionManager`, and `AiTriggerHook`
|
||||||
|
in `onCreate()`, cleans them up in `onDestroy()`. Overrides
|
||||||
|
`showAiSuggestion()` to bypass the normal suggestions-enabled gate and
|
||||||
|
intercepts `pickSuggestionManually()` to do a clean sentence replacement
|
||||||
|
(finish composing → delete original → commit corrected text).
|
||||||
|
|
||||||
|
**0003 — SuggestionStrip styling**
|
||||||
|
Detects the `KIND_AI_FLAG` bit (`0x10000`) on a `SuggestedWordInfo` and
|
||||||
|
applies italic style + auto-correct accent color to visually distinguish AI
|
||||||
|
suggestions from normal word predictions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Model placement
|
||||||
|
|
||||||
|
The model must be at `/data/local/tmp/gemma-4-E2B-it.litertlm` on the device.
|
||||||
|
This path is configured in `AiCorrectionEngine.MODEL_PATH`.
|
||||||
|
|
||||||
|
The model file is **not** tracked in this repository (2.6 GB, `.litertlm` is
|
||||||
|
in `.gitignore`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend selection
|
||||||
|
|
||||||
|
`AiCorrectionEngine` tries **GPU** (WebGPU/Vulkan via LiteRT-LM) first.
|
||||||
|
If GPU initialization fails, it falls back to **CPU** immediately. If a GPU
|
||||||
|
inference error occurs at runtime, it permanently switches to CPU for the
|
||||||
|
session and recreates the engine.
|
||||||
|
|
||||||
|
Tested on:
|
||||||
|
- Google Pixel 7 (Tensor G2) — CPU backend (OpenCL not available)
|
||||||
|
- Devices with Mali-G710 — GPU via WebGPU/Vulkan
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
The AI integration layer (`app/src/main/java/helium314/keyboard/latin/ai/`)
|
||||||
|
is original work added to this project.
|
||||||
|
|
||||||
|
HeliBoard itself is licensed under **GPL-3.0-only**. See
|
||||||
|
`heliboard/LICENSE` after running `setup_heliboard.sh`.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
// HeliBoard code expects this namespace to find its R class and BuildConfig
|
||||||
|
namespace = "helium314.keyboard.latin"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "helium314.keyboard.ai"
|
||||||
|
minSdk = 31 // MediaPipe LlmInference practical minimum
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0-ai"
|
||||||
|
|
||||||
|
ndk {
|
||||||
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
getByName("main") {
|
||||||
|
java.srcDirs(
|
||||||
|
"src/main/java", // AI layer
|
||||||
|
"../heliboard/app/src/main/java" // HeliBoard sources
|
||||||
|
)
|
||||||
|
res.srcDirs(
|
||||||
|
"src/main/res",
|
||||||
|
"../heliboard/app/src/main/res"
|
||||||
|
)
|
||||||
|
assets.srcDirs(
|
||||||
|
"../heliboard/app/src/main/assets"
|
||||||
|
)
|
||||||
|
jniLibs.srcDirs(
|
||||||
|
"../heliboard/app/src/main/jniLibs"
|
||||||
|
)
|
||||||
|
manifest.srcFile("../heliboard/app/src/main/AndroidManifest.xml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
externalNativeBuild {
|
||||||
|
ndkBuild {
|
||||||
|
path = file("../heliboard/app/src/main/jni/Android.mk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ndkVersion = "28.0.13004108"
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding = true
|
||||||
|
buildConfig = true
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
jniLibs {
|
||||||
|
pickFirsts += setOf(
|
||||||
|
"**/libjni_sentencepiece.so",
|
||||||
|
"**/libc++_shared.so"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
resources {
|
||||||
|
excludes += setOf(
|
||||||
|
"META-INF/DEPENDENCIES",
|
||||||
|
"META-INF/LICENSE*",
|
||||||
|
"META-INF/NOTICE*",
|
||||||
|
"META-INF/*.kotlin_module"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||||
|
|
||||||
|
implementation("androidx.core:core-ktx:1.16.0")
|
||||||
|
implementation("androidx.recyclerview:recyclerview:1.4.0")
|
||||||
|
implementation("androidx.autofill:autofill:1.3.0")
|
||||||
|
implementation("androidx.viewpager2:viewpager2:1.1.0")
|
||||||
|
implementation("androidx.preference:preference-ktx:1.2.1")
|
||||||
|
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
|
||||||
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
|
|
||||||
|
implementation(platform("androidx.compose:compose-bom:2025.05.00"))
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation("androidx.navigation:navigation-compose:2.8.9")
|
||||||
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
|
||||||
|
implementation("sh.calvin.reorderable:reorderable:2.4.3")
|
||||||
|
implementation("com.github.skydoves:colorpicker-compose:1.1.3")
|
||||||
|
|
||||||
|
implementation("com.google.ai.edge.litertlm:litertlm-android:latest.release")
|
||||||
|
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
|
||||||
|
}
|
||||||
Vendored
+61
@@ -0,0 +1,61 @@
|
|||||||
|
# ============================================================
|
||||||
|
# MediaPipe tasks-genai / LlmInference
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
-keep class com.google.mediapipe.** { *; }
|
||||||
|
-dontwarn com.google.mediapipe.**
|
||||||
|
|
||||||
|
# Protobuf (used internally by MediaPipe)
|
||||||
|
-keep class com.google.mediapipe.proto.** { *; }
|
||||||
|
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
|
||||||
|
<fields>;
|
||||||
|
}
|
||||||
|
-keep class com.google.protobuf.** { *; }
|
||||||
|
-dontwarn com.google.protobuf.**
|
||||||
|
|
||||||
|
# Google Flogger (used by MediaPipe internals)
|
||||||
|
-keep class com.google.common.flogger.** { *; }
|
||||||
|
-dontwarn com.google.common.flogger.**
|
||||||
|
|
||||||
|
# JNI methods must not be renamed
|
||||||
|
-keepclasseswithmembernames class * {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# MediaPipe framework interop
|
||||||
|
-keep public interface com.google.mediapipe.framework.* { public *; }
|
||||||
|
-keepclassmembers class com.google.mediapipe.framework.Packet {
|
||||||
|
public static *** create(...);
|
||||||
|
public long getNativeHandle();
|
||||||
|
public void release();
|
||||||
|
}
|
||||||
|
-keepclassmembers class com.google.mediapipe.framework.PacketCreator {
|
||||||
|
public void releaseWithSyncToken(...);
|
||||||
|
}
|
||||||
|
-keep class com.google.mediapipe.framework.MediaPipeException {
|
||||||
|
<init>(int, byte[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# AI engine — never obfuscate so Java↔Kotlin interop is stable
|
||||||
|
# ============================================================
|
||||||
|
-keep class helium314.keyboard.latin.ai.** { *; }
|
||||||
|
-keepclassmembers class helium314.keyboard.latin.ai.** { *; }
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Kotlin coroutines
|
||||||
|
# ============================================================
|
||||||
|
-keep class kotlinx.coroutines.** { *; }
|
||||||
|
-dontwarn kotlinx.coroutines.**
|
||||||
|
|
||||||
|
# Keep coroutine debug metadata (optional, remove in prod to shrink further)
|
||||||
|
-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# HeliBoard — keep JNI-accessible classes and reflection targets
|
||||||
|
# ============================================================
|
||||||
|
-keep class helium314.keyboard.latin.BinaryDictionary { *; }
|
||||||
|
-keep class helium314.keyboard.latin.DicTraverseSession { *; }
|
||||||
|
-keep class helium314.keyboard.latin.NgramContext { *; }
|
||||||
|
-keep class helium314.keyboard.latin.utils.BinaryDictionaryUtils { *; }
|
||||||
|
-dontwarn helium314.keyboard.**
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package helium314.keyboard.latin.ai
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.ai.edge.litertlm.Backend
|
||||||
|
import com.google.ai.edge.litertlm.Content
|
||||||
|
import com.google.ai.edge.litertlm.Contents
|
||||||
|
import com.google.ai.edge.litertlm.ConversationConfig
|
||||||
|
import com.google.ai.edge.litertlm.Engine
|
||||||
|
import com.google.ai.edge.litertlm.EngineConfig
|
||||||
|
import com.google.ai.edge.litertlm.SamplerConfig
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the LiteRT-LM [Engine] to provide on-device AI spell/grammar correction
|
||||||
|
* using the Gemma 4 E2B-it model in .litertlm format.
|
||||||
|
*
|
||||||
|
* Model placement:
|
||||||
|
* adb push gemma-4-E2B-it.litertlm /data/local/tmp/gemma-4-E2B-it.litertlm
|
||||||
|
* adb shell chmod 644 /data/local/tmp/gemma-4-E2B-it.litertlm
|
||||||
|
*/
|
||||||
|
class AiCorrectionEngine(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AiCorrectionEngine"
|
||||||
|
const val MODEL_PATH = "/data/local/tmp/gemma-4-E2B-it.litertlm"
|
||||||
|
|
||||||
|
private const val SYSTEM_INSTRUCTION =
|
||||||
|
"You are a helpful assistant. Correct only spelling and grammar errors. " +
|
||||||
|
"Return ONLY the corrected sentence, nothing else."
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile private var engine: Engine? = null
|
||||||
|
// Set to true after a GPU inference failure so we permanently switch to CPU.
|
||||||
|
@Volatile private var forceCpu: Boolean = false
|
||||||
|
|
||||||
|
private fun getOrCreate(): Engine {
|
||||||
|
engine?.let { return it }
|
||||||
|
synchronized(this) {
|
||||||
|
engine?.let { return it }
|
||||||
|
|
||||||
|
if (!File(MODEL_PATH).exists()) {
|
||||||
|
throw IllegalStateException("Gemma 4 model not found at $MODEL_PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Loading Gemma 4 E2B-it (.litertlm) model...")
|
||||||
|
val newEngine = createEngine()
|
||||||
|
engine = newEngine
|
||||||
|
Log.i(TAG, "Gemma 4 loaded successfully")
|
||||||
|
return newEngine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try GPU first for performance, fall back to CPU on init failure.
|
||||||
|
private fun createEngine(): Engine {
|
||||||
|
val backends = if (forceCpu) listOf(Backend.CPU()) else listOf(Backend.GPU(), Backend.CPU())
|
||||||
|
for (backend in backends) {
|
||||||
|
try {
|
||||||
|
val e = Engine(EngineConfig(modelPath = MODEL_PATH, backend = backend))
|
||||||
|
e.initialize()
|
||||||
|
Log.i(TAG, "Using backend: ${backend::class.simpleName}")
|
||||||
|
return e
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Log.w(TAG, "Backend ${backend::class.simpleName} failed at init: ${ex.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw IllegalStateException("All backends failed to load model at $MODEL_PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switches to CPU engine and returns it.
|
||||||
|
private fun switchToCpu(): Engine {
|
||||||
|
synchronized(this) {
|
||||||
|
forceCpu = true
|
||||||
|
engine?.close()
|
||||||
|
engine = null
|
||||||
|
}
|
||||||
|
return getOrCreate()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun correctSentence(sentence: String): String = withContext(Dispatchers.IO) {
|
||||||
|
if (sentence.isBlank()) return@withContext sentence
|
||||||
|
return@withContext try {
|
||||||
|
runInference(sentence, getOrCreate())
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
if (!forceCpu && ex.message?.let { it.contains("OpenCL") || it.contains("GPU") } == true) {
|
||||||
|
Log.w(TAG, "GPU inference failed, switching to CPU permanently")
|
||||||
|
try {
|
||||||
|
runInference(sentence, switchToCpu())
|
||||||
|
} catch (e2: Exception) {
|
||||||
|
Log.e(TAG, "CPU inference failed: ${e2.message}")
|
||||||
|
sentence
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Gemma 4 correction failed: ${ex.message}")
|
||||||
|
sentence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runInference(sentence: String, e: Engine): String {
|
||||||
|
val config = ConversationConfig(
|
||||||
|
systemInstruction = Contents.of(SYSTEM_INSTRUCTION),
|
||||||
|
samplerConfig = SamplerConfig(topK = 64, topP = 0.95, temperature = 1.0)
|
||||||
|
)
|
||||||
|
return e.createConversation(config).use { conversation ->
|
||||||
|
val message = conversation.sendMessage("Sentence: $sentence")
|
||||||
|
val text = message.contents.contents
|
||||||
|
.filterIsInstance<Content.Text>()
|
||||||
|
.joinToString("") { it.text }
|
||||||
|
.trim()
|
||||||
|
text.ifBlank { sentence }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
engine?.close()
|
||||||
|
engine = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package helium314.keyboard.latin.ai
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import helium314.keyboard.latin.SuggestedWords
|
||||||
|
import helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo
|
||||||
|
import helium314.keyboard.latin.suggestions.SuggestionStripViewAccessor
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridges AI correction results into HeliBoard's suggestion strip.
|
||||||
|
*
|
||||||
|
* Holds the current AI suggestion as a [StateFlow] and pushes it to the
|
||||||
|
* attached [SuggestionStripViewAccessor] whenever a new correction arrives.
|
||||||
|
*
|
||||||
|
* Lifecycle
|
||||||
|
* ---------
|
||||||
|
* Call [attachAccessor] in LatinIME.onCreate() and [detachAccessor] in
|
||||||
|
* LatinIME.onDestroy(). The [createImeScope] factory (@JvmStatic) creates
|
||||||
|
* a SupervisorJob-based scope that can be cancelled from Java.
|
||||||
|
*
|
||||||
|
* When the user taps the AI suggestion in the strip, HeliBoard's normal
|
||||||
|
* suggestion-pick flow will call pickSuggestionManually(). The corrected
|
||||||
|
* sentence (entire sentence, not just a word) replaces the typed text.
|
||||||
|
* This works because we set the suggestion score to Int.MAX_VALUE and
|
||||||
|
* mark it as the typed-word so HeliBoard uses commitText for the whole thing.
|
||||||
|
*
|
||||||
|
* Constructor signatures verified against HeliBoard main branch (Apr 2025).
|
||||||
|
*/
|
||||||
|
class AiSuggestionManager {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AiSuggestionManager"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom flag bit added to KIND_CORRECTION to mark AI suggestions.
|
||||||
|
* Using 0x10000 keeps the base kind as KIND_CORRECTION (= 1) so HeliBoard's
|
||||||
|
* normal suggestion-pick path handles it as a text commit, not commitCompletion.
|
||||||
|
* SuggestionStripLayoutHelper checks this bit for italic/colour styling.
|
||||||
|
*/
|
||||||
|
const val KIND_AI_FLAG = 0x10000
|
||||||
|
const val KIND_AI_CORRECTION = SuggestedWordInfo.KIND_CORRECTION or KIND_AI_FLAG
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method accessible from Java (LatinIME.java patch).
|
||||||
|
* Returns a [CoroutineScope] tied to a SupervisorJob so individual
|
||||||
|
* coroutine failures do not cancel the whole scope.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun createImeScope(): CoroutineScope =
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The current AI-corrected suggestion, or null if none is pending.
|
||||||
|
private val _currentSuggestion = MutableStateFlow<String?>(null)
|
||||||
|
val currentSuggestion: StateFlow<String?> = _currentSuggestion.asStateFlow()
|
||||||
|
|
||||||
|
// Original (uncorrected) sentence paired with the correction — used when
|
||||||
|
// the user accepts the suggestion to know how much text to replace.
|
||||||
|
@Volatile private var originalSentence: String? = null
|
||||||
|
|
||||||
|
@Volatile private var accessor: SuggestionStripViewAccessor? = null
|
||||||
|
|
||||||
|
/** Attach the IME's SuggestionStripViewAccessor. Called in LatinIME.onCreate(). */
|
||||||
|
fun attachAccessor(a: SuggestionStripViewAccessor) {
|
||||||
|
accessor = a
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called in LatinIME.onDestroy(). */
|
||||||
|
fun detachAccessor() {
|
||||||
|
accessor = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts an AI-corrected suggestion to the suggestion strip.
|
||||||
|
*
|
||||||
|
* [correctedSentence] — the model's output
|
||||||
|
* [original] — the original sentence (used for length calculation)
|
||||||
|
*
|
||||||
|
* Thread-safe: can be called from any coroutine context.
|
||||||
|
* HeliBoard's [SuggestionStripViewAccessor.setSuggestions] is main-thread-safe.
|
||||||
|
*/
|
||||||
|
fun postSuggestion(correctedSentence: String, original: String) {
|
||||||
|
_currentSuggestion.value = correctedSentence
|
||||||
|
originalSentence = original
|
||||||
|
Log.d(TAG, "Posting AI suggestion: \"$correctedSentence\"")
|
||||||
|
|
||||||
|
// Constructor signature verified against HeliBoard main (Apr 2025):
|
||||||
|
// SuggestedWordInfo(word, prevWordsContext, score, kindAndFlags,
|
||||||
|
// sourceDict, indexOfTouchPointOfSecondWord, autoCommitFirstWordConfidence)
|
||||||
|
val wordInfo = SuggestedWordInfo(
|
||||||
|
/* word = */ correctedSentence,
|
||||||
|
/* prevWordsContext = */ "",
|
||||||
|
/* score = */ Int.MAX_VALUE, // rank first in strip
|
||||||
|
/* kindAndFlags = */ KIND_AI_CORRECTION,
|
||||||
|
/* sourceDict = */ null,
|
||||||
|
/* indexOfTouchPointOfSecondWord = */ SuggestedWordInfo.NOT_AN_INDEX,
|
||||||
|
/* autoCommitFirstWordConfidence = */ SuggestedWordInfo.NOT_A_CONFIDENCE
|
||||||
|
)
|
||||||
|
|
||||||
|
val words = SuggestedWords(
|
||||||
|
/* suggestions = */ arrayListOf(wordInfo),
|
||||||
|
/* rawSuggestions = */ null,
|
||||||
|
/* typedWordInfo = */ wordInfo,
|
||||||
|
/* typedWordValid = */ false,
|
||||||
|
/* willAutoCorrect = */ false,
|
||||||
|
/* isObsoleteSuggestions = */ false,
|
||||||
|
/* inputStyle = */ SuggestedWords.INPUT_STYLE_PREDICTION,
|
||||||
|
/* sequenceNumber = */ SuggestedWords.NOT_A_SEQUENCE_NUMBER
|
||||||
|
)
|
||||||
|
|
||||||
|
val a = accessor
|
||||||
|
if (a == null) {
|
||||||
|
Log.w(TAG, "accessor is null — suggestion not shown")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// showAiSuggestion bypasses isSuggestionsEnabledPerUserSettings() so the AI
|
||||||
|
// correction appears regardless of toolbar mode or per-field suggestion settings.
|
||||||
|
a.showAiSuggestion(words)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the original (pre-correction) sentence, or null if none is pending. */
|
||||||
|
fun getOriginalSentence(): String? = originalSentence
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the AI suggestion from the strip.
|
||||||
|
* Called when the correction matches the original (no change needed)
|
||||||
|
* or when the user starts typing a new sentence.
|
||||||
|
*/
|
||||||
|
fun clearSuggestion() {
|
||||||
|
_currentSuggestion.value = null
|
||||||
|
originalSentence = null
|
||||||
|
accessor?.setNeutralSuggestionStrip()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package helium314.keyboard.latin.ai
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects sentence-ending punctuation in the input stream and triggers
|
||||||
|
* AI spell/grammar correction with a debounce to avoid rapid-fire calls.
|
||||||
|
*
|
||||||
|
* Integration
|
||||||
|
* -----------
|
||||||
|
* Called from InputLogic.java (patched via 0001-InputLogic-ai-hook.patch).
|
||||||
|
* The static [isSentenceEnder] method is used from Java to check individual
|
||||||
|
* code points before invoking [onSentenceEndDetected].
|
||||||
|
*
|
||||||
|
* Threading
|
||||||
|
* ---------
|
||||||
|
* [onSentenceEndDetected] is called on the IME main thread (from InputLogic).
|
||||||
|
* Coroutines launched here inherit the [scope] provided by LatinIME and
|
||||||
|
* switch to IO automatically inside [AiCorrectionEngine.correctSentence].
|
||||||
|
*/
|
||||||
|
class AiTriggerHook(
|
||||||
|
private val engine: AiCorrectionEngine,
|
||||||
|
private val manager: AiSuggestionManager,
|
||||||
|
private val scope: CoroutineScope
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AiTriggerHook"
|
||||||
|
private const val DEBOUNCE_MS = 500L
|
||||||
|
private const val MIN_SENTENCE_LENGTH = 4 // ignore trivially short fragments
|
||||||
|
private const val MAX_LOOKBACK_CHARS = 500 // chars to request from InputConnection
|
||||||
|
|
||||||
|
private val SENTENCE_ENDERS = setOf('.', '!', '?', '\u2026' /* … */)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from InputLogic.java to check whether a code point ends a sentence.
|
||||||
|
* Must be @JvmStatic so Java can call it without an instance.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun isSentenceEnder(codePoint: Int): Boolean =
|
||||||
|
codePoint.toChar() in SENTENCE_ENDERS
|
||||||
|
}
|
||||||
|
|
||||||
|
private var debounceJob: Job? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called after a sentence-ending punctuation character is committed to the
|
||||||
|
* input connection. [textBeforeCursor] is the raw text from
|
||||||
|
* InputConnection.getTextBeforeCursor(500, 0).
|
||||||
|
*
|
||||||
|
* Debounced at [DEBOUNCE_MS] ms to handle burst punctuation (e.g. "…").
|
||||||
|
*/
|
||||||
|
fun onSentenceEndDetected(textBeforeCursor: String) {
|
||||||
|
val sentence = extractLastSentence(textBeforeCursor)
|
||||||
|
if (sentence.length < MIN_SENTENCE_LENGTH) {
|
||||||
|
Log.v(TAG, "Sentence too short, skipping: \"$sentence\"")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceJob?.cancel()
|
||||||
|
debounceJob = scope.launch {
|
||||||
|
delay(DEBOUNCE_MS)
|
||||||
|
Log.d(TAG, "Triggering AI correction for: \"$sentence\"")
|
||||||
|
val corrected = engine.correctSentence(sentence)
|
||||||
|
if (corrected != sentence) {
|
||||||
|
manager.postSuggestion(corrected, sentence)
|
||||||
|
Log.d(TAG, "Correction: \"$sentence\" → \"$corrected\"")
|
||||||
|
} else {
|
||||||
|
manager.clearSuggestion()
|
||||||
|
Log.d(TAG, "No correction needed for: \"$sentence\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the last complete sentence from [text].
|
||||||
|
*
|
||||||
|
* Walks backwards to find the sentence boundary just before the one the
|
||||||
|
* user just typed, so we get the full sentence including its trailing
|
||||||
|
* punctuation. Example:
|
||||||
|
* "Hello world. This is a test." → "This is a test."
|
||||||
|
*/
|
||||||
|
private fun extractLastSentence(text: String): String {
|
||||||
|
if (text.isBlank()) return ""
|
||||||
|
val trimmed = text.trimEnd()
|
||||||
|
|
||||||
|
// Find the index of the last sentence-ender (the one just typed)
|
||||||
|
val lastEnd = trimmed.indexOfLast { it in SENTENCE_ENDERS }
|
||||||
|
if (lastEnd < 0) return trimmed // no boundary found, return all text
|
||||||
|
|
||||||
|
// Find the sentence-ender before that (start of our target sentence)
|
||||||
|
val prevEnd = trimmed.substring(0, lastEnd).indexOfLast { it in SENTENCE_ENDERS }
|
||||||
|
|
||||||
|
val start = if (prevEnd >= 0) prevEnd + 1 else 0
|
||||||
|
return trimmed.substring(start, lastEnd + 1).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancels any pending debounce job. Call when the IME is hidden or destroyed. */
|
||||||
|
fun cancel() {
|
||||||
|
debounceJob?.cancel()
|
||||||
|
debounceJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "9.0.1" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.2.10" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose") version "2.2.10" apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.enableJetifier=false
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.caching=true
|
||||||
|
android.defaults.buildfeatures.resvalues=true
|
||||||
|
android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
|
||||||
|
android.enableAppCompileTimeRClass=false
|
||||||
|
android.usesSdkInManifest.disallowed=false
|
||||||
|
android.uniquePackageNames=false
|
||||||
|
android.dependency.useConstraints=true
|
||||||
|
android.r8.strictFullModeForKeepRules=false
|
||||||
|
android.r8.optimizedResourceShrinking=false
|
||||||
|
android.builtInKotlin=false
|
||||||
|
android.newDsl=false
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#This file is generated by updateDaemonJvm
|
||||||
|
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect
|
||||||
|
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect
|
||||||
|
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect
|
||||||
|
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect
|
||||||
|
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e99bae143b75f9a10ead10248f02055e/redirect
|
||||||
|
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/04e088f8677de3b384108493cc9481d0/redirect
|
||||||
|
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect
|
||||||
|
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect
|
||||||
|
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e55dccbfe27cb97945148c61a39c89c5/redirect
|
||||||
|
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/dbd05c4936d573642f94cd149e1356c8/redirect
|
||||||
|
toolchainVendor=JETBRAINS
|
||||||
|
toolchainVersion=21
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Copy this file to local.properties and fill in your SDK/NDK paths.
|
||||||
|
# local.properties is NOT committed to git.
|
||||||
|
|
||||||
|
sdk.dir=C\:\\Users\\thors\\AppData\\Local\\Android\\Sdk
|
||||||
|
ndk.dir=C\:\\Users\\thors\\AppData\\Local\\Android\\Sdk\\ndk\\28.0.13004108
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
From: AIKeyboard AI Integration <ai@aikeyboard.local>
|
||||||
|
Date: Wed, 09 Apr 2025 00:00:00 +0000
|
||||||
|
Subject: [PATCH 1/3] Add AI spell correction trigger hook in InputLogic
|
||||||
|
|
||||||
|
After a sentence-ending punctuation character (. ! ? ...) is committed to the
|
||||||
|
InputConnection, notify AiTriggerHook with the text before the cursor so the
|
||||||
|
AI correction engine can run in the background.
|
||||||
|
|
||||||
|
Field name confirmed: mLatinIME (type LatinIME, first constructor parameter).
|
||||||
|
The hook is debounced (500ms) and async — the IME main thread is never blocked.
|
||||||
|
---
|
||||||
|
.../keyboard/latin/inputlogic/InputLogic.java | 13 +++++++++++++
|
||||||
|
1 file changed, 13 insertions(+)
|
||||||
|
|
||||||
|
diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
|
||||||
|
--- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
|
||||||
|
+++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
|
||||||
|
@@ -1228,6 +1228,19 @@ final class InputLogic {
|
||||||
|
|
||||||
|
mConnection.commitCodePoint(codePoint);
|
||||||
|
|
||||||
|
+ // === AI SPELL CORRECTION HOOK ===
|
||||||
|
+ // Runs async on Dispatchers.IO via coroutine — does not block the IME thread.
|
||||||
|
+ // mLatinIME is confirmed non-null here (it's set in the constructor).
|
||||||
|
+ if (helium314.keyboard.latin.ai.AiTriggerHook.isSentenceEnder(codePoint)
|
||||||
|
+ && mLatinIME.getAiTriggerHook() != null) {
|
||||||
|
+ final CharSequence beforeCursor =
|
||||||
|
+ mConnection.getTextBeforeCursor(500, 0);
|
||||||
|
+ if (beforeCursor != null) {
|
||||||
|
+ mLatinIME.getAiTriggerHook()
|
||||||
|
+ .onSentenceEndDetected(beforeCursor.toString());
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ // === END AI SPELL CORRECTION HOOK ===
|
||||||
|
+
|
||||||
|
if (isInlineEmojiSearchAction()) {
|
||||||
|
inputTransaction.setRequiresUpdateSuggestions();
|
||||||
|
} else {
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
From: AIKeyboard AI Integration <ai@aikeyboard.local>
|
||||||
|
Date: Wed, 09 Apr 2025 00:00:00 +0000
|
||||||
|
Subject: [PATCH 2/3] Wire AI engine lifecycle into LatinIME
|
||||||
|
|
||||||
|
- Declare AI component fields after existing field declarations
|
||||||
|
- Initialize AiCorrectionEngine, AiSuggestionManager, AiTriggerHook in onCreate()
|
||||||
|
- Clean up (cancel coroutines, close LlmInference) in onDestroy()
|
||||||
|
- Expose getAiTriggerHook() accessor used by InputLogic patch
|
||||||
|
---
|
||||||
|
.../keyboard/latin/LatinIME.java | 54 ++++++++++++++++++
|
||||||
|
1 file changed, 54 insertions(+)
|
||||||
|
|
||||||
|
diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java
|
||||||
|
index xxxxxxx..xxxxxxx 100644
|
||||||
|
--- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java
|
||||||
|
+++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java
|
||||||
|
@@ -133,6 +133,15 @@ public class LatinIME extends InputMethodService implements
|
||||||
|
private RichInputMethodManager mRichImm;
|
||||||
|
final KeyboardSwitcher mKeyboardSwitcher;
|
||||||
|
|
||||||
|
+ // ── AI spell correction components ──────────────────────────────────────
|
||||||
|
+ // Lazily initialised in onCreate(); destroyed in onDestroy().
|
||||||
|
+ private helium314.keyboard.latin.ai.AiCorrectionEngine mAiCorrectionEngine;
|
||||||
|
+ private helium314.keyboard.latin.ai.AiSuggestionManager mAiSuggestionManager;
|
||||||
|
+ private helium314.keyboard.latin.ai.AiTriggerHook mAiTriggerHook;
|
||||||
|
+ // CoroutineScope type requires kotlin.coroutines on classpath (satisfied by
|
||||||
|
+ // kotlinx-coroutines-android dependency in app/build.gradle.kts).
|
||||||
|
+ private kotlinx.coroutines.CoroutineScope mAiScope;
|
||||||
|
+ // ────────────────────────────────────────────────────────────────────────
|
||||||
|
+
|
||||||
|
private final SubtypeState mSubtypeState = new SubtypeState(
|
||||||
|
(InputMethodSubtype subtype) -> { switchToSubtype(subtype); return Unit.INSTANCE; });
|
||||||
|
private final StatsUtilsManager mStatsUtilsManager;
|
||||||
|
@@ -572,6 +581,21 @@ public class LatinIME extends InputMethodService implements
|
||||||
|
registerReceiver(mRestartAfterDeviceUnlockReceiver, restartAfterUnlockFilter);
|
||||||
|
|
||||||
|
StatsUtils.onCreate(mSettings.getCurrent(), mRichImm);
|
||||||
|
+
|
||||||
|
+ // ── Initialise AI components ─────────────────────────────────────────
|
||||||
|
+ // AiSuggestionManager must be attached before AiTriggerHook is created
|
||||||
|
+ // so that the first correction result has somewhere to go.
|
||||||
|
+ mAiSuggestionManager = new helium314.keyboard.latin.ai.AiSuggestionManager();
|
||||||
|
+ mAiSuggestionManager.attachAccessor(this);
|
||||||
|
+ mAiCorrectionEngine = new helium314.keyboard.latin.ai.AiCorrectionEngine(this);
|
||||||
|
+ // createImeScope() returns a SupervisorJob-backed CoroutineScope.
|
||||||
|
+ // Using @JvmStatic companion factory avoids complex Java↔Kotlin interop.
|
||||||
|
+ mAiScope = helium314.keyboard.latin.ai.AiSuggestionManager.createImeScope();
|
||||||
|
+ mAiTriggerHook = new helium314.keyboard.latin.ai.AiTriggerHook(
|
||||||
|
+ mAiCorrectionEngine,
|
||||||
|
+ mAiSuggestionManager,
|
||||||
|
+ mAiScope
|
||||||
|
+ );
|
||||||
|
+ // ────────────────────────────────────────────────────────────────────
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -683,6 +697,22 @@ public class LatinIME extends InputMethodService implements
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
mClipboardHistoryManager.onDestroy();
|
||||||
|
+
|
||||||
|
+ // ── Tear down AI components before releasing dictionaries ────────────
|
||||||
|
+ if (mAiTriggerHook != null) {
|
||||||
|
+ mAiTriggerHook.cancel();
|
||||||
|
+ }
|
||||||
|
+ if (mAiScope != null) {
|
||||||
|
+ // Cancel the coroutine scope to stop any in-flight correction jobs.
|
||||||
|
+ kotlinx.coroutines.JobKt.cancel(
|
||||||
|
+ (kotlinx.coroutines.Job) mAiScope.getCoroutineContext()
|
||||||
|
+ .get(kotlinx.coroutines.Job.Key),
|
||||||
|
+ null
|
||||||
|
+ );
|
||||||
|
+ }
|
||||||
|
+ if (mAiCorrectionEngine != null) {
|
||||||
|
+ mAiCorrectionEngine.close();
|
||||||
|
+ }
|
||||||
|
+ if (mAiSuggestionManager != null) {
|
||||||
|
+ mAiSuggestionManager.detachAccessor();
|
||||||
|
+ }
|
||||||
|
+ // ────────────────────────────────────────────────────────────────────
|
||||||
|
+
|
||||||
|
mDictionaryFacilitator.closeDictionaries();
|
||||||
|
mSettings.onDestroy();
|
||||||
|
unregisterReceiver(mRingerModeChangeReceiver);
|
||||||
|
@@ -695,4 +725,14 @@ public class LatinIME extends InputMethodService implements
|
||||||
|
mHandler.removeCallbacksAndMessages(null);
|
||||||
|
deallocateMemory();
|
||||||
|
}
|
||||||
|
+
|
||||||
|
+ // ── AI accessor ──────────────────────────────────────────────────────────
|
||||||
|
+ /**
|
||||||
|
+ * Returns the AiTriggerHook instance for use by InputLogic.
|
||||||
|
+ * Returns null if AI initialisation has not yet completed (race-safe).
|
||||||
|
+ */
|
||||||
|
+ @androidx.annotation.Nullable
|
||||||
|
+ public helium314.keyboard.latin.ai.AiTriggerHook getAiTriggerHook() {
|
||||||
|
+ return mAiTriggerHook;
|
||||||
|
+ }
|
||||||
|
+ // ────────────────────────────────────────────────────────────────────────
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
From: AIKeyboard AI Integration <ai@aikeyboard.local>
|
||||||
|
Date: Wed, 09 Apr 2025 00:00:00 +0000
|
||||||
|
Subject: [PATCH 3/3] Add italic colour styling for AI correction suggestions
|
||||||
|
|
||||||
|
Modifies two methods in SuggestionStripLayoutHelper:
|
||||||
|
|
||||||
|
1. getSuggestionTextColor(): Returns mColorAutoCorrect for AI suggestions
|
||||||
|
(KIND_APP_DEFINED / KIND_AI_CORRECTION = 6), giving them the same accent
|
||||||
|
colour as auto-corrections.
|
||||||
|
|
||||||
|
2. getStyledSuggestedWord(): Adds an italic StyleSpan for AI suggestions so
|
||||||
|
users can visually distinguish them from regular word predictions.
|
||||||
|
|
||||||
|
Both method signatures and bodies verified against HeliBoard main (Apr 2025).
|
||||||
|
---
|
||||||
|
.../suggestions/SuggestionStripLayoutHelper.java | 31 ++++++++++++++++++
|
||||||
|
1 file changed, 31 insertions(+)
|
||||||
|
|
||||||
|
diff --git a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripLayoutHelper.java b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripLayoutHelper.java
|
||||||
|
--- a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripLayoutHelper.java
|
||||||
|
+++ b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripLayoutHelper.java
|
||||||
|
@@ -1,3 +1,5 @@
|
||||||
|
+import android.graphics.Typeface;
|
||||||
|
+import android.text.style.StyleSpan;
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2013 The Android Open Source Project
|
||||||
|
*
|
||||||
|
@@ -1,0 +1,0 @@ private int getSuggestionTextColor(final SuggestedWords suggestedWords,
|
||||||
|
private int getSuggestionTextColor(final SuggestedWords suggestedWords,
|
||||||
|
final int indexInSuggestedWords) {
|
||||||
|
- final boolean isTypedWord = suggestedWords.getInfo(indexInSuggestedWords).isKindOf(SuggestedWordInfo.KIND_TYPED);
|
||||||
|
+ final SuggestedWordInfo wordInfo = suggestedWords.getInfo(indexInSuggestedWords);
|
||||||
|
+ final boolean isTypedWord = wordInfo.isKindOf(SuggestedWordInfo.KIND_TYPED);
|
||||||
|
+
|
||||||
|
+ // AI correction: use auto-correct accent colour so it stands out
|
||||||
|
+ if (wordInfo.isKindOf(SuggestedWordInfo.KIND_APP_DEFINED)) {
|
||||||
|
+ return mColorAutoCorrect;
|
||||||
|
+ }
|
||||||
|
|
||||||
|
final int color;
|
||||||
|
if (indexInSuggestedWords == SuggestedWords.INDEX_OF_AUTO_CORRECTION && suggestedWords.mWillAutoCorrect) {
|
||||||
|
@@ -1,0 +1,0 @@ private CharSequence getStyledSuggestedWord(final SuggestedWords suggestedWords,
|
||||||
|
private CharSequence getStyledSuggestedWord(final SuggestedWords suggestedWords,
|
||||||
|
final int indexInSuggestedWords) {
|
||||||
|
if (indexInSuggestedWords >= suggestedWords.size()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String word = suggestedWords.getLabel(indexInSuggestedWords);
|
||||||
|
final boolean isAutoCorrection = suggestedWords.mWillAutoCorrect
|
||||||
|
&& indexInSuggestedWords == SuggestedWords.INDEX_OF_AUTO_CORRECTION;
|
||||||
|
final boolean isTypedWordValid = suggestedWords.mTypedWordValid
|
||||||
|
&& indexInSuggestedWords == SuggestedWords.INDEX_OF_TYPED_WORD;
|
||||||
|
- if (!isAutoCorrection && !isTypedWordValid) {
|
||||||
|
+ final boolean isAiCorrection = suggestedWords.getInfo(indexInSuggestedWords)
|
||||||
|
+ .isKindOf(SuggestedWordInfo.KIND_APP_DEFINED);
|
||||||
|
+ if (!isAutoCorrection && !isTypedWordValid && !isAiCorrection) {
|
||||||
|
return word;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Spannable spannedWord = new SpannableString(word);
|
||||||
|
final int options = mSuggestionStripOptions;
|
||||||
|
if ((isAutoCorrection && (options & AUTO_CORRECT_BOLD) != 0)
|
||||||
|
|| (isTypedWordValid && (options & VALID_TYPED_WORD_BOLD) != 0)) {
|
||||||
|
addStyleSpan(spannedWord, BOLD_SPAN);
|
||||||
|
}
|
||||||
|
if (isAutoCorrection && (options & AUTO_CORRECT_UNDERLINE) != 0) {
|
||||||
|
addStyleSpan(spannedWord, UNDERLINE_SPAN);
|
||||||
|
}
|
||||||
|
+ // AI correction: italic to signal model-generated suggestion
|
||||||
|
+ if (isAiCorrection) {
|
||||||
|
+ addStyleSpan(spannedWord, new StyleSpan(Typeface.ITALIC));
|
||||||
|
+ }
|
||||||
|
return spannedWord;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plugins {
|
||||||
|
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "AIKeyboard"
|
||||||
|
include(":app")
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# setup_heliboard.sh
|
||||||
|
# Run once after cloning this repo to pull HeliBoard and apply AI patches.
|
||||||
|
#
|
||||||
|
# Model: https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HELIBOARD_REPO="https://github.com/Helium314/HeliBoard.git"
|
||||||
|
HELIBOARD_COMMIT="main"
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
HELIBOARD_DIR="$SCRIPT_DIR/heliboard"
|
||||||
|
PATCHES_DIR="$SCRIPT_DIR/patches"
|
||||||
|
|
||||||
|
echo "==> AIKeyboard Gemma 4 E2B Setup"
|
||||||
|
|
||||||
|
if [ ! -d "$HELIBOARD_DIR/.git" ]; then
|
||||||
|
git clone "$HELIBOARD_REPO" "$HELIBOARD_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$HELIBOARD_DIR"
|
||||||
|
git fetch origin
|
||||||
|
git checkout "$HELIBOARD_COMMIT"
|
||||||
|
|
||||||
|
echo "--> Applying Gemma 4 integration patches ..."
|
||||||
|
for patch in "$PATCHES_DIR"/*.patch; do
|
||||||
|
if [ -f "$patch" ]; then
|
||||||
|
git apply --check "$patch" 2>/dev/null && git apply "$patch" \
|
||||||
|
|| echo " SKIP: $(basename "$patch") already applied or conflicts."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1. Push Gemma 4 LiteRT model file to your device:"
|
||||||
|
echo " adb push gemma-4-E2B-it.litertlm /data/local/tmp/gemma-4-E2B-it.litertlm"
|
||||||
|
echo " adb shell chmod 644 /data/local/tmp/gemma-4-E2B-it.litertlm"
|
||||||
|
echo " 2. Build & Install: ./gradlew installDebug"
|
||||||
Reference in New Issue
Block a user