Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c4807ca
feat(ime): add raycast snippet model and dynamic expander
Balajitechlabs Sep 25, 2026
a97ccfc
feat(ime): integrate styled snippets into keyboard suggestion bar
Balajitechlabs Sep 25, 2026
3304268
feat(a11y): add universal snippet expansion and floating pill for ext…
Balajitechlabs Sep 25, 2026
81d10f3
feat(settings): add universal keyboard toggles in snippets management…
Balajitechlabs Sep 25, 2026
c8769a0
feat(settings): wire snippets management sheet into keyboard settings UI
Balajitechlabs Sep 25, 2026
bb65172
refactor(ui): align snippets sheet and floating pill with essentials …
Balajitechlabs Sep 25, 2026
aabde71
feat(a11y): add dynamic ime window tracking and gboard window event p…
Balajitechlabs Sep 25, 2026
c1ed66a
feat(duo): add animated drop-down pill and touch hitbox for snippets
Balajitechlabs Sep 25, 2026
cedb395
fix(snippets): prevent keyboard input obstruction and improve touch e…
Balajitechlabs Sep 25, 2026
63a91a3
feat(snippets): add smart display mode recommendation and settings in…
Balajitechlabs Sep 25, 2026
384f730
feat(island): wire snippet plugin, controller tap handling, and keybo…
Balajitechlabs Sep 25, 2026
e7aa154
feat(island): support custom plugin long-press overrides without auto…
Balajitechlabs Sep 25, 2026
e98cae7
feat(snippets): add dynamic island expanded card and clipboard copy o…
Balajitechlabs Sep 25, 2026
7792fe0
Merge branch 'develop' into feature/ime-snippets
Balajitechlabs Sep 26, 2026
ea0fbf5
chore: remove unused string resources for Turkish and Spanish transla…
sameerasw Oct 11, 2026
2a0613e
Merge branch 'develop' into feature/ime-snippets
Balajitechlabs Oct 11, 2026
3b2ef22
Merge branch 'develop' into feature/ime-snippets
Balajitechlabs Oct 11, 2026
e4fdac0
fix(ci): resolve onLongPress type mismatch and restore missing watchf…
Balajitechlabs Oct 11, 2026
e4ad22f
fix(snippets): protect password inputs, fix duo keyword format, and h…
Balajitechlabs Oct 11, 2026
41f7a61
refactor: clear secure settings cache on tile start listening
sameerasw Oct 11, 2026
89e2ca7
Merge branch 'develop' into feature/ime-snippets
Balajitechlabs Oct 11, 2026
ae72ef1
fix(snippets): resolve review blockers on privacy, expansion safety, …
Balajitechlabs Oct 11, 2026
036eeb0
Merge branch 'develop' into feature/ime-snippets
Balajitechlabs Oct 18, 2026
7eb0b47
Merge branch 'develop' into feature/ime-snippets
Balajitechlabs Oct 18, 2026
bdc6201
refactor(snippets): redesign engine for Raycast-style shortcuts, safe…
Balajitechlabs Oct 18, 2026
431d3bc
fix(snippets): dynamically register typeViewTextChanged only when opt…
Balajitechlabs Oct 18, 2026
0b7a2c6
fix(snippets): harden atomic recovery, clipboard isolation, and curso…
Balajitechlabs Oct 18, 2026
428b628
feat(snippets): add silent mode option and refine trigger matching
Balajitechlabs Oct 18, 2026
4c2030c
perf(snippets): cache IME lookup, harden password detection, and add …
Balajitechlabs Oct 18, 2026
e9e6f74
Merge remote-tracking branch 'origin/develop' into feature/ime-snippets
Balajitechlabs Oct 18, 2026
a5bebbb
fix(snippets): resolve search box accessibility expansion and IPC nod…
Balajitechlabs Oct 18, 2026
c7c30b0
fix(snippets): harden widget detection, reactive pill expansion, and …
Balajitechlabs Oct 18, 2026
013aadc
feat(snippets): gate external keyboard behind accessibility permissio…
Balajitechlabs Oct 18, 2026
678b884
fix(snippets): remove static typeViewTextChanged from xml in favor of…
Balajitechlabs Oct 18, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,17 @@ class SettingsRepository(
const val KEY_SIM_NAMES_APPLY_ON_BOOT = "sim_names_apply_on_boot"
const val KEY_POWER_SAVING_APPLY_ON_BOOT = "power_saving_apply_on_boot"
const val KEY_UNSPLASH_ACCESS_KEY = "unsplash_access_key"

// Snippets Universal
const val KEY_SNIPPETS_UNIVERSAL_ENABLED = "snippets_universal_enabled"
const val KEY_SNIPPETS_UNIVERSAL_AUTO_EXPAND = "snippets_universal_auto_expand"
const val KEY_SNIPPETS_UNIVERSAL_FLOATING_PILL = "snippets_universal_floating_pill"
const val KEY_SNIPPETS_SUGGESTION_DISPLAY_MODE = "snippets_suggestion_display_mode"
const val SNIPPETS_DISPLAY_NONE = "none"
const val SNIPPETS_DISPLAY_FLOATING_PILL = "pill"
const val SNIPPETS_DISPLAY_DYNAMIC_ISLAND = "island"
const val SNIPPETS_DISPLAY_DUO = "duo"
const val SNIPPETS_DISPLAY_BOTH = "both"
const val KEY_EXTERNAL_CONTROL_TOKEN = "external_control_token"
}

Expand Down Expand Up @@ -4548,6 +4559,35 @@ class SettingsRepository(
fun getStatusGlanceLongPressAction(): Action? = getRemapAction(KEY_STATUS_GLANCE_LONG_PRESS_ACTION)
fun setStatusGlanceLongPressAction(action: Action?) = setRemapAction(KEY_STATUS_GLANCE_LONG_PRESS_ACTION, action)

fun isSnippetsUniversalEnabled(): Boolean = getBoolean(KEY_SNIPPETS_UNIVERSAL_ENABLED, false)
fun setSnippetsUniversalEnabled(enabled: Boolean) = putBoolean(KEY_SNIPPETS_UNIVERSAL_ENABLED, enabled)

fun isSnippetsUniversalAutoExpandEnabled(): Boolean = getBoolean(KEY_SNIPPETS_UNIVERSAL_AUTO_EXPAND, true)
fun setSnippetsUniversalAutoExpandEnabled(enabled: Boolean) = putBoolean(KEY_SNIPPETS_UNIVERSAL_AUTO_EXPAND, enabled)

fun isSnippetsUniversalFloatingPillEnabled(): Boolean = getBoolean(KEY_SNIPPETS_UNIVERSAL_FLOATING_PILL, true)
fun setSnippetsUniversalFloatingPillEnabled(enabled: Boolean) = putBoolean(KEY_SNIPPETS_UNIVERSAL_FLOATING_PILL, enabled)

fun getRecommendedSnippetsSuggestionDisplayMode(): String {
val islandOn = isIslandEnabled()
val duoOn = isDuoEnabled()
return when {
islandOn && duoOn -> SNIPPETS_DISPLAY_BOTH
islandOn -> SNIPPETS_DISPLAY_DYNAMIC_ISLAND
duoOn -> SNIPPETS_DISPLAY_DUO
else -> SNIPPETS_DISPLAY_FLOATING_PILL
}
}

fun getSnippetsSuggestionDisplayMode(): String {
val stored = getString(KEY_SNIPPETS_SUGGESTION_DISPLAY_MODE, null)
if (stored != null) return stored
return getRecommendedSnippetsSuggestionDisplayMode()
}

fun setSnippetsSuggestionDisplayMode(mode: String) =
putString(KEY_SNIPPETS_SUGGESTION_DISPLAY_MODE, mode)

// Home screen shortcuts only carry an id; the action itself never leaves the app
fun getPinnedAction(id: String): Action? = getRemapAction(KEY_PINNED_ACTION_PREFIX + id)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.sameerasw.essentials.data.repository.SettingsRepository
import com.sameerasw.essentials.ime.snippets.SnippetExpander
import com.sameerasw.essentials.ime.snippets.SnippetRepository
import com.sameerasw.essentials.ui.ime.KeyboardInputView
import com.sameerasw.essentials.ui.theme.EssentialsTheme
import kotlinx.coroutines.Job
Expand Down Expand Up @@ -462,12 +464,49 @@ class EssentialsInputMethodService :
undoRedoManager.recordDelete(lastWord)
ic.deleteSurroundingText(lastWord.length, 0)
}
undoRedoManager.recordInsert(word + " ")
ic.commitText(word + " ", 1)
val insertText =
if (suggestion.snippet != null) {
SnippetExpander.expand(
suggestion.snippet.content,
this@EssentialsInputMethodService,
)
} else {
word + " "
}
undoRedoManager.recordInsert(insertText)
ic.commitText(insertText, 1)
suggestionEngine.clearSuggestions()
}
},
onType = { text ->
if (text == " ") {
val ic = currentInputConnection
if (ic != null) {
val textBefore = ic.getTextBeforeCursor(50, 0)?.toString() ?: ""
val lastWord = textBefore.split(Regex("\\s+")).lastOrNull() ?: ""
if (lastWord.isNotEmpty()) {
val matching =
SnippetRepository.getInstance(this@EssentialsInputMethodService)
.snippets.value.firstOrNull {
it.autoExpandOnSpace &&
it.keyword.equals(lastWord, ignoreCase = true)
}
if (matching != null) {
undoRedoManager.recordDelete(lastWord)
ic.deleteSurroundingText(lastWord.length, 0)
val expanded =
SnippetExpander.expand(
matching.content,
this@EssentialsInputMethodService,
)
undoRedoManager.recordInsert(expanded + " ")
ic.commitText(expanded + " ", 1)
suggestionEngine.clearSuggestions()
return@KeyboardInputView
}
}
}
}
undoRedoManager.recordInsert(text)
currentInputConnection?.commitText(text, 1)
if (isUserDictionaryEnabled && text.length == 1 && !text[0].isLetterOrDigit()) {
Expand Down Expand Up @@ -715,7 +754,7 @@ class EssentialsInputMethodService :
val textBefore = ic.getTextBeforeCursor(50, 0)?.toString()
if (!textBefore.isNullOrEmpty()) {
val lastWord = textBefore.split(Regex("\\s+")).lastOrNull() ?: ""
if (lastWord.isNotEmpty() && lastWord.all { it.isLetter() }) {
if (lastWord.isNotEmpty()) {
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.Default) {
suggestionEngine.lookup(lastWord)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,21 @@ enum class SuggestionType {
Prediction,
Correction,
Learned,
Snippet,
}

@androidx.annotation.Keep
data class Suggestion(
val text: String,
val type: SuggestionType,
val snippet: com.sameerasw.essentials.ime.snippets.Snippet? = null,
)

class SuggestionEngine(
private val context: Context,
) : SpellCheckerSession.SpellCheckerSessionListener {
private val snippetRepository =
com.sameerasw.essentials.ime.snippets.SnippetRepository.getInstance(context)
private val userDictFile = File(context.filesDir, "user_dict.txt")
private val userWords = mutableMapOf<String, Long>()

Expand All @@ -63,6 +67,7 @@ class SuggestionEngine(
// Current word being looked up to handle sync/async merging
private var currentWord: String = ""
private var currentSymSpellSuggestions: List<Suggestion> = emptyList()
private var currentMatchingSnippets: List<Suggestion> = emptyList()

fun initialize(scope: CoroutineScope) {
// Init Android Session (Main Thread)
Expand Down Expand Up @@ -194,10 +199,22 @@ class SuggestionEngine(
return@withContext
}

// 1. SymSpell (Fast, Predictive)
// 0. Snippets (Instant, Highest Priority)
val matchingSnippets =
snippetRepository.findMatching(word).map { snippet ->
Suggestion(
text = snippet.title.ifBlank { snippet.keyword },
type = SuggestionType.Snippet,
snippet = snippet,
)
}

currentMatchingSnippets = matchingSnippets

// 1. SymSpell (Fast, Predictive - only for letter words)
// Run on default dispatcher but wait for result to show immediately
val symResults =
if (isSymSpellReady) {
if (isSymSpellReady && word.all { it.isLetter() }) {
withContext(Dispatchers.Default) {
try {
// Max edit distance 2.0 for fuzzy
Expand All @@ -208,7 +225,7 @@ class SuggestionEngine(
val type =
if (userWords.containsKey(term)) SuggestionType.Learned else SuggestionType.Prediction
Suggestion(term, type)
}?.distinctBy { it.text }
}?.distinctBy { it.text.lowercase() }
?.take(6) ?: emptyList()
} catch (e: Exception) {
emptyList<Suggestion>()
Expand All @@ -219,12 +236,12 @@ class SuggestionEngine(
}

currentSymSpellSuggestions = symResults
// Update immediately with SymSpell results (Native usually takes longer)
_suggestions.value = symResults
val uniqueSymResults = symResults.distinctBy { it.text.lowercase() }
_suggestions.value = matchingSnippets + uniqueSymResults

// 2. Android (Corrective, Slower)
// 2. Android (Corrective, Slower - only for letter words)
val s = session
if (s != null) {
if (s != null && word.all { it.isLetter() }) {
try {
// Request suggestions. The callback onGetSuggestions will merge results
@Suppress("DEPRECATION")
Expand All @@ -236,15 +253,9 @@ class SuggestionEngine(
}

override fun onGetSuggestions(results: Array<out SuggestionsInfo>?) {
// Runs on binder thread usually, switch to Main logic if needed, but StateFlow is thread safe.
// We want to merge with currentSymSpellSuggestions

if (results.isNullOrEmpty()) return
if (results.isNullOrEmpty() || currentWord.isBlank()) return

val info = results[0]
// info.cookie or sequence is implementation dependent, assuming it matches 'currentWord' roughly
// Ideally we check validity but for simplicity we merge.

val androidSuggestions = mutableListOf<String>()
val count = info.suggestionsCount
if (count > 0) {
Expand All @@ -253,14 +264,14 @@ class SuggestionEngine(
}
}

// Merge: SymSpell (Prediction) + Android (Correction)
// Deduplicate
// Merge: Snippets + SymSpell (Prediction) + Android (Correction)
// Deduplicate dictionary suggestions without dropping snippets
val androidSuggestionsList =
androidSuggestions.map { Suggestion(it, SuggestionType.Correction) }
val merged =
val uniqueDictSuggestions =
(currentSymSpellSuggestions + androidSuggestionsList)
.distinctBy { it.text }
.take(8)
.distinctBy { it.text.lowercase() }
val merged = (currentMatchingSnippets + uniqueDictSuggestions).take(8)

_suggestions.value = merged
}
Expand All @@ -270,6 +281,8 @@ class SuggestionEngine(
fun clearSuggestions() {
_suggestions.value = emptyList()
currentSymSpellSuggestions = emptyList()
currentMatchingSnippets = emptyList()
currentWord = ""
}

fun getUserWords() = userWords.toMap()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 sameerasw.com
* License: MIT License
*
* Feature Module: IME - Snippets
* File: Snippet.kt
* Description: Data model representing a text replacement snippet.
*/

package com.sameerasw.essentials.ime.snippets

import androidx.annotation.Keep
import java.util.UUID

@Keep
data class Snippet(
val id: String = UUID.randomUUID().toString(),
val title: String,
val keyword: String,
val content: String,
val autoExpandOnSpace: Boolean = true,
val createdAt: Long = System.currentTimeMillis(),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2026 sameerasw.com
* License: MIT License
*
* Feature Module: IME - Snippets
* File: SnippetExpander.kt
* Description: Evaluates dynamic placeholder tags inside snippets.
*/

package com.sameerasw.essentials.ime.snippets

import android.content.ClipboardManager
import android.content.Context
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.Locale

object SnippetExpander {
private val DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.getDefault())
private val TIME_FORMAT = DateTimeFormatter.ofPattern("h:mm a", Locale.getDefault())

fun expand(template: String, context: Context?): String {
var result = template
if (result.contains("{date}", ignoreCase = true)) {
result = result.replace("{date}", LocalDate.now().format(DATE_FORMAT), ignoreCase = true)
}
if (result.contains("{time}", ignoreCase = true)) {
result = result.replace("{time}", LocalTime.now().format(TIME_FORMAT), ignoreCase = true)
}
if (result.contains("{clipboard}", ignoreCase = true)) {
val clipText = context?.let { getClipboardText(it) } ?: ""
result = result.replace("{clipboard}", clipText, ignoreCase = true)
}
return result
}

private fun getClipboardText(context: Context): String {
return try {
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
val clip = cm?.primaryClip
if (clip != null && clip.itemCount > 0) {
clip.getItemAt(0)?.text?.toString().orEmpty()
} else {
""
}
} catch (_: Exception) {
""
}
}
}
Loading
Loading