Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ import com.sameerasw.essentials.domain.model.NotificationLightingSweepPosition
import com.sameerasw.essentials.domain.model.PowerChoice
import com.sameerasw.essentials.domain.model.RemapSlot
import com.sameerasw.essentials.domain.model.ScaleAnimationsProfile
import com.sameerasw.essentials.domain.model.Snippet
import com.sameerasw.essentials.domain.model.SystemShortcutsState
import com.sameerasw.essentials.services.tiles.ScreenOffAccessibilityService
import java.util.UUID
import com.sameerasw.essentials.domain.model.TrackedRepo
import com.sameerasw.essentials.domain.model.github.GitHubUser
import com.sameerasw.essentials.utils.RootUtils
Expand Down Expand Up @@ -198,6 +201,12 @@ class SettingsRepository(
companion object {
const val PREFS_NAME = "essentials_prefs"

@Volatile
private var cachedSnippets: List<Snippet>? = null

@Volatile
private var cachedPrefix: String? = null

// Keys
const val KEY_DEBUGGING_TILE_TAP_ACTION = "debugging_tile_tap_action"
const val KEY_GENAI_AUTOMATION_ENABLED = "genai_automation_enabled"
Expand Down Expand Up @@ -403,6 +412,9 @@ class SettingsRepository(
const val KEY_KEYBOARD_CLIPBOARD_ENABLED = "keyboard_clipboard_enabled"
const val KEY_KEYBOARD_LONG_PRESS_SYMBOLS = "keyboard_long_press_symbols"
const val KEY_KEYBOARD_ACCENTED_CHARACTERS = "keyboard_accented_characters"
const val KEY_KEYBOARD_TEXT_SNIPPETS_ENABLED = "keyboard_text_snippets_enabled"
const val KEY_KEYBOARD_SNIPPET_PREFIX = "keyboard_snippet_prefix"
const val KEY_KEYBOARD_SNIPPETS_DATA = "keyboard_snippets_data"

// Essentials-AirSync Bridge
const val KEY_AIRSYNC_CONNECTION_ENABLED = "airsync_connection_enabled"
Expand Down Expand Up @@ -4883,4 +4895,46 @@ class SettingsRepository(
} ?: emptyMap()

fun setAppVolumeLevels(levels: Map<String, Float>) = putString(KEY_APP_VOLUME_LEVELS, gson.toJson(levels))

fun isTextSnippetsEnabled(): Boolean = getBoolean(KEY_KEYBOARD_TEXT_SNIPPETS_ENABLED, false)
fun setTextSnippetsEnabled(enabled: Boolean) {
putBoolean(KEY_KEYBOARD_TEXT_SNIPPETS_ENABLED, enabled)
ScreenOffAccessibilityService.updateSnippetTextListening(enabled)
}

fun getSnippetPrefix(): String {
cachedPrefix?.let { return it }
val prefix = getString(KEY_KEYBOARD_SNIPPET_PREFIX, ";") ?: ";"
cachedPrefix = prefix
return prefix
}

fun setSnippetPrefix(prefix: String) {
cachedPrefix = prefix
putString(KEY_KEYBOARD_SNIPPET_PREFIX, prefix)
}

fun getTextSnippets(): List<Snippet> {
cachedSnippets?.let { return it }
val json = getString(KEY_KEYBOARD_SNIPPETS_DATA, null) ?: return defaultSnippets().also { cachedSnippets = it }
val list =
try {
val type = object : TypeToken<List<Snippet>>() {}.type
gson.fromJson<List<Snippet>>(json, type) ?: defaultSnippets()
} catch (_: Exception) {
defaultSnippets()
}
cachedSnippets = list
return list
}

fun saveTextSnippets(snippets: List<Snippet>) {
cachedSnippets = snippets
putString(KEY_KEYBOARD_SNIPPETS_DATA, gson.toJson(snippets))
}

fun defaultSnippets(): List<Snippet> =
listOf(
Snippet(id = UUID.randomUUID().toString(), keyword = "shrug", title = "Shrug", content = "¯\\_(ツ)_/¯"),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 sameerasw.com
* License: MIT License
*
* Feature Module: Domain Model Layer
* File: Snippet.kt
* Description: Data class representing an expandable text snippet with trigger keyword.
*/

package com.sameerasw.essentials.domain.model

import java.util.UUID

data class Snippet(
val id: String = UUID.randomUUID().toString(),
val keyword: String,
val title: String = "",
val content: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ fun initPermissionRegistry() {
PermissionRegistry.register("ACCESSIBILITY", R.string.feat_status_glance_title)
PermissionRegistry.register("READ_CALENDAR", R.string.feat_status_glance_title)
PermissionRegistry.register("ACCESSIBILITY", R.string.feat_conscious_gate_title)
PermissionRegistry.register("ACCESSIBILITY", R.string.feat_text_snippets_title)

// Write secure settings permission
PermissionRegistry.register("WRITE_SECURE_SETTINGS", R.string.feat_statusbar_icons_title)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ class EssentialsInputMethodService :
// Undo Manager
private val undoRedoManager = UndoRedoManager()

// Settings & Snippet Expansion Engine
private val settingsRepository by lazy { SettingsRepository(this) }
private val snippetEngine by lazy {
SnippetExpansionEngine(
getSnippets = { settingsRepository.getTextSnippets() },
getPrefix = { settingsRepository.getSnippetPrefix() },
isEnabled = { settingsRepository.isTextSnippetsEnabled() },
)
}

// Suggestion Lookup Job
private var lookupJob: Job? = null

Expand Down Expand Up @@ -470,8 +480,21 @@ class EssentialsInputMethodService :
onType = { text ->
undoRedoManager.recordInsert(text)
currentInputConnection?.commitText(text, 1)
if (isUserDictionaryEnabled && text.length == 1 && !text[0].isLetterOrDigit()) {
val ic = currentInputConnection

val ic = currentInputConnection
var expanded = false
if (ic != null) {
val textBefore = ic.getTextBeforeCursor(100, 0)?.toString() ?: ""
expanded =
snippetEngine.checkAndExpand(textBefore) { deleteCount, replacement ->
undoRedoManager.recordDelete(textBefore.takeLast(deleteCount))
ic.deleteSurroundingText(deleteCount, 0)
undoRedoManager.recordInsert(replacement)
ic.commitText(replacement, 1)
}
}

if (!expanded && isUserDictionaryEnabled && text.length == 1 && !text[0].isLetterOrDigit()) {
if (ic != null) {
val textBefore = ic.getTextBeforeCursor(50, 0)?.toString()
if (!textBefore.isNullOrEmpty()) {
Expand Down Expand Up @@ -618,6 +641,7 @@ class EssentialsInputMethodService :
restarting: Boolean,
) {
super.onStartInputView(info, restarting)
isInputActive = true
// Ensure lifecycle is in RESUMED state when view becomes visible
if (lifecycleRegistry.currentState != Lifecycle.State.RESUMED) {
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
Expand All @@ -628,12 +652,14 @@ class EssentialsInputMethodService :

override fun onFinishInputView(finishingInput: Boolean) {
super.onFinishInputView(finishingInput)
isInputActive = false
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
kbdResetTrigger.value++
}

override fun onDestroy() {
super.onDestroy()
isInputActive = false
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
if (::clipboardManager.isInitialized) {
Expand Down Expand Up @@ -758,4 +784,10 @@ class EssentialsInputMethodService :
_clipboardHistory.value = current
}
}

companion object {
@Volatile
var isInputActive: Boolean = false
internal set
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2026 sameerasw.com
* License: MIT License
*
* Feature Module: Ime
* File: SnippetExpansionEngine.kt
* Description: Engine for detecting snippet prefixes/keywords and expanding text.
*/

package com.sameerasw.essentials.ime

import com.sameerasw.essentials.domain.model.Snippet

class SnippetExpansionEngine(
private val getSnippets: () -> List<Snippet>,
private val getPrefix: () -> String,
private val isEnabled: () -> Boolean,
) {
fun checkAndExpand(
textBeforeCursor: String,
onExpand: (deleteCount: Int, replacement: String) -> Unit,
): Boolean {
if (!isEnabled() || textBeforeCursor.isEmpty()) return false
val prefix = getPrefix()
val snippets = getSnippets()
if (snippets.isEmpty()) return false

val sorted = snippets.sortedByDescending { it.keyword.length }

for (snippet in sorted) {
if (snippet.keyword.isEmpty()) continue

if (prefix.isNotEmpty()) {
val trigger = "$prefix${snippet.keyword}"
val hasDelimiter = textBeforeCursor.endsWith(" ") || textBeforeCursor.endsWith("\t")
val candidate = if (hasDelimiter) textBeforeCursor.dropLast(1) else textBeforeCursor

if (candidate.endsWith(trigger, ignoreCase = true)) {
val triggerStart = candidate.length - trigger.length
val isBoundary = triggerStart == 0 || candidate[triggerStart - 1].isWhitespace()
if (!isBoundary) continue

if (!hasDelimiter) {
val hasLongerSibling = sorted.any {
it.keyword.length > snippet.keyword.length &&
it.keyword.startsWith(snippet.keyword, ignoreCase = true)
}
if (hasLongerSibling) continue
}

val matchLen = if (hasDelimiter) trigger.length + 1 else trigger.length
val replacement = if (hasDelimiter) snippet.content + " " else snippet.content
onExpand(matchLen, replacement)
return true
}
} else {
val lastChar = textBeforeCursor.last()
if (lastChar.isWhitespace()) {
val trimmed = textBeforeCursor.dropLast(1)
if (trimmed.endsWith(snippet.keyword, ignoreCase = true)) {
val beforeIndex = trimmed.length - snippet.keyword.length - 1
val isBoundary = beforeIndex < 0 || trimmed[beforeIndex].isWhitespace()
if (isBoundary) {
onExpand(snippet.keyword.length + 1, snippet.content + lastChar)
return true
}
}
}
}
}
return false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* Copyright (c) 2026 sameerasw.com
* License: MIT License
*
* Feature Module: Background Services & Receivers
* File: SnippetExpansionHandler.kt
* Description: Accessibility event handler for expanding text snippets across any input method or app.
*/

package com.sameerasw.essentials.services.handlers

import android.accessibilityservice.AccessibilityService
import android.os.Build
import android.os.Bundle
import android.text.InputType
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import com.sameerasw.essentials.data.repository.SettingsRepository
import com.sameerasw.essentials.ime.EssentialsInputMethodService
import com.sameerasw.essentials.ime.SnippetExpansionEngine

class SnippetExpansionHandler(
private val service: AccessibilityService,
) {
private val settings = SettingsRepository(service)
@Volatile private var expectedSelfText: String? = null

private val snippetEngine =
SnippetExpansionEngine(
getSnippets = { settings.getTextSnippets() },
getPrefix = { settings.getSnippetPrefix() },
isEnabled = { settings.isTextSnippetsEnabled() },
)

fun onTextChanged(event: AccessibilityEvent) {
if (!settings.isTextSnippetsEnabled()) return
if (EssentialsInputMethodService.isInputActive) return
if (event.isPassword) return

val source = event.source ?: return
if (source.isPassword || !source.isEditable) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && source.isShowingHintText) return

val inputType = source.inputType
val isPasswordType =
(inputType and InputType.TYPE_MASK_VARIATION) == InputType.TYPE_TEXT_VARIATION_PASSWORD ||
(inputType and InputType.TYPE_MASK_VARIATION) == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD ||
(inputType and InputType.TYPE_MASK_VARIATION) == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD ||
((inputType and InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_NUMBER &&
(inputType and InputType.TYPE_MASK_VARIATION) == InputType.TYPE_NUMBER_VARIATION_PASSWORD)
if (isPasswordType) return

val text = source.text?.toString() ?: return
if (text.isEmpty()) return

if (expectedSelfText != null) {
if (text == expectedSelfText) {
expectedSelfText = null
return
}
}

var cursor = source.textSelectionEnd
if (cursor < 0 || cursor > text.length) {
cursor = text.length
}

val textBeforeCursor = text.substring(0, cursor)

snippetEngine.checkAndExpand(textBeforeCursor) { deleteCount, replacement ->
val start = cursor - deleteCount
if (start < 0) return@checkAndExpand

val newText = text.substring(0, start) + replacement + text.substring(cursor)
val newCursor = start + replacement.length

val supportsSetText = source.actionList.any { it.id == AccessibilityNodeInfo.ACTION_SET_TEXT }
if (!supportsSetText) return@checkAndExpand

expectedSelfText = newText
val args =
Bundle().apply {
putCharSequence(
AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
newText,
)
}
val setSuccess = source.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
if (setSuccess) {
val selArgs =
Bundle().apply {
putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, newCursor)
putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, newCursor)
}
source.performAction(AccessibilityNodeInfo.ACTION_SET_SELECTION, selArgs)
} else {
expectedSelfText = null
}
}
}
}
Loading
Loading