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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ npm run deploy # Deploy to Firebase Functions (asia-south1)
> [!NOTE]
> `:app:testDebugUnitTest` now compiles and runs (the stale `AIGrammarViewModelTest`, which
> referenced deleted Grammar classes, was removed). It holds **pure JVM tests** for the
> conversion-critical decision logic — `ProStripState` (free-vs-PRO strip), `UnlockRouter`
> (auth→paywall routing), `PostLoginDestination` (post-login routing), and `KeyboardLayoutTest`
> (guards the removed "+"/`-20` key across all `res/xml` layouts). Keep view/IME/RevenueCat-SDK
> behaviour out of these (no mockito/Robolectric); test it by extracting the decision into a pure
> unit (see those classes) or via on-device verification. `:botok:test` remains the tokenizer suite.
> conversion-critical decision logic — `ProStripState` (free-vs-PRO strip), `WordComposer`
> (suggestion tap / tsheg rules), `UnlockRouter` (auth→paywall routing), `PostLoginDestination`
> (post-login routing), and `KeyboardLayoutTest` (guards the removed "+"/`-20` key across all
> `res/xml` layouts). Keep view/IME/RevenueCat-SDK behaviour out of these (no mockito/Robolectric);
> test it by extracting the decision into a pure unit (see those classes) or via on-device
> verification. `:botok:test` remains the tokenizer suite.

## Architecture Overview

Expand Down Expand Up @@ -114,6 +115,12 @@ preference changes apply the next time the keyboard opens.
- `TibetanKeyboardView extends KeyboardView` — the typing surface. Custom `onDraw` paints the
**Enter key gold** on top of any layout (visual only; key codes untouched).
- `SuggestionStripView` — Botok autocomplete; top suggestion gold, rest cream.
- `WordComposer` (pure, unit-tested) decides what a suggestion tap does: it replaces only the part
of the typed run the engine matched (`SuggestionEngine.suggest(...).matched`: the whole run, or
the longest tail of it starting after a tsheg), writes the next tsheg (`བོ` → `བོད་`), swallows
a tsheg typed onto it and lets a shad take its place (`བོད།`, but `རང་།` after nga). Every edit
is checked against the text before the cursor (`onUpdateSelection` re-syncs), so a cursor move
can never make a tap delete the wrong characters. Enter (`KEYCODE_DONE`) closes the run too.
- `KeyboardMode` enum: `NORMAL`, `AI_GRAMMAR`, `AI_REPHRASE`.

**Keyboard layouts & theming** are driven by `PreferenceManager.getDefaultSharedPreferences`
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ android {
// installed devices on API 23 (0.03%).
minSdkVersion 24
targetSdkVersion 36
versionCode project.hasProperty('versionCode') ? project.property('versionCode').toInteger() : 58
versionCode project.hasProperty('versionCode') ? project.property('versionCode').toInteger() : 59

// RevenueCat targeting rules match on this (App version >= 2.3.0).
versionName "2.3.1"
versionName "2.3.2"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
multiDexEnabled true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import android.view.inputmethod.InputConnection
import android.widget.FrameLayout
import androidx.preference.PreferenceManager
import com.google.firebase.auth.FirebaseAuth
import com.kharagedition.tibetankeyboard.BuildConfig
import com.kharagedition.tibetankeyboard.analytics.AppAnalytics
import com.kharagedition.tibetankeyboard.analytics.UserActivityTracker
import com.kharagedition.tibetankeyboard.data.local.TypingStatsStore
Expand All @@ -41,6 +42,7 @@ import com.kharagedition.tibetankeyboard.ui.journey.WordSegmenter
import com.kharagedition.tibetankeyboard.ui.keyboard.AIKeyboardInterface
import com.kharagedition.tibetankeyboard.ui.keyboard.KeyboardLayoutHint
import com.kharagedition.tibetankeyboard.ui.keyboard.TibetanKeyboardView
import com.kharagedition.tibetankeyboard.ui.keyboard.WordComposer
import com.kharagedition.tibetankeyboard.ui.keyboard.AIKeyboardView
import com.kharagedition.tibetankeyboard.ui.keyboard.EmojiKeyboardView
import com.kharagedition.tibetankeyboard.ui.keyboard.AIKeyboardCodes
Expand All @@ -66,11 +68,10 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
// honour the user's own saved language choice. Set on every onStartInputView.
private var forcedTibetan: Boolean? = null

// Tracks how many Unicode code points the user has typed since the last word boundary.
// Used to know exactly what to delete when a suggestion is selected.
// Resets on: shad (།), space, newline, or suggestion commit.
// Tshek (་) is NOT a boundary — it is part of the Tibetan word.
private var currentWordLength = 0
// The word being typed (the suggestion prefix) and the rules for accepting a suggestion:
// what a tap replaces, the tsheg it writes, and how the next key treats that tsheg. Pure and
// unit-tested; this service only feeds it editor text and applies the edits it returns.
private val composer = WordComposer()

// Journey streak/stats. PRIVACY: the store only ever receives counts and one-way word
// hashes — no typed text is persisted or transmitted (see TypingStatsStore's contract).
Expand Down Expand Up @@ -125,7 +126,7 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
}

override fun onStartInputView(info: EditorInfo?, restarting: Boolean) {
currentWordLength = 0
composer.reset()
// Read the focused field's requested layout before (re)building the view,
// so onCreateInputView can open on the matching language.
forcedTibetan = KeyboardLayoutHint.forcedTibetan(info?.privateImeOptions)
Expand Down Expand Up @@ -259,23 +260,30 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa

when (i) {
Keyboard.KEYCODE_DELETE -> {
composer.onDelete(textBeforeCursor(inputConnection))
inputConnection.deleteSurroundingText(1, 0)
if (currentWordLength > 0) currentWordLength--
aiKeyboardView?.updateSuggestions(currentPrefix(inputConnection))
aiKeyboardView?.updateSuggestions(composer.composing)
}
Keyboard.KEYCODE_SHIFT -> {
isCaps = !isCaps
keyboard!!.isShifted = isCaps
keyboardView!!.invalidateAllKeys()
}
Keyboard.KEYCODE_DONE -> {
// Enter ends the run like a shad does (word stats, suggestion state); the editor
// itself gets the action / key event below, not the composer's text.
val edit = composer.onChar('\n', textBeforeCursor(inputConnection))
if (edit.finishedChunk.isNotEmpty()) {
recordChunkAsWords(edit.finishedChunk)
}
sendDefaultEditorAction(true)
inputConnection.sendKeyEvent(
KeyEvent(
KeyEvent.ACTION_DOWN,
KeyEvent.KEYCODE_ENTER
)
)
aiKeyboardView?.updateSuggestions("")
}

// AI Feature Codes - These are handled by AIKeyboardView now
Expand Down Expand Up @@ -324,28 +332,23 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
else -> {
var code = i.toChar()
if (Character.isLetter(code) && isCaps) code = Character.toUpperCase(code)
// Shad (།) and space are sentence/word boundaries — reset the word tracker.
// Tshek (་) is a syllable separator WITHIN a word, so it increments the counter.
if (code == '།' || code == '༎' || code == ' ' || code == '\n') {
// A chunk just finished: segment it into real dictionary words (Tibetan has
// no spaces between words, so the whole chunk can be a full clause) and fold
// each into the Journey stats. Only one-way hashes survive (vocabulary size),
// never the text.
if (currentWordLength > 0) {
recordChunkAsWords(currentPrefix(inputConnection))
}
currentWordLength = 0
} else {
currentWordLength++
val edit = composer.onChar(code, textBeforeCursor(inputConnection))
// A shad (།), space or newline just finished a chunk: segment it into real
// dictionary words (Tibetan has no spaces between words, so the whole chunk can
// be a full clause) and fold each into the Journey stats. Only one-way hashes
// survive (vocabulary size), never the text.
if (edit.finishedChunk.isNotEmpty()) {
recordChunkAsWords(edit.finishedChunk)
}
inputConnection.commitText(code.toString(), 1)
// Journey streak: count Tibetan code points typed (a number, nothing else).
if (TypingStatsStore.isTibetanCodePoint(code.code)) {
applyEdit(inputConnection, edit)
// Journey streak: count Tibetan code points typed (a number, nothing else). A
// tsheg typed onto the one an accepted suggestion already wrote inserts nothing.
if (edit.text.isNotEmpty() && TypingStatsStore.isTibetanCodePoint(code.code)) {
typingStats.recordTibetanChars(1)?.let { milestone ->
AppAnalytics.logStreakMilestone(milestone)
}
}
aiKeyboardView?.updateSuggestions(currentPrefix(inputConnection))
aiKeyboardView?.updateSuggestions(composer.composing)
}
}
}
Expand Down Expand Up @@ -490,6 +493,7 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
inputConnection.deleteSurroundingText(textLength, 0)
inputConnection.commitText(correctedText, 1)
}
composer.reset()
currentMode = KeyboardMode.NORMAL
aiKeyboardView?.showNormalKeyboard()
}
Expand All @@ -503,6 +507,7 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
inputConnection.deleteSurroundingText(textLength, 0)
inputConnection.commitText(translatedText, 1)
}
composer.reset()
currentMode = KeyboardMode.NORMAL
aiKeyboardView?.showNormalKeyboard()
}
Expand All @@ -516,6 +521,7 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
inputConnection.deleteSurroundingText(textLength, 0)
inputConnection.commitText(rephrasedText, 1)
}
composer.reset()
currentMode = KeyboardMode.NORMAL
aiKeyboardView?.showNormalKeyboard()
}
Expand All @@ -525,20 +531,47 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
aiKeyboardView?.showNormalKeyboard()
}

override fun onSuggestionSelected(word: String) {
override fun onSuggestionSelected(word: String, matched: String) {
val ic = currentInputConnection ?: return
AppAnalytics.logKeyboardSuggestionSelected()
Log.d("TibetanKeyboard", "onSuggestionSelected: '$word', deleting $currentWordLength chars")
if (currentWordLength > 0) ic.deleteSurroundingText(currentWordLength, 0)
ic.commitText(word, 1)
currentWordLength = 0
// Journey stats: an accepted suggestion completes a word (hash-only, see store contract).
val edit = composer.onAccept(
word,
matched,
textBeforeCursor(ic, extra = matched.length),
ic.getTextAfterCursor(1, 0),
)
if (BuildConfig.DEBUG) {
Log.d(
"TibetanKeyboard",
"onSuggestionSelected: '$word' for '$matched' → delete ${edit.deleteBefore}, insert '${edit.text}'"
)
}
applyEdit(ic, edit)
// Journey stats (hash-only, see store contract): the accepted word completes a word, and
// the syllables it leaves in place before it are typed words too.
if (edit.finishedChunk.isNotEmpty()) {
recordChunkAsWords(edit.finishedChunk)
}
typingStats.recordWordTyped(word)?.let { milestone ->
AppAnalytics.logStreakMilestone(milestone)
}
aiKeyboardView?.updateSuggestions("")
}

override fun onUpdateSelection(
oldSelStart: Int, oldSelEnd: Int, newSelStart: Int, newSelEnd: Int,
candidatesStart: Int, candidatesEnd: Int,
) {
super.onUpdateSelection(oldSelStart, oldSelEnd, newSelStart, newSelEnd, candidatesStart, candidatesEnd)
// Our own edits echo back here too. The composer keeps state the editor still confirms
// and drops the rest (the user tapped elsewhere, the app changed the text), so the strip
// never offers to replace characters that are no longer before the cursor.
if (!composer.hasState) return
val ic = currentInputConnection ?: return
val before = if (newSelStart == newSelEnd) textBeforeCursor(ic) else null
if (composer.sync(before)) aiKeyboardView?.updateSuggestions("")
}

override fun onOpenChat() {
startActivity(
Intent(this, ChatActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
Expand Down Expand Up @@ -570,14 +603,20 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa
}
}

// Returns the Unicode code points the user has typed since the last word boundary,
// verified against actual text before the cursor.
private fun currentPrefix(ic: InputConnection): String {
if (currentWordLength == 0) return ""
val textBefore = ic.getTextBeforeCursor(currentWordLength + 5, 0)?.toString() ?: ""
val prefix = textBefore.takeLast(currentWordLength)
Log.d("TibetanKeyboard", "prefix='$prefix' (wordLen=$currentWordLength)")
return prefix
/**
* The text before the cursor, as much as the composer needs to check its state (plus
* [extra]); null when the editor cannot say, which the composer treats as "trust nothing".
*/
private fun textBeforeCursor(ic: InputConnection, extra: Int = 0): CharSequence? =
ic.getTextBeforeCursor(composer.lookback + extra, 0)

/** Applies one composer decision as a single editor change. */
private fun applyEdit(ic: InputConnection, edit: WordComposer.Edit) {
if (edit.deleteBefore == 0 && edit.text.isEmpty()) return
ic.beginBatchEdit()
if (edit.deleteBefore > 0) ic.deleteSurroundingText(edit.deleteBefore, 0)
if (edit.text.isNotEmpty()) ic.commitText(edit.text, 1)
ic.endBatchEdit()
}

private fun vibratePhone() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ interface AIKeyboardInterface {
fun onTranslateReplace(originalText: String, translatedText: String)
fun onRephraseReplace(originalText: String, rephrasedText: String)
fun onAICancel()
fun onSuggestionSelected(word: String)

/**
* The user tapped [word] in the suggestion strip. [matched] is the end of the typed prefix
* the word completes (see `SuggestionEngine.suggest`): that, and only that, gets replaced.
*/
fun onSuggestionSelected(word: String, matched: String)

/** Open the AI chat screen (PRO users). */
fun onOpenChat()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.kharagedition.tibetankeyboard.ui.keyboard

import com.kharagedition.tibetankeyboard.BuildConfig
import com.kharagedition.tibetankeyboard.R

import android.animation.AnimatorSet
Expand Down Expand Up @@ -70,6 +71,8 @@ class AIKeyboardView @JvmOverloads constructor(
private var themeColor: String = "#FF704C04"
private lateinit var suggestionStrip: SuggestionStripView
private var suggestionEngine: SuggestionEngine? = null
/** The end of the typed prefix the chips now showing complete (what a tap replaces). */
private var suggestionsMatched = ""
private var currentOriginalText = ""
private var currentSuggestedText = ""
private var currentSourceLang = "en" // English
Expand Down Expand Up @@ -230,7 +233,7 @@ class AIKeyboardView @JvmOverloads constructor(
AppAnalytics.logSuggestionQuotaExhausted(dailyLimit())
refreshStrip()
}
aiKeyboardInterface?.onSuggestionSelected(word)
aiKeyboardInterface?.onSuggestionSelected(word, suggestionsMatched)
}
suggestionStrip.onLockedSuggestionClick = {
aiKeyboardInterface?.onUnlockPro(AppAnalytics.UpgradeSource.KEYBOARD_SUGGESTIONS)
Expand Down Expand Up @@ -632,8 +635,12 @@ class AIKeyboardView @JvmOverloads constructor(
val mode = suggestionMode()
val strip = ProStripState.forUser(isPremiumUser, mode)
applyStrip(strip)
val suggestions = if (prefix.isNotEmpty()) engine.getSuggestions(prefix, strip.maxSuggestions) else emptyList()
Log.d(TAG, "updateSuggestions: prefix='$prefix' → ${suggestions.size} results: $suggestions")
val result = if (prefix.isNotEmpty()) engine.suggest(prefix, strip.maxSuggestions) else SuggestionEngine.Suggestions.NONE
val suggestions = result.words
suggestionsMatched = result.matched
if (BuildConfig.DEBUG) {
Log.d(TAG, "updateSuggestions: prefix='$prefix' → ${suggestions.size} results for '${result.matched}': $suggestions")
}
// Only swap the chips inside the single top bar — never change its height. Empty list →
// blank middle; the bar stays one fixed height so the keyboard never jumps (Gboard flow).
if (mode == SuggestionMode.LIVE) {
Expand Down
Loading
Loading