From c4807cafeb5df523f12303de9234b11b1235b0c5 Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:35:11 +0530 Subject: [PATCH 01/27] feat(ime): add raycast snippet model and dynamic expander --- .../essentials/ime/snippets/Snippet.kt | 23 ++++ .../ime/snippets/SnippetExpander.kt | 48 +++++++ .../ime/snippets/SnippetRepository.kt | 125 ++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 app/src/main/java/com/sameerasw/essentials/ime/snippets/Snippet.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetExpander.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt diff --git a/app/src/main/java/com/sameerasw/essentials/ime/snippets/Snippet.kt b/app/src/main/java/com/sameerasw/essentials/ime/snippets/Snippet.kt new file mode 100644 index 0000000000..6bc3edf3b9 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ime/snippets/Snippet.kt @@ -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(), +) diff --git a/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetExpander.kt b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetExpander.kt new file mode 100644 index 0000000000..f7516876b7 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetExpander.kt @@ -0,0 +1,48 @@ +/* + * 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.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object SnippetExpander { + private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + private val TIME_FORMAT = SimpleDateFormat("h:mm a", Locale.getDefault()) + + /** + * Replaces dynamic placeholders such as {date}, {time}, and {clipboard} with actual values. + */ + fun expand(template: String, context: Context?): String { + var result = template + if (result.contains("{date}", ignoreCase = true)) { + result = result.replace("{date}", DATE_FORMAT.format(Date()), ignoreCase = true) + } + if (result.contains("{time}", ignoreCase = true)) { + result = result.replace("{time}", TIME_FORMAT.format(Date()), 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 + cm?.primaryClip?.getItemAt(0)?.text?.toString().orEmpty() + } catch (_: Exception) { + "" + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt new file mode 100644 index 0000000000..596af7f3f7 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: IME - Snippets + * File: SnippetRepository.kt + * Description: Repository for persisting and managing custom snippets. + */ + +package com.sameerasw.essentials.ime.snippets + +import android.content.Context +import android.util.Log +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream + +class SnippetRepository private constructor(context: Context) { + private val appContext = context.applicationContext + private val scope = CoroutineScope(Dispatchers.IO) + private val gson = Gson() + private val storageFile = File(appContext.filesDir, "snippets.json") + + private val _snippets = MutableStateFlow>(emptyList()) + val snippets: StateFlow> = _snippets.asStateFlow() + + init { + loadSnippets() + } + + private fun loadSnippets() { + scope.launch { + try { + if (!storageFile.exists()) { + val defaults = listOf( + Snippet( + title = "Current Date", + keyword = "!date", + content = "{date}", + ), + Snippet( + title = "Quote Clipboard", + keyword = "!quote", + content = "> {clipboard}", + ), + ) + saveToFile(defaults) + _snippets.value = defaults + return@launch + } + + val json = storageFile.readText() + val type = object : TypeToken>() {}.type + val loaded: List? = gson.fromJson(json, type) + _snippets.value = loaded ?: emptyList() + } catch (e: Exception) { + Log.e(TAG, "Error loading snippets", e) + } + } + } + + private suspend fun saveToFile(items: List) = withContext(Dispatchers.IO) { + try { + val json = gson.toJson(items) + FileOutputStream(storageFile).use { fos -> + fos.write(json.toByteArray(Charsets.UTF_8)) + } + } catch (e: Exception) { + Log.e(TAG, "Error saving snippets", e) + } + } + + fun addSnippet(snippet: Snippet) { + val updated = _snippets.value + snippet + _snippets.value = updated + scope.launch { saveToFile(updated) } + } + + fun updateSnippet(snippet: Snippet) { + val updated = _snippets.value.map { if (it.id == snippet.id) snippet else it } + _snippets.value = updated + scope.launch { saveToFile(updated) } + } + + fun deleteSnippet(id: String) { + val updated = _snippets.value.filterNot { it.id == id } + _snippets.value = updated + scope.launch { saveToFile(updated) } + } + + /** + * Finds snippets matching the current typed word prefix or title. + */ + fun findMatching(word: String): List { + val query = word.trim() + if (query.isEmpty()) return emptyList() + + return _snippets.value.filter { snippet -> + snippet.keyword.equals(query, ignoreCase = true) || + snippet.keyword.startsWith(query, ignoreCase = true) || + (query.length >= 2 && snippet.title.contains(query, ignoreCase = true)) + } + } + + companion object { + private const val TAG = "SnippetRepository" + + @Volatile + private var INSTANCE: SnippetRepository? = null + + fun getInstance(context: Context): SnippetRepository { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: SnippetRepository(context).also { INSTANCE = it } + } + } + } +} From a97ccfc0cbdc9084ddb4041bf774a4d74ca18096 Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:35:20 +0530 Subject: [PATCH 02/27] feat(ime): integrate styled snippets into keyboard suggestion bar --- .../ime/EssentialsInputMethodService.kt | 45 ++++++++++++++++-- .../essentials/ime/SuggestionEngine.kt | 33 +++++++++---- .../essentials/ui/ime/KeyboardInputView.kt | 46 +++++++++++++------ .../res/drawable/rounded_text_snippet_24.xml | 9 ++++ 4 files changed, 108 insertions(+), 25 deletions(-) create mode 100644 app/src/main/res/drawable/rounded_text_snippet_24.xml diff --git a/app/src/main/java/com/sameerasw/essentials/ime/EssentialsInputMethodService.kt b/app/src/main/java/com/sameerasw/essentials/ime/EssentialsInputMethodService.kt index 6240cf8863..8f33bcaad5 100644 --- a/app/src/main/java/com/sameerasw/essentials/ime/EssentialsInputMethodService.kt +++ b/app/src/main/java/com/sameerasw/essentials/ime/EssentialsInputMethodService.kt @@ -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 @@ -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()) { @@ -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) } diff --git a/app/src/main/java/com/sameerasw/essentials/ime/SuggestionEngine.kt b/app/src/main/java/com/sameerasw/essentials/ime/SuggestionEngine.kt index 9da571fdbf..5726f32221 100644 --- a/app/src/main/java/com/sameerasw/essentials/ime/SuggestionEngine.kt +++ b/app/src/main/java/com/sameerasw/essentials/ime/SuggestionEngine.kt @@ -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() @@ -194,10 +198,20 @@ 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, + ) + } + + // 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 @@ -219,12 +233,12 @@ class SuggestionEngine( } currentSymSpellSuggestions = symResults - // Update immediately with SymSpell results (Native usually takes longer) - _suggestions.value = symResults + // Update immediately with Snippets + SymSpell results (Native usually takes longer) + _suggestions.value = (matchingSnippets + symResults).distinctBy { it.text } - // 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") @@ -237,7 +251,7 @@ class SuggestionEngine( override fun onGetSuggestions(results: Array?) { // Runs on binder thread usually, switch to Main logic if needed, but StateFlow is thread safe. - // We want to merge with currentSymSpellSuggestions + // We want to merge with currentSymSpellSuggestions and keep snippet suggestions if (results.isNullOrEmpty()) return @@ -253,12 +267,13 @@ class SuggestionEngine( } } - // Merge: SymSpell (Prediction) + Android (Correction) + // Merge: Snippets + SymSpell (Prediction) + Android (Correction) // Deduplicate + val snippetSuggestions = _suggestions.value.filter { it.type == SuggestionType.Snippet } val androidSuggestionsList = androidSuggestions.map { Suggestion(it, SuggestionType.Correction) } val merged = - (currentSymSpellSuggestions + androidSuggestionsList) + (snippetSuggestions + currentSymSpellSuggestions + androidSuggestionsList) .distinctBy { it.text } .take(8) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/ime/KeyboardInputView.kt b/app/src/main/java/com/sameerasw/essentials/ui/ime/KeyboardInputView.kt index 4f578aae6d..55e763c8de 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/ime/KeyboardInputView.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/ime/KeyboardInputView.kt @@ -781,6 +781,7 @@ fun KeyboardInputView( ) { i -> val suggestion = mergedSuggestions[i] val isLearned = suggestion.type == SuggestionType.Learned + val isSnippet = suggestion.type == SuggestionType.Snippet val suggInteraction = remember { MutableInteractionSource() } val animatedRadius by animateDpAsState( targetValue = keyRoundness, @@ -797,14 +798,20 @@ fun KeyboardInputView( onPress = { performLightHaptic() }, interactionSource = suggInteraction, containerColor = - if (isLearned) { - MaterialTheme.colorScheme.secondaryContainer + when { + isSnippet -> MaterialTheme.colorScheme.tertiaryContainer + isLearned -> MaterialTheme.colorScheme.secondaryContainer + else -> + MaterialTheme.colorScheme.secondaryContainer.copy( + alpha = 0.7f, + ) + }, + contentColor = + if (isSnippet) { + MaterialTheme.colorScheme.onTertiaryContainer } else { - MaterialTheme.colorScheme.secondaryContainer.copy( - alpha = 0.7f, - ) + MaterialTheme.colorScheme.onSecondaryContainer }, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, shape = RoundedCornerShape(animatedRadius), modifier = Modifier @@ -812,13 +819,26 @@ fun KeyboardInputView( .fillMaxWidth() .maskClip(RoundedCornerShape(animatedRadius)), ) { - Text( - text = suggestion.text, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - fontFamily = CustomFontFamily, - maxLines = 1, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(horizontal = 8.dp), + ) { + if (isSnippet) { + Icon( + painter = painterResource(id = R.drawable.rounded_text_snippet_24), + contentDescription = "Snippet", + modifier = Modifier.size(16.dp), + ) + } + Text( + text = suggestion.text, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + fontFamily = CustomFontFamily, + maxLines = 1, + ) + } } } diff --git a/app/src/main/res/drawable/rounded_text_snippet_24.xml b/app/src/main/res/drawable/rounded_text_snippet_24.xml new file mode 100644 index 0000000000..0b85e4e2cc --- /dev/null +++ b/app/src/main/res/drawable/rounded_text_snippet_24.xml @@ -0,0 +1,9 @@ + + + From 33042685be1011522b0241d9cb27103d2b74c563 Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:35:26 +0530 Subject: [PATCH 03/27] feat(a11y): add universal snippet expansion and floating pill for external keyboards --- .../handlers/SnippetAccessibilityHandler.kt | 273 ++++++++++++++++++ .../tiles/ScreenOffAccessibilityService.kt | 20 ++ .../ime/snippets/SnippetFloatingPillView.kt | 139 +++++++++ .../res/xml/accessibility_service_config.xml | 2 +- 4 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/ime/snippets/SnippetFloatingPillView.kt diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt new file mode 100644 index 0000000000..584fdad5dd --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt @@ -0,0 +1,273 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Background Services & Receivers - Handlers + * File: SnippetAccessibilityHandler.kt + * Description: Accessibility service handler providing universal snippet expansion and floating pills across external keyboards. + */ + +package com.sameerasw.essentials.services.handlers + +import android.accessibilityservice.AccessibilityService +import android.content.Context +import android.graphics.PixelFormat +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.Gravity +import android.view.MotionEvent +import android.view.WindowManager +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import android.widget.FrameLayout +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.ime.snippets.Snippet +import com.sameerasw.essentials.ime.snippets.SnippetExpander +import com.sameerasw.essentials.ime.snippets.SnippetRepository +import com.sameerasw.essentials.island.service.OverlayLifecycleOwner +import com.sameerasw.essentials.ui.ime.snippets.SnippetFloatingPillView +import com.sameerasw.essentials.ui.theme.EssentialsTheme +import com.sameerasw.essentials.utils.HapticUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow + +class SnippetAccessibilityHandler( + private val service: AccessibilityService, + private val scope: CoroutineScope, +) { + companion object { + private const val TAG = "SnippetA11yHandler" + private const val AUTO_DISMISS_TIMEOUT_MS = 6000L + } + + private val windowManager = service.getSystemService(Context.WINDOW_SERVICE) as WindowManager + private val settingsRepository = SettingsRepository(service) + private val snippetRepository = SnippetRepository.getInstance(service) + private val mainHandler = Handler(Looper.getMainLooper()) + + private var isUniversalEnabled = settingsRepository.isSnippetsUniversalEnabled() + private var isAutoExpandEnabled = settingsRepository.isSnippetsUniversalAutoExpandEnabled() + private var isFloatingPillEnabled = settingsRepository.isSnippetsUniversalFloatingPillEnabled() + + private val currentMatchedSnippet = MutableStateFlow(null) + private var activeNode: AccessibilityNodeInfo? = null + private var lastTypedWord: String = "" + + private var overlayView: FrameLayout? = null + private var lifecycleOwner: OverlayLifecycleOwner? = null + + private val dismissRunnable = Runnable { hideFloatingPill() } + + fun updateSettings() { + isUniversalEnabled = settingsRepository.isSnippetsUniversalEnabled() + isAutoExpandEnabled = settingsRepository.isSnippetsUniversalAutoExpandEnabled() + isFloatingPillEnabled = settingsRepository.isSnippetsUniversalFloatingPillEnabled() + if (!isUniversalEnabled) { + hideFloatingPill() + } + } + + fun onTextChanged(event: AccessibilityEvent) { + if (!isUniversalEnabled) return + + val sourceNode = event.source ?: return + if (!sourceNode.isEditable) return + + activeNode = sourceNode + + val textList = event.text + val fullText = + if (textList.isNotEmpty()) { + textList.joinToString("") + } else { + sourceNode.text?.toString().orEmpty() + } + + if (fullText.isEmpty()) { + hideFloatingPill() + return + } + + // 1. Check for Option A: Space-based instant auto-expand + if (fullText.endsWith(" ") && isAutoExpandEnabled) { + val textBeforeSpace = fullText.dropLast(1) + val candidateWord = textBeforeSpace.substringAfterLast(' ', "").ifBlank { textBeforeSpace } + + if (candidateWord.isNotBlank()) { + val matchingSnippet = + snippetRepository.snippets.value.firstOrNull { snippet -> + snippet.keyword.equals(candidateWord, ignoreCase = false) + } + + if (matchingSnippet != null && matchingSnippet.autoExpandOnSpace) { + val expanded = SnippetExpander.expand(matchingSnippet.content, service) + replaceKeywordInNode( + node = sourceNode, + fullText = fullText, + targetWord = "$candidateWord ", + replacement = "$expanded ", + ) + hideFloatingPill() + return + } + } + } + + // 2. Check for Option B: Floating pill trigger + val currentWord = fullText.substringAfterLast(' ', "").ifBlank { fullText } + lastTypedWord = currentWord + + if (currentWord.isNotBlank() && isFloatingPillEnabled) { + val matches = snippetRepository.findMatching(currentWord) + if (matches.isNotEmpty()) { + showFloatingPill(matches.first()) + } else { + hideFloatingPill() + } + } else { + hideFloatingPill() + } + } + + private fun replaceKeywordInNode( + node: AccessibilityNodeInfo, + fullText: String, + targetWord: String, + replacement: String, + ) { + val targetIndex = fullText.lastIndexOf(targetWord) + if (targetIndex < 0) return + + val newText = + fullText.substring(0, targetIndex) + + replacement + + fullText.substring(targetIndex + targetWord.length) + + val args = + Bundle().apply { + putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, newText) + } + node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args) + + // Set cursor position at the end of replacement + val newCursorPos = targetIndex + replacement.length + val cursorArgs = + Bundle().apply { + putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, newCursorPos) + putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, newCursorPos) + } + node.performAction(AccessibilityNodeInfo.ACTION_SET_SELECTION, cursorArgs) + } + + private fun showFloatingPill(snippet: Snippet) { + mainHandler.post { + currentMatchedSnippet.value = snippet + mainHandler.removeCallbacks(dismissRunnable) + mainHandler.postDelayed(dismissRunnable, AUTO_DISMISS_TIMEOUT_MS) + + if (overlayView == null) { + val lifecycle = OverlayLifecycleOwner().also { it.onCreate() } + lifecycleOwner = lifecycle + + val composeView = + ComposeView(service).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) + setContent { + EssentialsTheme { + val currentSnippet = currentMatchedSnippet.collectAsState().value + if (currentSnippet != null) { + SnippetFloatingPillView( + snippet = currentSnippet, + onExpand = { + overlayView?.let { HapticUtil.performUIHaptic(it) } + activeNode?.let { node -> + val text = node.text?.toString().orEmpty() + val expanded = + SnippetExpander.expand(currentSnippet.content, service) + replaceKeywordInNode( + node = node, + fullText = text, + targetWord = lastTypedWord, + replacement = "$expanded ", + ) + } + hideFloatingPill() + }, + onDismiss = { hideFloatingPill() }, + ) + } + } + } + } + + val frame = + FrameLayout(service).apply { + setViewTreeLifecycleOwner(lifecycle) + setViewTreeSavedStateRegistryOwner(lifecycle) + setViewTreeViewModelStoreOwner(lifecycle) + addView(composeView) + } + + val density = service.resources.displayMetrics.density + val params = + WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or + WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH, + PixelFormat.TRANSLUCENT, + ).apply { + gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL + y = (290 * density).toInt() + } + + frame.setOnTouchListener { _, event -> + if (event.action == MotionEvent.ACTION_OUTSIDE) { + hideFloatingPill() + true + } else { + false + } + } + + overlayView = frame + try { + windowManager.addView(frame, params) + } catch (e: Exception) { + Log.e(TAG, "Failed to display snippet floating pill overlay", e) + overlayView = null + } + } + } + } + + fun hideFloatingPill() { + mainHandler.post { + mainHandler.removeCallbacks(dismissRunnable) + currentMatchedSnippet.value = null + val view = overlayView ?: return@post + overlayView = null + try { + windowManager.removeView(view) + } catch (_: Exception) { + } + lifecycleOwner?.onDestroy() + lifecycleOwner = null + } + } + + fun destroy() { + hideFloatingPill() + activeNode = null + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt index 606f88b492..5fa97c3ccf 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt @@ -77,6 +77,7 @@ class ScreenOffAccessibilityService : private lateinit var duoOverlayHandler: DuoOverlayHandler lateinit var islandOverlayHandler: IslandCoordinator private lateinit var statusGlanceHandler: StatusGlanceHandler + private lateinit var snippetAccessibilityHandler: com.sameerasw.essentials.services.handlers.SnippetAccessibilityHandler private var lightSensor: Sensor? = null private var lightSensorLux: Float = 100f @@ -280,6 +281,10 @@ class ScreenOffAccessibilityService : key == SettingsRepository.KEY_STATUS_GLANCE_HIDE_WHEN_LOCKED ) { statusGlanceHandler.updateState() + } else if (key?.startsWith("snippets_universal_") == true) { + if (::snippetAccessibilityHandler.isInitialized) { + snippetAccessibilityHandler.updateSettings() + } } } @@ -306,6 +311,8 @@ class ScreenOffAccessibilityService : islandOverlayHandler.onVisibilityChanged = { duoOverlayHandler.setIslandVisible(it) } duoOverlayHandler.openBrief = { islandOverlayHandler.openBrief() } statusGlanceHandler = StatusGlanceHandler(this) + snippetAccessibilityHandler = + com.sameerasw.essentials.services.handlers.SnippetAccessibilityHandler(this, serviceScope) flashlightHandler.register() statusBarIconHandler.register() @@ -507,6 +514,9 @@ class ScreenOffAccessibilityService : duoOverlayHandler.destroy() islandOverlayHandler.onDestroy() statusGlanceHandler.destroy() + if (::snippetAccessibilityHandler.isInitialized) { + snippetAccessibilityHandler.destroy() + } statusBarIconHandler.unregister() stopInputEventListener() cancelPocketFlashlightTurnOff() @@ -549,9 +559,19 @@ class ScreenOffAccessibilityService : islandOverlayHandler.updateConsciousGateState() } + if (event.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED) { + if (::snippetAccessibilityHandler.isInitialized) { + snippetAccessibilityHandler.onTextChanged(event) + } + return + } + if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED || event.eventType == AccessibilityEvent.TYPE_WINDOWS_CHANGED ) { + if (::snippetAccessibilityHandler.isInitialized) { + snippetAccessibilityHandler.hideFloatingPill() + } checkFullscreenState() checkStatusBarExpansion() freezeHandler.removeCallbacks(shadeRecheckRunnable) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/ime/snippets/SnippetFloatingPillView.kt b/app/src/main/java/com/sameerasw/essentials/ui/ime/snippets/SnippetFloatingPillView.kt new file mode 100644 index 0000000000..ca59eb715c --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/ime/snippets/SnippetFloatingPillView.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: IME - Snippets + * File: SnippetFloatingPillView.kt + * Description: Floating suggestion pill overlay above external keyboards. + */ + +package com.sameerasw.essentials.ui.ime.snippets + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.ime.snippets.Snippet + +@Composable +fun SnippetFloatingPillView( + snippet: Snippet, + onExpand: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = true, + enter = fadeIn() + scaleIn(initialScale = 0.85f), + exit = fadeOut() + scaleOut(targetScale = 0.85f), + ) { + Surface( + modifier = + modifier + .shadow(elevation = 8.dp, shape = CircleShape) + .clip(CircleShape) + .clickable { onExpand() }, + color = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + tonalElevation = 6.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Snippet Icon Badge + Box( + modifier = + Modifier + .size(24.dp) + .background( + color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.2f), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.rounded_text_snippet_24), + contentDescription = null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.tertiary, + ) + } + + // Keyword Badge + Text( + text = snippet.keyword, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + + // Divider / separator dot + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.5f), + ) + + // Title or Content Preview + Text( + text = snippet.title.ifBlank { snippet.content }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.85f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 180.dp), + ) + + Spacer(Modifier.width(2.dp)) + + // Dismiss "X" touch target + Box( + modifier = + Modifier + .size(20.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.12f)) + .clickable { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.rounded_close_24), + contentDescription = "Dismiss", + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + } + } + } +} diff --git a/app/src/main/res/xml/accessibility_service_config.xml b/app/src/main/res/xml/accessibility_service_config.xml index 115e93f9c5..d441c36e29 100644 --- a/app/src/main/res/xml/accessibility_service_config.xml +++ b/app/src/main/res/xml/accessibility_service_config.xml @@ -1,6 +1,6 @@ Date: Fri, 25 Sep 2026 17:35:31 +0530 Subject: [PATCH 04/27] feat(settings): add universal keyboard toggles in snippets management sheet --- .../data/repository/SettingsRepository.kt | 12 + .../apps/sheets/SnippetsBottomSheet.kt | 604 ++++++++++++++++++ app/src/main/res/values/strings.xml | 25 + 3 files changed, 641 insertions(+) create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 5afe386939..680ae0f7f7 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -300,6 +300,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_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" // Essentials-AirSync Bridge const val KEY_AIRSYNC_CONNECTION_ENABLED = "airsync_connection_enabled" @@ -3922,5 +3925,14 @@ 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, true) + 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) } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt new file mode 100644 index 0000000000..002dd5dd55 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt @@ -0,0 +1,604 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - Apps (IME Snippets) + * File: SnippetsBottomSheet.kt + * Description: BottomSheet UI for managing, adding, editing, and deleting Raycast-style text snippets. + */ + +package com.sameerasw.essentials.ui.features.apps.sheets + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.ime.snippets.Snippet +import com.sameerasw.essentials.ime.snippets.SnippetRepository +import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.utils.HapticUtil + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun SnippetsBottomSheet( + onDismissRequest: () -> Unit, +) { + val context = LocalContext.current + val view = LocalView.current + val repository = remember { SnippetRepository.getInstance(context) } + val settingsRepository = remember { SettingsRepository(context) } + val snippets by repository.snippets.collectAsState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var universalEnabled by remember { mutableStateOf(settingsRepository.isSnippetsUniversalEnabled()) } + var autoExpandEnabled by remember { mutableStateOf(settingsRepository.isSnippetsUniversalAutoExpandEnabled()) } + var floatingPillEnabled by remember { mutableStateOf(settingsRepository.isSnippetsUniversalFloatingPillEnabled()) } + + var searchQuery by remember { mutableStateOf("") } + var editingSnippet by remember { mutableStateOf(null) } + var isCreatingNew by remember { mutableStateOf(false) } + var deletingSnippet by remember { mutableStateOf(null) } + + val filteredSnippets = + remember(snippets, searchQuery) { + val query = searchQuery.trim() + if (query.isEmpty()) { + snippets + } else { + snippets.filter { + it.keyword.contains(query, ignoreCase = true) || + it.title.contains(query, ignoreCase = true) || + it.content.contains(query, ignoreCase = true) + } + } + } + + // Delete Confirmation Dialog + deletingSnippet?.let { target -> + AlertDialog( + onDismissRequest = { deletingSnippet = null }, + title = { Text(stringResource(R.string.snippets_delete)) }, + text = { Text(stringResource(R.string.snippets_delete_confirm)) }, + confirmButton = { + TextButton( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + repository.deleteSnippet(target.id) + deletingSnippet = null + }, + ) { + Text(stringResource(R.string.snippets_delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { deletingSnippet = null }) { + Text(stringResource(R.string.snippets_cancel)) + } + }, + ) + } + + // Add / Edit Dialog + if (isCreatingNew || editingSnippet != null) { + val initial = editingSnippet + SnippetEditDialog( + snippet = initial, + onDismiss = { + isCreatingNew = false + editingSnippet = null + }, + onSave = { savedSnippet -> + HapticUtil.performVirtualKeyHaptic(view) + if (initial == null) { + repository.addSnippet(savedSnippet) + } else { + repository.updateSnippet(savedSnippet) + } + isCreatingNew = false + editingSnippet = null + }, + ) + } + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + sheetState = sheetState, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Header: Title + Add Button + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.snippets_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + FilledTonalButton( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + isCreatingNew = true + }, + shape = RoundedCornerShape(12.dp), + ) { + Icon( + painter = painterResource(R.drawable.rounded_add_24), + contentDescription = stringResource(R.string.snippets_add), + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.snippets_add)) + } + } + + // Universal External Keyboard Support (Gboard, SwiftKey, etc.) + RoundedCardContainer(spacing = 2.dp) { + Column( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceBright) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.snippets_universal_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(R.string.snippets_universal_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = universalEnabled, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + universalEnabled = checked + settingsRepository.setSnippetsUniversalEnabled(checked) + }, + ) + } + + AnimatedVisibility(visible = universalEnabled) { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + // Option A: Instant Auto-expand + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.snippets_universal_auto_expand), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.snippets_universal_auto_expand_desc), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = autoExpandEnabled, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + autoExpandEnabled = checked + settingsRepository.setSnippetsUniversalAutoExpandEnabled(checked) + }, + ) + } + + // Option B: Floating Pill Overlay + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.snippets_universal_floating_pill), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.snippets_universal_floating_pill_desc), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = floatingPillEnabled, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + floatingPillEnabled = checked + settingsRepository.setSnippetsUniversalFloatingPillEnabled(checked) + }, + ) + } + } + } + } + } + + Spacer(Modifier.height(12.dp)) + + // Search Bar + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + placeholder = { Text(stringResource(R.string.snippets_search_hint)) }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.rounded_search_24), + contentDescription = "Search", + modifier = Modifier.size(20.dp), + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon( + painter = painterResource(R.drawable.rounded_close_24), + contentDescription = "Clear", + modifier = Modifier.size(20.dp), + ) + } + } + }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceBright, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceBright, + ), + ) + + // Snippet List + if (filteredSnippets.isEmpty()) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 48.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.snippets_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(filteredSnippets, key = { it.id }) { snippet -> + RoundedCardContainer(spacing = 2.dp) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceBright) + .clickable { editingSnippet = snippet } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Keyword Chip + Box( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.tertiaryContainer) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = snippet.keyword, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + + Text( + text = snippet.title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Text( + text = snippet.content, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + if (snippet.autoExpandOnSpace) { + Text( + text = "⚡ " + stringResource(R.string.snippets_auto_expand), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + editingSnippet = snippet + }, + ) { + Icon( + painter = painterResource(R.drawable.rounded_edit_24), + contentDescription = stringResource(R.string.snippets_edit), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + IconButton( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + deletingSnippet = snippet + }, + ) { + Icon( + painter = painterResource(R.drawable.rounded_delete_24), + contentDescription = stringResource(R.string.snippets_delete), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + } + } + } + } + } + item { + Spacer(Modifier.height(16.dp)) + } + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SnippetEditDialog( + snippet: Snippet?, + onDismiss: () -> Unit, + onSave: (Snippet) -> Unit, +) { + var title by remember { mutableStateOf(snippet?.title.orEmpty()) } + var keyword by remember { mutableStateOf(snippet?.keyword.orEmpty()) } + var content by remember { mutableStateOf(snippet?.content.orEmpty()) } + var autoExpandOnSpace by remember { mutableStateOf(snippet?.autoExpandOnSpace ?: true) } + + val isValid = keyword.isNotBlank() && content.isNotBlank() + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = + if (snippet == null) { + stringResource(R.string.snippets_add) + } else { + stringResource(R.string.snippets_edit) + }, + fontWeight = FontWeight.Bold, + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text(stringResource(R.string.snippets_snippet_title)) }, + placeholder = { Text("e.g. My Email") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) + + OutlinedTextField( + value = keyword, + onValueChange = { keyword = it }, + label = { Text(stringResource(R.string.snippets_keyword)) }, + placeholder = { Text("e.g. !email") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) + + OutlinedTextField( + value = content, + onValueChange = { content = it }, + label = { Text(stringResource(R.string.snippets_content)) }, + placeholder = { Text("Expanded snippet text") }, + minLines = 3, + maxLines = 6, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) + + // Quick Variable Chips + Text( + text = stringResource(R.string.snippets_quick_insert), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + val variables = listOf("{date}", "{time}", "{clipboard}") + variables.forEach { placeholder -> + AssistChip( + onClick = { content += placeholder }, + label = { Text(placeholder) }, + colors = + AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + shape = RoundedCornerShape(8.dp), + ) + } + } + + // Auto-expand Toggle + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.snippets_auto_expand), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.snippets_auto_expand_desc), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = autoExpandOnSpace, + onCheckedChange = { autoExpandOnSpace = it }, + ) + } + } + }, + confirmButton = { + Button( + onClick = { + if (isValid) { + val result = + snippet?.copy( + title = title.ifBlank { keyword }, + keyword = keyword.trim(), + content = content, + autoExpandOnSpace = autoExpandOnSpace, + ) ?: Snippet( + title = title.ifBlank { keyword }, + keyword = keyword.trim(), + content = content, + autoExpandOnSpace = autoExpandOnSpace, + ) + onSave(result) + } + }, + enabled = isValid, + ) { + Text(stringResource(R.string.snippets_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.snippets_cancel)) + } + }, + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 86c77cec26..b519b855c2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3000,5 +3000,30 @@ 3. Install on Watch Open the Play Store listing directly on your watch to install Open on Watch + + Text snippets + Manage Raycast-style expansion shortcuts + Text snippets + Search snippets… + No snippets yet + Add snippet + Edit snippet + Keyword + Title + Expanded text + Auto-expand on space + Expands immediately when typing space after keyword + Insert variable + Save + Delete + Are you sure you want to delete this snippet? + Cancel + External keyboard support + Expand snippets across Gboard, SwiftKey, and any other keyboard using accessibility + Instant auto-expand + Instantly expands keyword on Space in any app + Floating suggestion pill + Show floating pill above keyboard when trigger prefix is typed + From c8769a0a8c5d2d32ecee7a1bdd74d90d4cd5ecf1 Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:36:14 +0530 Subject: [PATCH 05/27] feat(settings): wire snippets management sheet into keyboard settings UI --- .../ui/features/system/KeyboardSettingsUI.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt index a5ca7ad2e4..b5ef147cb8 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt @@ -59,6 +59,7 @@ fun KeyboardSettingsUI( var text by remember { mutableStateOf("") } val isKeyboardEnabled by viewModel.isKeyboardEnabled val isKeyboardSelected by viewModel.isKeyboardSelected + var isSnippetsSheetVisible by remember { mutableStateOf(false) } LaunchedEffect(Unit) { while (true) { @@ -340,6 +341,16 @@ fun KeyboardSettingsUI( onCheckedChange = { viewModel.isUserDictionarySheetVisible.value = true }, ) } + + IconToggleItem( + iconRes = R.drawable.rounded_text_snippet_24, + title = stringResource(R.string.label_keyboard_snippets), + description = stringResource(R.string.desc_keyboard_snippets), + isChecked = false, + showToggle = false, + onCheckedChange = { isSnippetsSheetVisible = true }, + modifier = Modifier.highlight(highlightSetting == "keyboard_snippets"), + ) } Spacer(Modifier.height(32.dp)) @@ -350,5 +361,11 @@ fun KeyboardSettingsUI( onDismissRequest = { viewModel.isUserDictionarySheetVisible.value = false }, ) } + + if (isSnippetsSheetVisible) { + com.sameerasw.essentials.ui.features.apps.sheets.SnippetsBottomSheet( + onDismissRequest = { isSnippetsSheetVisible = false }, + ) + } } } From bb65172e095982b74eff72a6340e55304274f8b2 Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:01:29 +0530 Subject: [PATCH 06/27] refactor(ui): align snippets sheet and floating pill with essentials design system --- .../apps/sheets/SnippetsBottomSheet.kt | 474 ++++++++---------- .../ui/features/system/KeyboardSettingsUI.kt | 1 + .../essentials/ui/ime/KeyboardInputView.kt | 39 +- .../ime/snippets/SnippetFloatingPillView.kt | 64 ++- 4 files changed, 275 insertions(+), 303 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt index 002dd5dd55..93535c9774 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt @@ -4,12 +4,12 @@ * * Feature Module: UI Feature - Apps (IME Snippets) * File: SnippetsBottomSheet.kt - * Description: BottomSheet UI for managing, adding, editing, and deleting Raycast-style text snippets. + * Description: BottomSheet UI for managing, adding, editing, and deleting Raycast-style text snippets + * adhering to Essentials' core design system (EssentialsBottomSheet, RoundedCardContainer, IconToggleItem). */ package com.sameerasw.essentials.ui.features.apps.sheets -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -19,33 +19,27 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -66,10 +60,12 @@ import com.sameerasw.essentials.R import com.sameerasw.essentials.data.repository.SettingsRepository import com.sameerasw.essentials.ime.snippets.Snippet import com.sameerasw.essentials.ime.snippets.SnippetRepository +import com.sameerasw.essentials.ui.core.cards.IconToggleItem import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.core.sheets.EssentialsBottomSheet import com.sameerasw.essentials.utils.HapticUtil -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun SnippetsBottomSheet( onDismissRequest: () -> Unit, @@ -79,7 +75,6 @@ fun SnippetsBottomSheet( val repository = remember { SnippetRepository.getInstance(context) } val settingsRepository = remember { SettingsRepository(context) } val snippets by repository.snippets.collectAsState() - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var universalEnabled by remember { mutableStateOf(settingsRepository.isSnippetsUniversalEnabled()) } var autoExpandEnabled by remember { mutableStateOf(settingsRepository.isSnippetsUniversalAutoExpandEnabled()) } @@ -129,12 +124,12 @@ fun SnippetsBottomSheet( ) } - // Add / Edit Dialog + // Add / Edit Bottom Sheet if (isCreatingNew || editingSnippet != null) { val initial = editingSnippet - SnippetEditDialog( + SnippetEditSheet( snippet = initial, - onDismiss = { + onDismissRequest = { isCreatingNew = false editingSnippet = null }, @@ -151,216 +146,169 @@ fun SnippetsBottomSheet( ) } - ModalBottomSheet( + EssentialsBottomSheet( onDismissRequest = onDismissRequest, - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - sheetState = sheetState, ) { - Column( + LazyColumn( modifier = Modifier .fillMaxWidth() - .windowInsetsPadding(WindowInsets.navigationBars) .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - // Header: Title + Add Button - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { + // Header Title + item(key = "header_title") { Text( text = stringResource(R.string.snippets_title), style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.SemiBold, ) - FilledTonalButton( - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - isCreatingNew = true - }, - shape = RoundedCornerShape(12.dp), - ) { - Icon( - painter = painterResource(R.drawable.rounded_add_24), - contentDescription = stringResource(R.string.snippets_add), - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(6.dp)) - Text(stringResource(R.string.snippets_add)) - } } - // Universal External Keyboard Support (Gboard, SwiftKey, etc.) - RoundedCardContainer(spacing = 2.dp) { - Column( - modifier = - Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceBright) - .padding(horizontal = 16.dp, vertical = 12.dp), - ) { + // Top Action: Add Snippet Card (Matches UserDictionaryBottomSheet pattern) + item(key = "add_snippet_action") { + RoundedCardContainer(spacing = 2.dp) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceBright) + .clickable { + HapticUtil.performVirtualKeyHaptic(view) + isCreatingNew = true + } + .padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, ) { - Column(modifier = Modifier.weight(1f)) { + Icon( + painter = painterResource(R.drawable.rounded_add_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(16.dp)) + Column { Text( - text = stringResource(R.string.snippets_universal_title), + text = stringResource(R.string.snippets_add), style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, ) Text( - text = stringResource(R.string.snippets_universal_desc), + text = stringResource(R.string.desc_keyboard_snippets), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Switch( - checked = universalEnabled, + } + } + } + + // Settings: External Keyboard Support Group + item(key = "external_keyboard_settings") { + RoundedCardContainer(spacing = 2.dp) { + IconToggleItem( + iconRes = R.drawable.rounded_keyboard_24, + title = stringResource(R.string.snippets_universal_title), + description = stringResource(R.string.snippets_universal_desc), + isChecked = universalEnabled, + onCheckedChange = { checked -> + universalEnabled = checked + settingsRepository.setSnippetsUniversalEnabled(checked) + }, + ) + + if (universalEnabled) { + IconToggleItem( + iconRes = R.drawable.rounded_bolt_24, + title = stringResource(R.string.snippets_universal_auto_expand), + description = stringResource(R.string.snippets_universal_auto_expand_desc), + isChecked = autoExpandEnabled, onCheckedChange = { checked -> - HapticUtil.performVirtualKeyHaptic(view) - universalEnabled = checked - settingsRepository.setSnippetsUniversalEnabled(checked) + autoExpandEnabled = checked + settingsRepository.setSnippetsUniversalAutoExpandEnabled(checked) }, ) - } - AnimatedVisibility(visible = universalEnabled) { - Column( - modifier = Modifier.fillMaxWidth().padding(top = 10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - // Option A: Instant Auto-expand - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.snippets_universal_auto_expand), - style = MaterialTheme.typography.bodyMedium, - ) - Text( - text = stringResource(R.string.snippets_universal_auto_expand_desc), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = autoExpandEnabled, - onCheckedChange = { checked -> - HapticUtil.performVirtualKeyHaptic(view) - autoExpandEnabled = checked - settingsRepository.setSnippetsUniversalAutoExpandEnabled(checked) - }, - ) - } - - // Option B: Floating Pill Overlay - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.snippets_universal_floating_pill), - style = MaterialTheme.typography.bodyMedium, - ) - Text( - text = stringResource(R.string.snippets_universal_floating_pill_desc), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = floatingPillEnabled, - onCheckedChange = { checked -> - HapticUtil.performVirtualKeyHaptic(view) - floatingPillEnabled = checked - settingsRepository.setSnippetsUniversalFloatingPillEnabled(checked) - }, - ) - } - } + IconToggleItem( + iconRes = R.drawable.rounded_text_snippet_24, + title = stringResource(R.string.snippets_universal_floating_pill), + description = stringResource(R.string.snippets_universal_floating_pill_desc), + isChecked = floatingPillEnabled, + onCheckedChange = { checked -> + floatingPillEnabled = checked + settingsRepository.setSnippetsUniversalFloatingPillEnabled(checked) + }, + ) } } } - Spacer(Modifier.height(12.dp)) - // Search Bar - OutlinedTextField( - value = searchQuery, - onValueChange = { searchQuery = it }, - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 12.dp), - placeholder = { Text(stringResource(R.string.snippets_search_hint)) }, - leadingIcon = { - Icon( - painter = painterResource(R.drawable.rounded_search_24), - contentDescription = "Search", - modifier = Modifier.size(20.dp), - ) - }, - trailingIcon = { - if (searchQuery.isNotEmpty()) { - IconButton(onClick = { searchQuery = "" }) { - Icon( - painter = painterResource(R.drawable.rounded_close_24), - contentDescription = "Clear", - modifier = Modifier.size(20.dp), - ) + item(key = "search_bar") { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(stringResource(R.string.snippets_search_hint)) }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.rounded_search_24), + contentDescription = "Search", + modifier = Modifier.size(20.dp), + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon( + painter = painterResource(R.drawable.rounded_close_24), + contentDescription = "Clear", + modifier = Modifier.size(20.dp), + ) + } } - } - }, - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = - OutlinedTextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceBright, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceBright, - ), - ) + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceBright, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceBright, + ), + ) + } - // Snippet List + // Snippets List if (filteredSnippets.isEmpty()) { - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 48.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.snippets_empty), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + item(key = "empty_state") { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.snippets_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } else { - LazyColumn( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(filteredSnippets, key = { it.id }) { snippet -> - RoundedCardContainer(spacing = 2.dp) { + item(key = "snippets_group") { + RoundedCardContainer(spacing = 2.dp) { + filteredSnippets.forEach { snippet -> Row( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceBright) - .clickable { editingSnippet = snippet } - .padding(horizontal = 16.dp, vertical = 14.dp), + .clickable { + HapticUtil.performVirtualKeyHaptic(view) + editingSnippet = snippet + } + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { @@ -409,7 +357,7 @@ fun SnippetsBottomSheet( Text( text = "⚡ " + stringResource(R.string.snippets_auto_expand), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.tertiary, + color = MaterialTheme.colorScheme.primary, ) } } @@ -447,22 +395,24 @@ fun SnippetsBottomSheet( } } } - item { - Spacer(Modifier.height(16.dp)) - } } } + + item(key = "bottom_spacer") { + Spacer(Modifier.height(8.dp)) + } } } } @OptIn(ExperimentalLayoutApi::class) @Composable -private fun SnippetEditDialog( +private fun SnippetEditSheet( snippet: Snippet?, - onDismiss: () -> Unit, + onDismissRequest: () -> Unit, onSave: (Snippet) -> Unit, ) { + val view = LocalView.current var title by remember { mutableStateOf(snippet?.title.orEmpty()) } var keyword by remember { mutableStateOf(snippet?.keyword.orEmpty()) } var content by remember { mutableStateOf(snippet?.content.orEmpty()) } @@ -470,9 +420,17 @@ private fun SnippetEditDialog( val isValid = keyword.isNotBlank() && content.isNotBlank() - AlertDialog( - onDismissRequest = onDismiss, - title = { + EssentialsBottomSheet( + onDismissRequest = onDismissRequest, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { Text( text = if (snippet == null) { @@ -480,46 +438,43 @@ private fun SnippetEditDialog( } else { stringResource(R.string.snippets_edit) }, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, ) - }, - text = { - Column( + + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text(stringResource(R.string.snippets_snippet_title)) }, + placeholder = { Text("e.g. My Email") }, + singleLine = true, modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - OutlinedTextField( - value = title, - onValueChange = { title = it }, - label = { Text(stringResource(R.string.snippets_snippet_title)) }, - placeholder = { Text("e.g. My Email") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - ) + shape = RoundedCornerShape(12.dp), + ) - OutlinedTextField( - value = keyword, - onValueChange = { keyword = it }, - label = { Text(stringResource(R.string.snippets_keyword)) }, - placeholder = { Text("e.g. !email") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - ) + OutlinedTextField( + value = keyword, + onValueChange = { keyword = it }, + label = { Text(stringResource(R.string.snippets_keyword)) }, + placeholder = { Text("e.g. !email") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) - OutlinedTextField( - value = content, - onValueChange = { content = it }, - label = { Text(stringResource(R.string.snippets_content)) }, - placeholder = { Text("Expanded snippet text") }, - minLines = 3, - maxLines = 6, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - ) + OutlinedTextField( + value = content, + onValueChange = { content = it }, + label = { Text(stringResource(R.string.snippets_content)) }, + placeholder = { Text("Expanded snippet text") }, + minLines = 3, + maxLines = 6, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) - // Quick Variable Chips + // Variable Insert Chips + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( text = stringResource(R.string.snippets_quick_insert), style = MaterialTheme.typography.labelSmall, @@ -532,7 +487,10 @@ private fun SnippetEditDialog( val variables = listOf("{date}", "{time}", "{clipboard}") variables.forEach { placeholder -> AssistChip( - onClick = { content += placeholder }, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + content += placeholder + }, label = { Text(placeholder) }, colors = AssistChipDefaults.assistChipColors( @@ -543,38 +501,24 @@ private fun SnippetEditDialog( ) } } + } - // Auto-expand Toggle - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(top = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.snippets_auto_expand), - style = MaterialTheme.typography.bodyMedium, - ) - Text( - text = stringResource(R.string.snippets_auto_expand_desc), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = autoExpandOnSpace, - onCheckedChange = { autoExpandOnSpace = it }, - ) - } + // Auto-expand Toggle using standard RoundedCardContainer + IconToggleItem + RoundedCardContainer(spacing = 2.dp) { + IconToggleItem( + iconRes = R.drawable.rounded_bolt_24, + title = stringResource(R.string.snippets_auto_expand), + description = stringResource(R.string.snippets_auto_expand_desc), + isChecked = autoExpandOnSpace, + onCheckedChange = { autoExpandOnSpace = it }, + ) } - }, - confirmButton = { + + // Save Button Button( onClick = { if (isValid) { + HapticUtil.performVirtualKeyHaptic(view) val result = snippet?.copy( title = title.ifBlank { keyword }, @@ -591,14 +535,20 @@ private fun SnippetEditDialog( } }, enabled = isValid, + modifier = + Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(16.dp), ) { - Text(stringResource(R.string.snippets_save)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.snippets_cancel)) + Text( + text = stringResource(R.string.snippets_save), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) } - }, - ) + + Spacer(Modifier.height(8.dp)) + } + } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt index b5ef147cb8..31afea5c38 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt @@ -348,6 +348,7 @@ fun KeyboardSettingsUI( description = stringResource(R.string.desc_keyboard_snippets), isChecked = false, showToggle = false, + onClick = { isSnippetsSheetVisible = true }, onCheckedChange = { isSnippetsSheetVisible = true }, modifier = Modifier.highlight(highlightSetting == "keyboard_snippets"), ) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/ime/KeyboardInputView.kt b/app/src/main/java/com/sameerasw/essentials/ui/ime/KeyboardInputView.kt index 55e763c8de..517e1bb3f3 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/ime/KeyboardInputView.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/ime/KeyboardInputView.kt @@ -824,20 +824,43 @@ fun KeyboardInputView( horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(horizontal = 8.dp), ) { - if (isSnippet) { + if (isSnippet && suggestion.snippet != null) { Icon( painter = painterResource(id = R.drawable.rounded_text_snippet_24), contentDescription = "Snippet", modifier = Modifier.size(16.dp), ) + Text( + text = suggestion.snippet.keyword, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + fontFamily = CustomFontFamily, + maxLines = 1, + ) + if (suggestion.snippet.title.isNotBlank() && suggestion.snippet.title != suggestion.snippet.keyword) { + Text( + text = "• ${suggestion.snippet.title}", + style = MaterialTheme.typography.bodySmall, + fontFamily = CustomFontFamily, + maxLines = 1, + ) + } + } else { + if (isSnippet) { + Icon( + painter = painterResource(id = R.drawable.rounded_text_snippet_24), + contentDescription = "Snippet", + modifier = Modifier.size(16.dp), + ) + } + Text( + text = suggestion.text, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + fontFamily = CustomFontFamily, + maxLines = 1, + ) } - Text( - text = suggestion.text, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - fontFamily = CustomFontFamily, - maxLines = 1, - ) } } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/ime/snippets/SnippetFloatingPillView.kt b/app/src/main/java/com/sameerasw/essentials/ui/ime/snippets/SnippetFloatingPillView.kt index ca59eb715c..cbd99a47d8 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/ime/snippets/SnippetFloatingPillView.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/ime/snippets/SnippetFloatingPillView.kt @@ -4,7 +4,7 @@ * * Feature Module: IME - Snippets * File: SnippetFloatingPillView.kt - * Description: Floating suggestion pill overlay above external keyboards. + * Description: Floating suggestion pill overlay above external keyboards styled to match Essentials' floating island design. */ package com.sameerasw.essentials.ui.ime.snippets @@ -39,7 +39,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.sameerasw.essentials.R import com.sameerasw.essentials.ime.snippets.Snippet @@ -52,21 +51,21 @@ fun SnippetFloatingPillView( ) { AnimatedVisibility( visible = true, - enter = fadeIn() + scaleIn(initialScale = 0.85f), - exit = fadeOut() + scaleOut(targetScale = 0.85f), + enter = fadeIn() + scaleIn(initialScale = 0.88f), + exit = fadeOut() + scaleOut(targetScale = 0.88f), ) { Surface( modifier = modifier - .shadow(elevation = 8.dp, shape = CircleShape) + .shadow(elevation = 10.dp, shape = CircleShape) .clip(CircleShape) .clickable { onExpand() }, - color = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + color = MaterialTheme.colorScheme.surfaceBright, + contentColor = MaterialTheme.colorScheme.onSurface, tonalElevation = 6.dp, ) { Row( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + modifier = Modifier.padding(start = 12.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -74,9 +73,9 @@ fun SnippetFloatingPillView( Box( modifier = Modifier - .size(24.dp) + .size(26.dp) .background( - color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.2f), + color = MaterialTheme.colorScheme.primaryContainer, shape = CircleShape, ), contentAlignment = Alignment.Center, @@ -85,52 +84,51 @@ fun SnippetFloatingPillView( painter = painterResource(R.drawable.rounded_text_snippet_24), contentDescription = null, modifier = Modifier.size(15.dp), - tint = MaterialTheme.colorScheme.tertiary, + tint = MaterialTheme.colorScheme.onPrimaryContainer, ) } // Keyword Badge - Text( - text = snippet.keyword, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onTertiaryContainer, - ) - - // Divider / separator dot - Text( - text = "•", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.5f), - ) + Box( + modifier = + Modifier + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.tertiaryContainer) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = snippet.keyword, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } // Title or Content Preview Text( text = snippet.title.ifBlank { snippet.content }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.85f), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.widthIn(max = 180.dp), + modifier = Modifier.widthIn(max = 160.dp), ) - Spacer(Modifier.width(2.dp)) - // Dismiss "X" touch target Box( modifier = Modifier - .size(20.dp) + .size(24.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.12f)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) .clickable { onDismiss() }, contentAlignment = Alignment.Center, ) { Icon( painter = painterResource(R.drawable.rounded_close_24), contentDescription = "Dismiss", - modifier = Modifier.size(12.dp), - tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } From aabde71c8221258ac24f6bffabc3a2c55e62832f Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:07:03 +0530 Subject: [PATCH 07/27] feat(a11y): add dynamic ime window tracking and gboard window event protection --- .../handlers/SnippetAccessibilityHandler.kt | 202 ++++++++++++------ .../tiles/ScreenOffAccessibilityService.kt | 23 +- .../ui/features/system/KeyboardSettingsUI.kt | 26 +++ 3 files changed, 182 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt index 584fdad5dd..ac271e470d 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt @@ -99,7 +99,7 @@ class SnippetAccessibilityHandler( // 1. Check for Option A: Space-based instant auto-expand if (fullText.endsWith(" ") && isAutoExpandEnabled) { val textBeforeSpace = fullText.dropLast(1) - val candidateWord = textBeforeSpace.substringAfterLast(' ', "").ifBlank { textBeforeSpace } + val candidateWord = textBeforeSpace.takeLastWhile { !it.isWhitespace() } if (candidateWord.isNotBlank()) { val matchingSnippet = @@ -122,7 +122,7 @@ class SnippetAccessibilityHandler( } // 2. Check for Option B: Floating pill trigger - val currentWord = fullText.substringAfterLast(' ', "").ifBlank { fullText } + val currentWord = fullText.takeLastWhile { !it.isWhitespace() } lastTypedWord = currentWord if (currentWord.isNotBlank() && isFloatingPillEnabled) { @@ -167,87 +167,155 @@ class SnippetAccessibilityHandler( node.performAction(AccessibilityNodeInfo.ACTION_SET_SELECTION, cursorArgs) } + private fun calculateImeOffset(): Int { + val density = service.resources.displayMetrics.density + val displayHeight = service.resources.displayMetrics.heightPixels + val fallbackOffset = (290 * density).toInt() + + try { + val currentWindows = service.windows + if (!currentWindows.isNullOrEmpty()) { + val imeWindow = + currentWindows.firstOrNull { + it.type == android.view.accessibility.AccessibilityWindowInfo.TYPE_INPUT_METHOD + } + if (imeWindow != null) { + val imeBounds = android.graphics.Rect() + imeWindow.getBoundsInScreen(imeBounds) + if (imeBounds.height() > 0 && imeBounds.top < displayHeight) { + val keyboardHeight = (displayHeight - imeBounds.top).coerceAtLeast(0) + return keyboardHeight + (10 * density).toInt() + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error calculating IME window bounds", e) + } + return fallbackOffset + } + private fun showFloatingPill(snippet: Snippet) { mainHandler.post { currentMatchedSnippet.value = snippet mainHandler.removeCallbacks(dismissRunnable) mainHandler.postDelayed(dismissRunnable, AUTO_DISMISS_TIMEOUT_MS) - if (overlayView == null) { - val lifecycle = OverlayLifecycleOwner().also { it.onCreate() } - lifecycleOwner = lifecycle - - val composeView = - ComposeView(service).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) - setContent { - EssentialsTheme { - val currentSnippet = currentMatchedSnippet.collectAsState().value - if (currentSnippet != null) { - SnippetFloatingPillView( - snippet = currentSnippet, - onExpand = { - overlayView?.let { HapticUtil.performUIHaptic(it) } - activeNode?.let { node -> - val text = node.text?.toString().orEmpty() - val expanded = - SnippetExpander.expand(currentSnippet.content, service) - replaceKeywordInNode( - node = node, - fullText = text, - targetWord = lastTypedWord, - replacement = "$expanded ", - ) - } - hideFloatingPill() - }, - onDismiss = { hideFloatingPill() }, - ) - } - } + if (overlayView != null) { + // Overlay already visible; update positioning dynamically if keyboard height changed + try { + val frame = overlayView ?: return@post + val lp = frame.layoutParams as? WindowManager.LayoutParams + if (lp != null) { + val newY = calculateImeOffset() + if (lp.y != newY) { + lp.y = newY + windowManager.updateViewLayout(frame, lp) } } + } catch (_: Exception) { + } + return@post + } - val frame = - FrameLayout(service).apply { - setViewTreeLifecycleOwner(lifecycle) - setViewTreeSavedStateRegistryOwner(lifecycle) - setViewTreeViewModelStoreOwner(lifecycle) - addView(composeView) + val lifecycle = OverlayLifecycleOwner().also { it.onCreate() } + lifecycleOwner = lifecycle + + val composeView = + ComposeView(service).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) + setContent { + EssentialsTheme { + val currentSnippet = currentMatchedSnippet.collectAsState().value + if (currentSnippet != null) { + SnippetFloatingPillView( + snippet = currentSnippet, + onExpand = { + overlayView?.let { HapticUtil.performUIHaptic(it) } + activeNode?.let { node -> + val text = node.text?.toString().orEmpty() + val expanded = + SnippetExpander.expand(currentSnippet.content, service) + replaceKeywordInNode( + node = node, + fullText = text, + targetWord = lastTypedWord, + replacement = "$expanded ", + ) + } + hideFloatingPill() + }, + onDismiss = { hideFloatingPill() }, + ) + } + } } + } - val density = service.resources.displayMetrics.density - val params = - WindowManager.LayoutParams( - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or - WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or - WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH, - PixelFormat.TRANSLUCENT, - ).apply { - gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL - y = (290 * density).toInt() - } + val frame = + FrameLayout(service).apply { + setViewTreeLifecycleOwner(lifecycle) + setViewTreeSavedStateRegistryOwner(lifecycle) + setViewTreeViewModelStoreOwner(lifecycle) + addView(composeView) + } - frame.setOnTouchListener { _, event -> - if (event.action == MotionEvent.ACTION_OUTSIDE) { - hideFloatingPill() - true - } else { - false - } + val params = + WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or + WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH, + PixelFormat.TRANSLUCENT, + ).apply { + gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL + y = calculateImeOffset() } - overlayView = frame - try { - windowManager.addView(frame, params) - } catch (e: Exception) { - Log.e(TAG, "Failed to display snippet floating pill overlay", e) - overlayView = null + frame.setOnTouchListener { _, event -> + if (event.action == MotionEvent.ACTION_OUTSIDE) { + hideFloatingPill() + true + } else { + false + } + } + + overlayView = frame + try { + windowManager.addView(frame, params) + } catch (e: Exception) { + Log.e(TAG, "Failed to display snippet floating pill overlay", e) + overlayView = null + } + } + } + + fun onNonImeWindowChanged() { + if (!isImePresent()) { + hideFloatingPill() + } + } + + private fun isImePresent(): Boolean { + return try { + val currentWindows = service.windows + if (currentWindows.isNullOrEmpty()) return false + val displayHeight = service.resources.displayMetrics.heightPixels + val imeWindow = + currentWindows.firstOrNull { + it.type == android.view.accessibility.AccessibilityWindowInfo.TYPE_INPUT_METHOD } + if (imeWindow != null) { + val bounds = android.graphics.Rect() + imeWindow.getBoundsInScreen(bounds) + bounds.height() > 0 && bounds.top < displayHeight + } else { + false } + } catch (_: Exception) { + false } } diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt index 5fa97c3ccf..b151635c73 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt @@ -569,8 +569,10 @@ class ScreenOffAccessibilityService : if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED || event.eventType == AccessibilityEvent.TYPE_WINDOWS_CHANGED ) { - if (::snippetAccessibilityHandler.isInitialized) { - snippetAccessibilityHandler.hideFloatingPill() + val eventPkg = event.packageName?.toString().orEmpty() + val isImeEvent = isKeyboardPackage(eventPkg) + if (!isImeEvent && ::snippetAccessibilityHandler.isInitialized) { + snippetAccessibilityHandler.onNonImeWindowChanged() } checkFullscreenState() checkStatusBarExpansion() @@ -611,6 +613,9 @@ class ScreenOffAccessibilityService : statusGlanceHandler.setShadeExpanded(expanded) islandOverlayHandler.setShadeExpanded(expanded) duoOverlayHandler.setShadeExpanded(expanded) + if (expanded && ::snippetAccessibilityHandler.isInitialized) { + snippetAccessibilityHandler.hideFloatingPill() + } } private fun isShadeWindowVisible(): Boolean { @@ -648,12 +653,26 @@ class ScreenOffAccessibilityService : duoOverlayHandler.setFullscreen(isFullscreen) statusGlanceHandler.setFullscreen(isFullscreen) islandOverlayHandler.setFullscreen(isFullscreen) + if (isFullscreen && ::snippetAccessibilityHandler.isInitialized) { + snippetAccessibilityHandler.hideFloatingPill() + } } } } catch (_: Exception) {} } } + private fun isKeyboardPackage(pkg: String): Boolean { + if (pkg.isEmpty()) return false + return pkg.contains("inputmethod", ignoreCase = true) || + pkg.contains("keyboard", ignoreCase = true) || + pkg == "com.google.android.inputmethod.latin" || + pkg == "com.sec.android.inputmethod" || + pkg == "com.touchtype.swiftkey" || + pkg == "com.samsung.android.honeyboard" || + pkg == packageName + } + override fun onInterrupt() {} private fun updatePocketModeSensors() { diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt index 31afea5c38..3ed98ce049 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt @@ -342,6 +342,32 @@ fun KeyboardSettingsUI( ) } + } + + // Gboard & External Keyboards Section + Text( + text = "Gboard & External Keyboards", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + RoundedCardContainer(spacing = 2.dp) { + val settingsRepo = remember { com.sameerasw.essentials.data.repository.SettingsRepository(context) } + var universalOn by remember { mutableStateOf(settingsRepo.isSnippetsUniversalEnabled()) } + + IconToggleItem( + iconRes = R.drawable.rounded_keyboard_24, + title = stringResource(R.string.snippets_universal_title), + description = stringResource(R.string.snippets_universal_desc), + isChecked = universalOn, + onCheckedChange = { checked -> + universalOn = checked + settingsRepo.setSnippetsUniversalEnabled(checked) + }, + modifier = Modifier.highlight(highlightSetting == "snippets_universal"), + ) + IconToggleItem( iconRes = R.drawable.rounded_text_snippet_24, title = stringResource(R.string.label_keyboard_snippets), From c1ed66a8a8b2254602e2617d48776bda4c400235 Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:35:36 +0530 Subject: [PATCH 08/27] feat(duo): add animated drop-down pill and touch hitbox for snippets - Implement animated drop-down capsule beneath the camera ring with OvershootInterpolator showing keyword and title preview - Expand Duo touch anchor window to 280dp x (diameter + 72dp) with screen-width boundary clamping - Prioritize snippet expansion tap with forgiving human touch slop (up to 650ms) and suppress ring gestures during active snippet interaction --- .../services/handlers/DuoOverlayHandler.kt | 83 ++++++++ .../services/handlers/DuoTouchHandler.kt | 23 +- .../essentials/utils/DuoOverlayView.kt | 201 +++++++++++++++++- 3 files changed, 297 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt index 741eab2b3e..7ddedf2858 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt @@ -363,6 +363,28 @@ class DuoOverlayHandler( var openBrief: (() -> Unit)? = null + fun showSnippetSuggestion(snippet: com.sameerasw.essentials.ime.snippets.Snippet, onExpand: () -> Unit) { + mainHandler.post { + ensureTouchAnchor(isSnippet = true) + duoTouchHandler?.onSnippetTapAction = { + onExpand() + restoreTouchAnchor() + } + val titleText = snippet.title.ifBlank { snippet.content.take(18) } + overlayView?.setInteractiveSnippet(keyword = "!${snippet.keyword}", title = titleText) + } + } + + fun hideSnippetSuggestion() { + mainHandler.post { + duoTouchHandler?.onSnippetTapAction = null + if (overlayView?.isSnippetActive == true) { + overlayView?.resetInteractiveState(animate = true) + } + restoreTouchAnchor() + } + } + fun init() { windowManager = service.getSystemService(AccessibilityService.WINDOW_SERVICE) as? WindowManager updateState() @@ -1158,6 +1180,66 @@ class DuoOverlayHandler( + private fun ensureTouchAnchor(isSnippet: Boolean = false) { + val wm = windowManager ?: return + val density = service.resources.displayMetrics.density + if (duoTouchHandler == null) { + duoTouchHandler = DuoTouchHandler(service) + } + duoTouchHandler?.apply { + this.overlayView = this@DuoOverlayHandler.overlayView + this.cameraCenterX = currentCenterX + this.cameraCenterY = currentCenterY + this.cameraRadiusPx = currentCameraRadiusPx + this.ringRadiusScale = settingsRepository.getDuoRingRadius() + } + + if (touchAnchorView == null) { + touchAnchorView = View(service).apply { + setOnTouchListener { _, event -> + duoTouchHandler?.onTouchEvent(event) ?: false + } + } + } + + val displayWidth = service.resources.displayMetrics.widthPixels + val snippetActive = isSnippet || overlayView?.isSnippetActive == true + val diameter = (((currentCameraRadiusPx + 20f * density) * 2 * settingsRepository.getDuoRingRadius()).toInt()).coerceAtLeast((44f * density).toInt()) + val touchWidth = if (snippetActive) maxOf(diameter, (280f * density).toInt()) else diameter + val touchHeight = if (snippetActive) diameter + (72f * density).toInt() else diameter + + @Suppress("DEPRECATION") + val touchParams = WindowManager.LayoutParams( + touchWidth, + touchHeight, + WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or + WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD, + PixelFormat.TRANSLUCENT + ).apply { + gravity = Gravity.TOP or Gravity.START + x = ((currentCenterX - touchWidth / 2f).toInt()).coerceIn(0, (displayWidth - touchWidth).coerceAtLeast(0)) + y = ((currentCenterY - diameter / 2f).toInt()).coerceAtLeast(0) + } + + if (!isTouchAnchorAdded) { + try { + wm.addView(touchAnchorView, touchParams) + isTouchAnchorAdded = true + } catch (e: Exception) { + Log.e("DuoOverlayHandler", "Failed to add Duo touch anchor for snippet", e) + } + } else { + try { + wm.updateViewLayout(touchAnchorView, touchParams) + } catch (_: Exception) {} + } + } + private fun restoreTouchAnchor() { val wm = windowManager ?: return val anchor = touchAnchorView ?: return @@ -1181,6 +1263,7 @@ class DuoOverlayHandler( val density = service.resources.displayMetrics.density val diameter = (((currentCameraRadiusPx + 20f * density) * 2 * settingsRepository.getDuoRingRadius()).toInt()).coerceAtLeast((44f * density).toInt()) + @Suppress("DEPRECATION") val touchParams = WindowManager.LayoutParams( diameter, diameter, diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoTouchHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoTouchHandler.kt index 698cc5f64a..f577abb62c 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoTouchHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoTouchHandler.kt @@ -49,6 +49,7 @@ class DuoTouchHandler( var cameraRadiusPx: Float = 36f var ringRadiusScale: Float = 1.0f var openBrief: (() -> Unit)? = null + var onSnippetTapAction: (() -> Unit)? = null private var downX: Float = 0f private var downY: Float = 0f @@ -110,7 +111,9 @@ class DuoTouchHandler( MotionEvent.ACTION_DOWN -> { val effectiveRadius = (cameraRadiusPx + 20f * density) * ringRadiusScale val dist = hypot(x - cameraCenterX, y - cameraCenterY) - if (dist > effectiveRadius) { + val isSnippet = overlayView?.isSnippetActive == true && onSnippetTapAction != null + val inSnippetPill = isSnippet && overlayView?.snippetPillRect?.contains(x, y) == true + if (dist > effectiveRadius && !inSnippetPill) { isTouchActiveInCutout = false return false } @@ -129,6 +132,11 @@ class DuoTouchHandler( overlayView?.triggerTapAnimation() + if (isSnippet) { + // Suppress ring gestures while snippet suggestion is interactive + return true + } + val doubleTapAction = settingsRepository.getDuoDoubleTapAction() val isSecondTapInWindow = (downTime - lastTapTime) < doubleTapTimeoutMs && hypot(x - lastTapX, y - lastTapY) < touchSlopPx * 1.5f @@ -225,7 +233,20 @@ class DuoTouchHandler( isSoundModeTriggered || isVolumeOrBrightnessAdjusted + val snippetTap = onSnippetTapAction + if (snippetTap != null && overlayView?.isSnippetActive == true) { + if (totalDist < touchSlopPx * 2f && elapsed < 650L) { + onSnippetTapAction = null + overlayView?.resetInteractiveState(animate = true) + HapticUtil.performHapticForService(service, HapticFeedbackType.CLICK) + snippetTap() + isTouchActiveInCutout = false + return true + } + } + if (!didPerformAnyGesture && totalDist < touchSlopPx && elapsed < 350L) { + val doubleTapAction = settingsRepository.getDuoDoubleTapAction() val tapAction = if (settingsRepository.isDuoTapForBriefActive() && openBrief != null) null else settingsRepository.getDuoTapAction() val briefTap = openBrief?.takeIf { settingsRepository.isDuoTapForBriefActive() } diff --git a/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt b/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt index f1298c8497..1a74c24d7b 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt @@ -501,9 +501,19 @@ class DuoOverlayView(context: Context) : View(context) { const val INTERACTIVE_MODE_BRIGHTNESS = 2 const val INTERACTIVE_MODE_TRACK = 3 const val INTERACTIVE_MODE_SOUND_MODE = 4 + const val INTERACTIVE_MODE_SNIPPET = 5 const val MEDIA_ART_SPIN_DURATION_MS = 32000L } + val isSnippetActive: Boolean + get() = interactiveMode == INTERACTIVE_MODE_SNIPPET + + private var snippetKeyword: String = "" + private var snippetTitle: String = "" + private var snippetPillFraction: Float = 0f + private var snippetPillAnimator: ValueAnimator? = null + val snippetPillRect = RectF() + private var interactiveMode: Int = INTERACTIVE_MODE_NONE private var interactiveProgress: Float = 0f private var interactiveIcon: Bitmap? = null @@ -655,6 +665,32 @@ class DuoOverlayView(context: Context) : View(context) { updateProgressAnimation(interactiveProgress) } + fun setInteractiveSnippet(keyword: String = "", title: String = "") { + interactiveMode = INTERACTIVE_MODE_SNIPPET + interactiveProgress = 100f + interactiveIcon = getThemedBitmap(R.drawable.rounded_text_snippet_24) + snippetKeyword = keyword + snippetTitle = title + + removeCallbacks(revertInteractiveRunnable) + postDelayed(revertInteractiveRunnable, 4500L) + + snippetPillAnimator?.cancel() + snippetPillAnimator = ValueAnimator.ofFloat(snippetPillFraction, 1.0f).apply { + duration = 320 + interpolator = OvershootInterpolator(1.2f) + addUpdateListener { anim -> + snippetPillFraction = anim.animatedValue as Float + invalidate() + } + start() + } + + updateActiveProgressMode() + updateProgressAnimation(100f) + triggerTapAnimation() + } + fun setInteractiveTrackRotation(rotationDegrees: Float) { trackRotationAnimator?.cancel() interactiveTrackRotation = rotationDegrees @@ -679,11 +715,35 @@ class DuoOverlayView(context: Context) : View(context) { fun resetInteractiveState(animate: Boolean = true) { removeCallbacks(revertInteractiveRunnable) - if (interactiveMode == INTERACTIVE_MODE_NONE) return + if (interactiveMode == INTERACTIVE_MODE_NONE && snippetPillFraction <= 0.01f) return interactiveMode = INTERACTIVE_MODE_NONE interactiveIcon = null releaseTrackRotation() updateActiveProgressMode() + + snippetPillAnimator?.cancel() + if (animate && snippetPillFraction > 0.01f) { + snippetPillAnimator = ValueAnimator.ofFloat(snippetPillFraction, 0f).apply { + duration = 240 + interpolator = DecelerateInterpolator() + addUpdateListener { anim -> + snippetPillFraction = anim.animatedValue as Float + if (snippetPillFraction <= 0.01f) { + snippetKeyword = "" + snippetTitle = "" + snippetPillRect.setEmpty() + } + invalidate() + } + start() + } + } else { + snippetPillFraction = 0f + snippetKeyword = "" + snippetTitle = "" + snippetPillRect.setEmpty() + invalidate() + } } private fun getChargingBoltBitmap(): Bitmap? { @@ -1397,29 +1457,31 @@ class DuoOverlayView(context: Context) : View(context) { style = Paint.Style.STROKE } - private val notificationPillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + private val snippetPillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL - color = Color.BLACK + color = Color.parseColor("#1C1B1F") } - private val notificationPillBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + private val snippetPillBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE strokeWidth = 1f * density color = Color.argb(45, 255, 255, 255) } - private val notificationTitlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + private val snippetKeywordPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) letterSpacing = -0.01f } - private val notificationBodyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.WHITE - typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + private val snippetTitlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#C4C6D0") + typeface = Typeface.DEFAULT letterSpacing = -0.01f } + private val snippetIconPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) + init { val (track, progress, dot) = getTargetColors() currentTrackColor = track @@ -1912,8 +1974,127 @@ class DuoOverlayView(context: Context) : View(context) { } } - canvas.restore() } + + if (snippetPillFraction > 0.01f) { + drawSnippetPill(canvas) + } + } + + private fun drawSnippetPill(canvas: Canvas) { + if (snippetPillFraction <= 0.01f) return + + val displayMetrics = resources.displayMetrics + val screenWidth = displayMetrics.widthPixels.toFloat() + + val pillHeight = 32f * density + val pillCornerRadius = pillHeight / 2f + val iconSize = 16f * density + val paddingHorizontal = 12f * density + val spacing = 6f * density + + val kwTextSize = 12.5f * density + val titleTextSize = 11.5f * density + snippetKeywordPaint.textSize = kwTextSize + snippetTitlePaint.textSize = titleTextSize + + val keywordText = snippetKeyword + val titleText = snippetTitle + + val kwWidth = if (keywordText.isNotEmpty()) snippetKeywordPaint.measureText(keywordText) else 0f + val hasSeparator = keywordText.isNotEmpty() && titleText.isNotEmpty() + val separator = " • " + val sepWidth = if (hasSeparator) snippetTitlePaint.measureText(separator) else 0f + var titleWidth = if (titleText.isNotEmpty()) snippetTitlePaint.measureText(titleText) else 0f + + val maxAllowedWidth = screenWidth - (32f * density) + var totalContentWidth = paddingHorizontal * 2 + iconSize + spacing + kwWidth + (if (hasSeparator) sepWidth + titleWidth else 0f) + + var displayTitle = titleText + if (totalContentWidth > maxAllowedWidth) { + val availableForTitle = (maxAllowedWidth - (paddingHorizontal * 2 + iconSize + spacing + kwWidth + sepWidth)).coerceAtLeast(0f) + if (availableForTitle > 30f * density) { + displayTitle = android.text.TextUtils.ellipsize( + titleText, + android.text.TextPaint(snippetTitlePaint), + availableForTitle, + android.text.TextUtils.TruncateAt.END + ).toString() + titleWidth = snippetTitlePaint.measureText(displayTitle) + } else { + displayTitle = "" + titleWidth = 0f + } + totalContentWidth = paddingHorizontal * 2 + iconSize + spacing + kwWidth + (if (displayTitle.isNotEmpty()) sepWidth + titleWidth else 0f) + } + + val pillWidth = totalContentWidth.coerceIn(48f * density, maxAllowedWidth) + + val minCenterX = pillWidth / 2f + 16f * density + val maxCenterX = screenWidth - pillWidth / 2f - 16f * density + val pillCenterX = cameraCenterX.coerceIn(minCenterX, maxCenterX) + + val pillLeft = pillCenterX - pillWidth / 2f + val pillRight = pillCenterX + pillWidth / 2f + + val baseRadius = (cameraRadiusPx + arcThicknessPx / 2f + 2f * density) * ringRadiusScale + val dropDistance = 10f * density + (12f * density * snippetPillFraction) + val pillTop = cameraCenterY + baseRadius + dropDistance + val pillBottom = pillTop + pillHeight + + snippetPillRect.set(pillLeft, pillTop, pillRight, pillBottom) + + val alphaInt = (255 * snippetPillFraction).toInt().coerceIn(0, 255) + if (alphaInt <= 0) return + + val saveCount = canvas.save() + val scale = 0.85f + 0.15f * snippetPillFraction + canvas.scale(scale, scale, pillCenterX, (pillTop + pillBottom) / 2f) + + snippetPillPaint.color = Color.argb((alphaInt * 0.96f).toInt(), 28, 27, 31) + canvas.drawRoundRect(snippetPillRect, pillCornerRadius, pillCornerRadius, snippetPillPaint) + + val borderAlpha = (alphaInt * 0.35f).toInt() + val borderColor = if (isDarkTheme) Color.WHITE else Color.BLACK + snippetPillBorderPaint.color = Color.argb(borderAlpha, Color.red(borderColor), Color.green(borderColor), Color.blue(borderColor)) + canvas.drawRoundRect(snippetPillRect, pillCornerRadius, pillCornerRadius, snippetPillBorderPaint) + + var currentX = pillLeft + paddingHorizontal + val iconY = pillTop + (pillHeight - iconSize) / 2f + val icon = interactiveIcon ?: getThemedBitmap(R.drawable.rounded_text_snippet_24) + if (icon != null) { + val iconRectF = RectF(currentX, iconY, currentX + iconSize, iconY + iconSize) + snippetIconPaint.alpha = alphaInt + snippetIconPaint.colorFilter = PorterDuffColorFilter(currentProgressColor, PorterDuff.Mode.SRC_IN) + canvas.drawBitmap(icon, null, iconRectF, snippetIconPaint) + currentX += iconSize + spacing + } + + val textCenterY = (pillTop + pillBottom) / 2f + val kwBounds = android.graphics.Rect() + if (keywordText.isNotEmpty()) { + snippetKeywordPaint.color = Color.argb(alphaInt, 255, 255, 255) + snippetKeywordPaint.getTextBounds(keywordText, 0, keywordText.length, kwBounds) + val kwBaseline = textCenterY - kwBounds.exactCenterY() + canvas.drawText(keywordText, currentX, kwBaseline, snippetKeywordPaint) + currentX += kwWidth + } + + if (displayTitle.isNotEmpty()) { + snippetTitlePaint.color = Color.argb((alphaInt * 0.72f).toInt(), 200, 205, 215) + if (hasSeparator) { + snippetTitlePaint.getTextBounds(separator, 0, separator.length, kwBounds) + val sepBaseline = textCenterY - kwBounds.exactCenterY() + canvas.drawText(separator, currentX, sepBaseline, snippetTitlePaint) + currentX += sepWidth + } + + snippetTitlePaint.getTextBounds(displayTitle, 0, displayTitle.length, kwBounds) + val titleBaseline = textCenterY - kwBounds.exactCenterY() + canvas.drawText(displayTitle, currentX, titleBaseline, snippetTitlePaint) + } + + canvas.restoreToCount(saveCount) } override fun onAttachedToWindow() { @@ -1947,6 +2128,8 @@ class DuoOverlayView(context: Context) : View(context) { tapAnimator?.cancel() pullDownAnimator?.cancel() trackRotationAnimator?.cancel() + snippetPillAnimator?.cancel() + snippetPillAnimator = null } } From cedb395ec5bd293830aa1f42147c3ae45113e043 Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:35:44 +0530 Subject: [PATCH 09/27] fix(snippets): prevent keyboard input obstruction and improve touch ergonomics - Dynamically measure active input field bounds directly on text change to position pill comfortably above keyboard - Add vertical drag gesture support to floating pill frame so users can move it out of the way - Route SNIPPETS_DISPLAY_BOTH concurrently to Dynamic Island and Duo camera ring while keeping keyboard typing area clear --- .../handlers/SnippetAccessibilityHandler.kt | 178 ++++++++++++++---- 1 file changed, 142 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt index ac271e470d..fd9c77220a 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt @@ -32,6 +32,7 @@ import com.sameerasw.essentials.data.repository.SettingsRepository import com.sameerasw.essentials.ime.snippets.Snippet import com.sameerasw.essentials.ime.snippets.SnippetExpander import com.sameerasw.essentials.ime.snippets.SnippetRepository +import com.sameerasw.essentials.island.service.IslandCoordinator import com.sameerasw.essentials.island.service.OverlayLifecycleOwner import com.sameerasw.essentials.ui.ime.snippets.SnippetFloatingPillView import com.sameerasw.essentials.ui.theme.EssentialsTheme @@ -42,6 +43,8 @@ import kotlinx.coroutines.flow.MutableStateFlow class SnippetAccessibilityHandler( private val service: AccessibilityService, private val scope: CoroutineScope, + var islandCoordinator: IslandCoordinator? = null, + var duoOverlayHandler: DuoOverlayHandler? = null, ) { companion object { private const val TAG = "SnippetA11yHandler" @@ -59,6 +62,7 @@ class SnippetAccessibilityHandler( private val currentMatchedSnippet = MutableStateFlow(null) private var activeNode: AccessibilityNodeInfo? = null + private var lastInputBounds: android.graphics.Rect? = null private var lastTypedWord: String = "" private var overlayView: FrameLayout? = null @@ -71,7 +75,7 @@ class SnippetAccessibilityHandler( isAutoExpandEnabled = settingsRepository.isSnippetsUniversalAutoExpandEnabled() isFloatingPillEnabled = settingsRepository.isSnippetsUniversalFloatingPillEnabled() if (!isUniversalEnabled) { - hideFloatingPill() + dismissAllSuggestions() } } @@ -82,6 +86,11 @@ class SnippetAccessibilityHandler( if (!sourceNode.isEditable) return activeNode = sourceNode + val bounds = android.graphics.Rect() + sourceNode.getBoundsInScreen(bounds) + if (bounds.height() > 0 && bounds.top > 0) { + lastInputBounds = bounds + } val textList = event.text val fullText = @@ -92,7 +101,7 @@ class SnippetAccessibilityHandler( } if (fullText.isEmpty()) { - hideFloatingPill() + dismissAllSuggestions() return } @@ -115,25 +124,25 @@ class SnippetAccessibilityHandler( targetWord = "$candidateWord ", replacement = "$expanded ", ) - hideFloatingPill() + dismissAllSuggestions() return } } } - // 2. Check for Option B: Floating pill trigger + // 2. Check for Option B: Suggestion trigger (Pill, Island, Duo, or Both) val currentWord = fullText.takeLastWhile { !it.isWhitespace() } lastTypedWord = currentWord - if (currentWord.isNotBlank() && isFloatingPillEnabled) { + if (currentWord.isNotBlank()) { val matches = snippetRepository.findMatching(currentWord) if (matches.isNotEmpty()) { - showFloatingPill(matches.first()) + showSuggestion(matches.first()) } else { - hideFloatingPill() + dismissAllSuggestions() } } else { - hideFloatingPill() + dismissAllSuggestions() } } @@ -170,7 +179,7 @@ class SnippetAccessibilityHandler( private fun calculateImeOffset(): Int { val density = service.resources.displayMetrics.density val displayHeight = service.resources.displayMetrics.heightPixels - val fallbackOffset = (290 * density).toInt() + var keyboardHeight = (290 * density).toInt() try { val currentWindows = service.windows @@ -183,18 +192,103 @@ class SnippetAccessibilityHandler( val imeBounds = android.graphics.Rect() imeWindow.getBoundsInScreen(imeBounds) if (imeBounds.height() > 0 && imeBounds.top < displayHeight) { - val keyboardHeight = (displayHeight - imeBounds.top).coerceAtLeast(0) - return keyboardHeight + (10 * density).toInt() + keyboardHeight = (displayHeight - imeBounds.top).coerceAtLeast(0) } } } } catch (e: Exception) { Log.e(TAG, "Error calculating IME window bounds", e) } - return fallbackOffset + + // Dynamically measure active input field to ensure the pill floats comfortably above it + val bounds = lastInputBounds + if (bounds != null && bounds.height() > 0 && bounds.top > 0 && bounds.top < displayHeight) { + val nodeTopFromBottom = displayHeight - bounds.top + if (nodeTopFromBottom >= keyboardHeight && bounds.top > (80 * density)) { + return nodeTopFromBottom + (12 * density).toInt() + } + } + + // Fallback: place comfortably above standard multi-line input bar + return keyboardHeight + (72 * density).toInt() } - private fun showFloatingPill(snippet: Snippet) { + private fun expandSnippet(snippet: Snippet) { + overlayView?.let { HapticUtil.performUIHaptic(it) } + val targetNode = activeNode?.takeIf { + try { it.refresh() } catch (_: Exception) { false } + } ?: service.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) + + if (targetNode != null) { + val text = targetNode.text?.toString().orEmpty() + val expanded = SnippetExpander.expand(snippet.content, service) + replaceKeywordInNode( + node = targetNode, + fullText = text, + targetWord = lastTypedWord, + replacement = "$expanded ", + ) + } + dismissAllSuggestions() + } + + private fun showSuggestion(snippet: Snippet) { + val mode = settingsRepository.getSnippetsSuggestionDisplayMode() + val onExpand = { expandSnippet(snippet) } + + when (mode) { + SettingsRepository.SNIPPETS_DISPLAY_FLOATING_PILL -> { + islandCoordinator?.hideSnippet() + duoOverlayHandler?.hideSnippetSuggestion() + showFloatingPill(snippet, onExpand) + } + SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> { + hideFloatingPill() + duoOverlayHandler?.hideSnippetSuggestion() + if (settingsRepository.isIslandEnabled()) { + islandCoordinator?.showSnippet(snippet, onExpand) + } else { + showFloatingPill(snippet, onExpand) + } + } + SettingsRepository.SNIPPETS_DISPLAY_DUO -> { + hideFloatingPill() + islandCoordinator?.hideSnippet() + if (settingsRepository.isDuoEnabled()) { + duoOverlayHandler?.showSnippetSuggestion(snippet, onExpand) + } else { + showFloatingPill(snippet, onExpand) + } + } + SettingsRepository.SNIPPETS_DISPLAY_BOTH -> { + hideFloatingPill() + val islandOn = settingsRepository.isIslandEnabled() + val duoOn = settingsRepository.isDuoEnabled() + if (islandOn) { + islandCoordinator?.showSnippet(snippet, onExpand) + } + if (duoOn) { + duoOverlayHandler?.showSnippetSuggestion(snippet, onExpand) + } + if (!islandOn && !duoOn) { + showFloatingPill(snippet, onExpand) + } + } + else -> { + islandCoordinator?.hideSnippet() + duoOverlayHandler?.hideSnippetSuggestion() + showFloatingPill(snippet, onExpand) + } + } + } + + fun dismissAllSuggestions() { + hideFloatingPill() + islandCoordinator?.hideSnippet() + duoOverlayHandler?.hideSnippetSuggestion() + } + + private fun showFloatingPill(snippet: Snippet, onExpand: () -> Unit) { mainHandler.post { currentMatchedSnippet.value = snippet mainHandler.removeCallbacks(dismissRunnable) @@ -229,22 +323,8 @@ class SnippetAccessibilityHandler( if (currentSnippet != null) { SnippetFloatingPillView( snippet = currentSnippet, - onExpand = { - overlayView?.let { HapticUtil.performUIHaptic(it) } - activeNode?.let { node -> - val text = node.text?.toString().orEmpty() - val expanded = - SnippetExpander.expand(currentSnippet.content, service) - replaceKeywordInNode( - node = node, - fullText = text, - targetWord = lastTypedWord, - replacement = "$expanded ", - ) - } - hideFloatingPill() - }, - onDismiss = { hideFloatingPill() }, + onExpand = onExpand, + onDismiss = { dismissAllSuggestions() }, ) } } @@ -273,12 +353,38 @@ class SnippetAccessibilityHandler( y = calculateImeOffset() } + val density = service.resources.displayMetrics.density + var initialY = 0 + var initialTouchY = 0f + frame.setOnTouchListener { _, event -> - if (event.action == MotionEvent.ACTION_OUTSIDE) { - hideFloatingPill() - true - } else { - false + when (event.action) { + MotionEvent.ACTION_OUTSIDE -> { + dismissAllSuggestions() + true + } + MotionEvent.ACTION_DOWN -> { + val lp = frame.layoutParams as? WindowManager.LayoutParams + initialY = lp?.y ?: 0 + initialTouchY = event.rawY + false + } + MotionEvent.ACTION_MOVE -> { + val dy = (initialTouchY - event.rawY).toInt() + if (kotlin.math.abs(dy) > (8 * density).toInt()) { + val lp = frame.layoutParams as? WindowManager.LayoutParams + if (lp != null) { + lp.y = (initialY + dy).coerceAtLeast(0) + try { + windowManager.updateViewLayout(frame, lp) + } catch (_: Exception) {} + } + true + } else { + false + } + } + else -> false } } @@ -294,7 +400,7 @@ class SnippetAccessibilityHandler( fun onNonImeWindowChanged() { if (!isImePresent()) { - hideFloatingPill() + dismissAllSuggestions() } } @@ -335,7 +441,7 @@ class SnippetAccessibilityHandler( } fun destroy() { - hideFloatingPill() + dismissAllSuggestions() activeNode = null } } From 63a91a3e7d1370fa21a170728da6cb4cc3936f9c Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:35:53 +0530 Subject: [PATCH 10/27] feat(snippets): add smart display mode recommendation and settings integration - Implement getRecommendedSnippetsSuggestionDisplayMode() to dynamically evaluate active features (Island, Duo, or both) and default accordingly - Display (Recommended) badge on matching setup option in SnippetDisplayStyleSheet - Add Text snippets toggle with gear button and live mode descriptions to Dynamic Island and Duo settings screens - Streamline SnippetsBottomSheet settings hierarchy --- .../data/repository/SettingsRepository.kt | 33 +++- .../apps/sheets/SnippetsBottomSheet.kt | 153 +++++++++++++++++- .../ui/features/display/DuoSettingsUI.kt | 52 ++++++ .../ui/features/display/IslandSettingsUI.kt | 52 ++++++ app/src/main/res/values/strings.xml | 11 ++ 5 files changed, 290 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 680ae0f7f7..b6d4d80335 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -300,9 +300,6 @@ 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_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" // Essentials-AirSync Bridge const val KEY_AIRSYNC_CONNECTION_ENABLED = "airsync_connection_enabled" @@ -647,6 +644,16 @@ class SettingsRepository( const val KEY_BUBBLE_WEB_FULLSCREEN = "bubble_web_fullscreen" 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" + + // 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_FLOATING_PILL = "pill" + const val SNIPPETS_DISPLAY_DYNAMIC_ISLAND = "island" + const val SNIPPETS_DISPLAY_DUO = "duo" + const val SNIPPETS_DISPLAY_BOTH = "both" } fun isSimNamesApplyOnBootEnabled(): Boolean = getBoolean(KEY_SIM_NAMES_APPLY_ON_BOOT, false) @@ -3934,5 +3941,25 @@ class SettingsRepository( 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) } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt index 93535c9774..6ecc5db8d0 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt @@ -79,6 +79,8 @@ fun SnippetsBottomSheet( var universalEnabled by remember { mutableStateOf(settingsRepository.isSnippetsUniversalEnabled()) } var autoExpandEnabled by remember { mutableStateOf(settingsRepository.isSnippetsUniversalAutoExpandEnabled()) } var floatingPillEnabled by remember { mutableStateOf(settingsRepository.isSnippetsUniversalFloatingPillEnabled()) } + var suggestionDisplayMode by remember { mutableStateOf(settingsRepository.getSnippetsSuggestionDisplayMode()) } + var isDisplayStyleSheetVisible by remember { mutableStateOf(false) } var searchQuery by remember { mutableStateOf("") } var editingSnippet by remember { mutableStateOf(null) } @@ -146,6 +148,17 @@ fun SnippetsBottomSheet( ) } + if (isDisplayStyleSheetVisible) { + SnippetDisplayStyleSheet( + currentMode = suggestionDisplayMode, + onModeSelected = { newMode -> + suggestionDisplayMode = newMode + settingsRepository.setSnippetsSuggestionDisplayMode(newMode) + }, + onDismissRequest = { isDisplayStyleSheetVisible = false }, + ) + } + EssentialsBottomSheet( onDismissRequest = onDismissRequest, ) { @@ -229,15 +242,27 @@ fun SnippetsBottomSheet( }, ) + val modeLabel = when (suggestionDisplayMode) { + SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> stringResource(R.string.snippets_display_island_title) + SettingsRepository.SNIPPETS_DISPLAY_DUO -> stringResource(R.string.snippets_display_duo_title) + SettingsRepository.SNIPPETS_DISPLAY_BOTH -> stringResource(R.string.snippets_display_both_title) + else -> stringResource(R.string.snippets_display_pill_title) + } + val modeIcon = when (suggestionDisplayMode) { + SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> R.drawable.rounded_bolt_24 + SettingsRepository.SNIPPETS_DISPLAY_DUO -> R.drawable.rounded_circle_24 + SettingsRepository.SNIPPETS_DISPLAY_BOTH -> R.drawable.rounded_auto_awesome_24 + else -> R.drawable.rounded_keyboard_24 + } + IconToggleItem( - iconRes = R.drawable.rounded_text_snippet_24, - title = stringResource(R.string.snippets_universal_floating_pill), - description = stringResource(R.string.snippets_universal_floating_pill_desc), - isChecked = floatingPillEnabled, - onCheckedChange = { checked -> - floatingPillEnabled = checked - settingsRepository.setSnippetsUniversalFloatingPillEnabled(checked) - }, + iconRes = modeIcon, + title = stringResource(R.string.snippets_display_style_title), + description = modeLabel, + isChecked = false, + showToggle = false, + onClick = { isDisplayStyleSheetVisible = true }, + onCheckedChange = { isDisplayStyleSheetVisible = true }, ) } } @@ -552,3 +577,115 @@ private fun SnippetEditSheet( } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SnippetDisplayStyleSheet( + currentMode: String, + onModeSelected: (String) -> Unit, + onDismissRequest: () -> Unit, +) { + val view = LocalView.current + val context = LocalContext.current + val recommendedMode = remember { SettingsRepository(context).getRecommendedSnippetsSuggestionDisplayMode() } + + EssentialsBottomSheet(onDismissRequest = onDismissRequest) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column { + Text( + text = stringResource(R.string.snippets_display_style_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(R.string.snippets_display_style_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + RoundedCardContainer(spacing = 2.dp) { + val options = + listOf( + Triple( + SettingsRepository.SNIPPETS_DISPLAY_FLOATING_PILL, + Pair( + stringResource(R.string.snippets_display_pill_title), + stringResource(R.string.snippets_display_pill_desc), + ), + R.drawable.rounded_keyboard_24, + ), + Triple( + SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND, + Pair( + stringResource(R.string.snippets_display_island_title), + stringResource(R.string.snippets_display_island_desc), + ), + R.drawable.rounded_bolt_24, + ), + Triple( + SettingsRepository.SNIPPETS_DISPLAY_DUO, + Pair( + stringResource(R.string.snippets_display_duo_title), + stringResource(R.string.snippets_display_duo_desc), + ), + R.drawable.rounded_circle_24, + ), + Triple( + SettingsRepository.SNIPPETS_DISPLAY_BOTH, + Pair( + stringResource(R.string.snippets_display_both_title), + stringResource(R.string.snippets_display_both_desc), + ), + R.drawable.rounded_auto_awesome_24, + ), + ) + + options.forEach { (mode, textPair, iconRes) -> + val isSelected = currentMode == mode + val isRecommended = mode == recommendedMode + val title = if (isRecommended) "${textPair.first} (${stringResource(R.string.snippets_recommended_tag)})" else textPair.first + IconToggleItem( + iconRes = iconRes, + title = title, + description = textPair.second, + isChecked = isSelected, + showToggle = false, + trailingContent = + if (isSelected) { + { + Icon( + painter = painterResource(R.drawable.rounded_check_24), + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + } + } else { + null + }, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onModeSelected(mode) + onDismissRequest() + }, + onCheckedChange = { + HapticUtil.performVirtualKeyHaptic(view) + onModeSelected(mode) + onDismissRequest() + }, + ) + } + } + + Spacer(Modifier.height(16.dp)) + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt index 6b57a4cea2..c5c8a6c42c 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt @@ -62,6 +62,7 @@ import com.sameerasw.essentials.ui.core.sheets.SingleAppSelectionSheet import com.sameerasw.essentials.ui.core.sheets.SometimesEssentialsSettingsSheet import com.sameerasw.essentials.ui.core.sheets.SoundModeSettingsSheet import com.sameerasw.essentials.ui.features.apps.sheets.KeyboardSelectionSheet +import com.sameerasw.essentials.ui.features.apps.sheets.SnippetDisplayStyleSheet import com.sameerasw.essentials.ui.features.audio.sheets.SetVolumeSettingsSheet import com.sameerasw.essentials.ui.features.display.sheets.DuoBatteryOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.DuoBatteryPercentageOptionsBottomSheet @@ -95,6 +96,8 @@ fun DuoSettingsUI( var showNetworkOptionsSheet by remember { mutableStateOf(false) } var showTimeOptionsSheet by remember { mutableStateOf(false) } var showMediaAppSelectionSheet by remember { mutableStateOf(false) } + var showSnippetStyleSheet by remember { mutableStateOf(false) } + var snippetDisplayMode by remember { mutableStateOf(SettingsRepository(context).getSnippetsSuggestionDisplayMode()) } var pickingActionForGesture by remember { mutableStateOf(null) } var activeConfigGesture by remember { mutableStateOf(null) } @@ -451,6 +454,44 @@ fun DuoSettingsUI( }, modifier = Modifier.highlight(highlightSetting == "duo_show_flashlight"), ) + + val isSnippetsDuoActive = snippetDisplayMode == SettingsRepository.SNIPPETS_DISPLAY_DUO || + snippetDisplayMode == SettingsRepository.SNIPPETS_DISPLAY_BOTH + IconToggleItem( + iconRes = R.drawable.rounded_text_snippet_24, + title = stringResource(R.string.label_keyboard_snippets), + description = when (snippetDisplayMode) { + SettingsRepository.SNIPPETS_DISPLAY_DUO -> stringResource(R.string.snippets_display_duo_title) + SettingsRepository.SNIPPETS_DISPLAY_BOTH -> stringResource(R.string.snippets_display_both_title) + SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> stringResource(R.string.snippets_display_island_title) + else -> stringResource(R.string.snippets_display_pill_title) + }, + isChecked = isSnippetsDuoActive, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + val repo = SettingsRepository(context) + if (checked) { + repo.setSnippetsUniversalEnabled(true) + val newMode = if (snippetDisplayMode == SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND) { + SettingsRepository.SNIPPETS_DISPLAY_BOTH + } else { + SettingsRepository.SNIPPETS_DISPLAY_DUO + } + repo.setSnippetsSuggestionDisplayMode(newMode) + snippetDisplayMode = newMode + } else { + val newMode = if (snippetDisplayMode == SettingsRepository.SNIPPETS_DISPLAY_BOTH) { + SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND + } else { + SettingsRepository.SNIPPETS_DISPLAY_FLOATING_PILL + } + repo.setSnippetsSuggestionDisplayMode(newMode) + snippetDisplayMode = newMode + } + }, + onSettingsClick = { showSnippetStyleSheet = true }, + modifier = Modifier.highlight(highlightSetting == "duo_show_snippets"), + ) } Text( @@ -1143,5 +1184,16 @@ fun DuoSettingsUI( context = context, ) } + + if (showSnippetStyleSheet) { + SnippetDisplayStyleSheet( + currentMode = snippetDisplayMode, + onModeSelected = { newMode -> + snippetDisplayMode = newMode + SettingsRepository(context).setSnippetsSuggestionDisplayMode(newMode) + }, + onDismissRequest = { showSnippetStyleSheet = false }, + ) + } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt index 3653bb0d83..005b2a567d 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt @@ -58,6 +58,7 @@ import com.sameerasw.essentials.ui.core.cards.IconToggleItem import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.ui.core.sheets.AppSelectionSheet import com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet +import com.sameerasw.essentials.ui.features.apps.sheets.SnippetDisplayStyleSheet import com.sameerasw.essentials.ui.features.consciousgate.CONSCIOUS_GATE_FEATURE_ID import com.sameerasw.essentials.ui.features.display.actions.GestureActionPickerSheet import com.sameerasw.essentials.ui.features.display.actions.HorizontalSlideModeSheet @@ -176,6 +177,8 @@ fun IslandSettingsUI( var showWeatherOptionsSheet by remember { mutableStateOf(false) } var pickingGesture by remember { mutableStateOf(null) } var showSlideModeSheet by remember { mutableStateOf(false) } + var showSnippetStyleSheet by remember { mutableStateOf(false) } + var snippetDisplayMode by remember { mutableStateOf(SettingsRepository(context).getSnippetsSuggestionDisplayMode()) } val hasShellPermission = if (ShellUtils.isRootEnabled(context)) { @@ -878,6 +881,44 @@ fun IslandSettingsUI( onSettingsClick = { showDevicesBatterySheet = true }, modifier = Modifier.highlight(highlightSetting == "island_show_devices"), ) + + val isSnippetsIslandActive = snippetDisplayMode == SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND || + snippetDisplayMode == SettingsRepository.SNIPPETS_DISPLAY_BOTH + IconToggleItem( + iconRes = R.drawable.rounded_text_snippet_24, + title = stringResource(R.string.label_keyboard_snippets), + description = when (snippetDisplayMode) { + SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> stringResource(R.string.snippets_display_island_title) + SettingsRepository.SNIPPETS_DISPLAY_BOTH -> stringResource(R.string.snippets_display_both_title) + SettingsRepository.SNIPPETS_DISPLAY_DUO -> stringResource(R.string.snippets_display_duo_title) + else -> stringResource(R.string.snippets_display_pill_title) + }, + isChecked = isSnippetsIslandActive, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + val repo = SettingsRepository(context) + if (checked) { + repo.setSnippetsUniversalEnabled(true) + val newMode = if (snippetDisplayMode == SettingsRepository.SNIPPETS_DISPLAY_DUO) { + SettingsRepository.SNIPPETS_DISPLAY_BOTH + } else { + SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND + } + repo.setSnippetsSuggestionDisplayMode(newMode) + snippetDisplayMode = newMode + } else { + val newMode = if (snippetDisplayMode == SettingsRepository.SNIPPETS_DISPLAY_BOTH) { + SettingsRepository.SNIPPETS_DISPLAY_DUO + } else { + SettingsRepository.SNIPPETS_DISPLAY_FLOATING_PILL + } + repo.setSnippetsSuggestionDisplayMode(newMode) + snippetDisplayMode = newMode + } + }, + onSettingsClick = { showSnippetStyleSheet = true }, + modifier = Modifier.highlight(highlightSetting == "island_show_snippets"), + ) } RoundedCardContainer( @@ -1153,4 +1194,15 @@ fun IslandSettingsUI( allowIconEdit = true, ) } + + if (showSnippetStyleSheet) { + SnippetDisplayStyleSheet( + currentMode = snippetDisplayMode, + onModeSelected = { newMode -> + snippetDisplayMode = newMode + SettingsRepository(context).setSnippetsSuggestionDisplayMode(newMode) + }, + onDismissRequest = { showSnippetStyleSheet = false }, + ) + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b519b855c2..c2bca0c5a9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3024,6 +3024,17 @@ Instantly expands keyword on Space in any app Floating suggestion pill Show floating pill above keyboard when trigger prefix is typed + Suggestion display style + Choose where snippet suggestions appear when typing + Floating Pill + Floating chip positioned directly above keyboard + Dynamic Island + Subtle line peek around camera cutout at top of screen + Duo Camera Ring + Ambient camera ring pulse and glow around punch-hole + Dynamic Island & Duo + Simultaneous suggestions on Dynamic Island and Duo camera ring + Recommended From 384f7309d95e12355de53817dc934687408d76fa Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:40:18 +0530 Subject: [PATCH 11/27] feat(island): wire snippet plugin, controller tap handling, and keyboard settings - Add SnippetIslandPlugin to display snippet keyword peeks on Dynamic Island - Register SnippetIslandPlugin in IslandCoordinator and expose showSnippet/hideSnippet - Update IslandController to execute custom interactions.onTap on Line stage items - Wire SnippetAccessibilityHandler lifecycle into ScreenOffAccessibilityService - Expose Text snippets item in KeyboardSettingsUI to open management bottom sheet --- .../plugins/snippets/SnippetIslandPlugin.kt | 110 ++++++++++++++++++ .../island/service/IslandCoordinator.kt | 12 ++ .../island/state/IslandController.kt | 10 +- .../tiles/ScreenOffAccessibilityService.kt | 13 ++- .../ui/features/system/KeyboardSettingsUI.kt | 46 +++++++- 5 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetIslandPlugin.kt diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetIslandPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetIslandPlugin.kt new file mode 100644 index 0000000000..bb02daf3f8 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetIslandPlugin.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Dynamic Island - Plugins + * File: SnippetIslandPlugin.kt + * Description: Dynamic Island plugin showing snippet suggestions and peek banners for external keyboards. + */ + +package com.sameerasw.essentials.island.plugins.snippets + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.ime.snippets.Snippet +import com.sameerasw.essentials.island.model.CompactCell +import com.sameerasw.essentials.island.model.CompactPlacement +import com.sameerasw.essentials.island.model.InteractionOverrides +import com.sameerasw.essentials.island.model.IslandItem +import com.sameerasw.essentials.island.model.IslandPriority +import com.sameerasw.essentials.island.model.LineContent +import com.sameerasw.essentials.island.model.PluginRequest +import com.sameerasw.essentials.island.plugins.BaseIslandPlugin +import com.sameerasw.essentials.island.ui.components.IslandIcon +import com.sameerasw.essentials.island.ui.components.RollingText + +class SnippetIslandPlugin : BaseIslandPlugin() { + companion object { + const val ITEM_KEY = "snippet_suggestion" + private const val PEEK_DURATION_MS = 4500L + } + + override val id = "snippets" + + private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper()) + private val autoDismissRunnable = Runnable { hideSnippet() } + + private var activeSnippet: Snippet? = null + private var onExpandAction: (() -> Unit)? = null + + fun showSnippet(snippet: Snippet, onExpand: () -> Unit) { + activeSnippet = snippet + onExpandAction = onExpand + render() + ctx?.request(PluginRequest.Peek(ITEM_KEY, PEEK_DURATION_MS)) + mainHandler.removeCallbacks(autoDismissRunnable) + mainHandler.postDelayed(autoDismissRunnable, PEEK_DURATION_MS) + } + + fun hideSnippet() { + mainHandler.removeCallbacks(autoDismissRunnable) + if (activeSnippet == null) return + activeSnippet = null + onExpandAction = null + publish(emptyList()) + ctx?.request(PluginRequest.Collapse(ITEM_KEY)) + } + + override fun onStop() { + mainHandler.removeCallbacks(autoDismissRunnable) + activeSnippet = null + onExpandAction = null + } + + private fun render() { + val snippet = activeSnippet + if (snippet == null) { + publish(emptyList()) + return + } + + val item = IslandItem( + key = ITEM_KEY, + priority = IslandPriority.NOTIFICATION - 1, + placement = CompactPlacement.Dynamic, + compact = listOf( + CompactCell("snippet.icon") { + IslandIcon( + res = R.drawable.rounded_text_snippet_24, + size = 18.dp, + tint = MaterialTheme.colorScheme.primary, + ) + }, + CompactCell("snippet.keyword") { + RollingText(snippet.keyword) + }, + ), + line = LineContent( + icon = { + IslandIcon( + res = R.drawable.rounded_text_snippet_24, + tint = MaterialTheme.colorScheme.primary, + ) + }, + start = snippet.keyword, + end = snippet.title.ifEmpty { snippet.content.take(24) }, + ), + interactions = InteractionOverrides( + onTap = { + onExpandAction?.invoke() + hideSnippet() + true + }, + ), + dismissible = true, + onDismiss = { hideSnippet() }, + ) + publish(listOf(item)) + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt b/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt index d558614b91..018d696e1e 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt @@ -4,6 +4,7 @@ import android.provider.Settings import com.sameerasw.essentials.island.plugins.alarm.AlarmPlugin import com.sameerasw.essentials.island.plugins.timer.TimerPlugin import com.sameerasw.essentials.island.plugins.call.CallPlugin +import com.sameerasw.essentials.island.plugins.snippets.SnippetIslandPlugin import android.accessibilityservice.AccessibilityService import android.app.KeyguardManager import android.content.BroadcastReceiver @@ -95,6 +96,8 @@ class IslandCoordinator( anchorProvider = { geometry?.anchor ?: CameraAnchor.Center }, ) + private val snippetPlugin = SnippetIslandPlugin() + private val plugins: List = listOf( CallPlugin(), TimeBatteryPlugin(), @@ -113,6 +116,7 @@ class IslandCoordinator( NetworkPlugin(), DevicesPlugin(), BriefPlugin(), + snippetPlugin, ) private var scope: CoroutineScope? = null @@ -327,6 +331,14 @@ class IslandCoordinator( } } + fun showSnippet(snippet: com.sameerasw.essentials.ime.snippets.Snippet, onExpand: () -> Unit) { + mainHandler.post { snippetPlugin.showSnippet(snippet, onExpand) } + } + + fun hideSnippet() { + mainHandler.post { snippetPlugin.hideSnippet() } + } + fun onDestroy() { mainHandler.removeCallbacks(applyForegroundPackage) mainHandler.removeCallbacks(applyFullscreen) diff --git a/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt b/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt index 8a33342688..79937b29f9 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt @@ -97,7 +97,15 @@ class IslandController( collapse() true } - IslandStage.Line -> current.focusedKey?.let { expand(it); true } ?: false + IslandStage.Line -> { + val focused = current.focused + if (focused?.interactions?.onTap?.invoke() == true) { + collapse() + true + } else { + current.focusedKey?.let { expand(it); true } ?: false + } + } IslandStage.Compact -> { val target = itemKey?.let { current.items[it] }?.takeIf { it.expanded != null } ?: defaultTapTarget(current) diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt index b151635c73..4c11c9a16a 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt @@ -281,7 +281,7 @@ class ScreenOffAccessibilityService : key == SettingsRepository.KEY_STATUS_GLANCE_HIDE_WHEN_LOCKED ) { statusGlanceHandler.updateState() - } else if (key?.startsWith("snippets_universal_") == true) { + } else if (key?.startsWith("snippets_") == true) { if (::snippetAccessibilityHandler.isInitialized) { snippetAccessibilityHandler.updateSettings() } @@ -312,7 +312,12 @@ class ScreenOffAccessibilityService : duoOverlayHandler.openBrief = { islandOverlayHandler.openBrief() } statusGlanceHandler = StatusGlanceHandler(this) snippetAccessibilityHandler = - com.sameerasw.essentials.services.handlers.SnippetAccessibilityHandler(this, serviceScope) + com.sameerasw.essentials.services.handlers.SnippetAccessibilityHandler( + service = this, + scope = serviceScope, + islandCoordinator = islandOverlayHandler, + duoOverlayHandler = duoOverlayHandler, + ) flashlightHandler.register() statusBarIconHandler.register() @@ -392,12 +397,12 @@ class ScreenOffAccessibilityService : } "CONSCIOUS_GATE_CONFIRMED" -> { - intent?.getStringExtra("package_name")?.let { appFlowHandler.onConsciousGateConfirmed(it) } + intent.getStringExtra("package_name")?.let { appFlowHandler.onConsciousGateConfirmed(it) } islandOverlayHandler.updateConsciousGateState() } "CONSCIOUS_GATE_CLOSED" -> { - intent?.getStringExtra("package_name")?.let { appFlowHandler.onConsciousGateClosed(it) } + intent.getStringExtra("package_name")?.let { appFlowHandler.onConsciousGateClosed(it) } islandOverlayHandler.updateConsciousGateState() } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt index 3ed98ce049..b03910ccd8 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt @@ -60,6 +60,9 @@ fun KeyboardSettingsUI( val isKeyboardEnabled by viewModel.isKeyboardEnabled val isKeyboardSelected by viewModel.isKeyboardSelected var isSnippetsSheetVisible by remember { mutableStateOf(false) } + val settingsRepo = remember { com.sameerasw.essentials.data.repository.SettingsRepository(context) } + var suggestionDisplayMode by remember { mutableStateOf(settingsRepo.getSnippetsSuggestionDisplayMode()) } + var isDisplayStyleSheetVisible by remember { mutableStateOf(false) } LaunchedEffect(Unit) { while (true) { @@ -353,7 +356,6 @@ fun KeyboardSettingsUI( ) RoundedCardContainer(spacing = 2.dp) { - val settingsRepo = remember { com.sameerasw.essentials.data.repository.SettingsRepository(context) } var universalOn by remember { mutableStateOf(settingsRepo.isSnippetsUniversalEnabled()) } IconToggleItem( @@ -368,6 +370,32 @@ fun KeyboardSettingsUI( modifier = Modifier.highlight(highlightSetting == "snippets_universal"), ) + if (universalOn) { + val modeLabel = when (suggestionDisplayMode) { + com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> stringResource(R.string.snippets_display_island_title) + com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_DUO -> stringResource(R.string.snippets_display_duo_title) + com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_BOTH -> stringResource(R.string.snippets_display_both_title) + else -> stringResource(R.string.snippets_display_pill_title) + } + val modeIcon = when (suggestionDisplayMode) { + com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> R.drawable.rounded_bolt_24 + com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_DUO -> R.drawable.rounded_circle_24 + com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_BOTH -> R.drawable.rounded_auto_awesome_24 + else -> R.drawable.rounded_keyboard_24 + } + + IconToggleItem( + iconRes = modeIcon, + title = stringResource(R.string.snippets_display_style_title), + description = modeLabel, + isChecked = false, + showToggle = false, + onClick = { isDisplayStyleSheetVisible = true }, + onCheckedChange = { isDisplayStyleSheetVisible = true }, + modifier = Modifier.highlight(highlightSetting == "snippets_display_style"), + ) + } + IconToggleItem( iconRes = R.drawable.rounded_text_snippet_24, title = stringResource(R.string.label_keyboard_snippets), @@ -391,7 +419,21 @@ fun KeyboardSettingsUI( if (isSnippetsSheetVisible) { com.sameerasw.essentials.ui.features.apps.sheets.SnippetsBottomSheet( - onDismissRequest = { isSnippetsSheetVisible = false }, + onDismissRequest = { + isSnippetsSheetVisible = false + suggestionDisplayMode = settingsRepo.getSnippetsSuggestionDisplayMode() + }, + ) + } + + if (isDisplayStyleSheetVisible) { + com.sameerasw.essentials.ui.features.apps.sheets.SnippetDisplayStyleSheet( + currentMode = suggestionDisplayMode, + onModeSelected = { newMode -> + suggestionDisplayMode = newMode + settingsRepo.setSnippetsSuggestionDisplayMode(newMode) + }, + onDismissRequest = { isDisplayStyleSheetVisible = false }, ) } } From e7aa154d350181ebb251154fe40f3d9a3bf2b0ad Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:57:43 +0530 Subject: [PATCH 12/27] feat(island): support custom plugin long-press overrides without auto-collapse - Allow InteractionOverrides.onLongPress to return Any? to support both Unit and Boolean lambdas - Prevent premature collapse in IslandController when onLongPress returns true - Route targeted item long-press events in IslandRoot before falling back to compact gestures --- .../sameerasw/essentials/island/model/IslandModels.kt | 2 +- .../essentials/island/state/IslandController.kt | 7 ++++++- .../com/sameerasw/essentials/island/ui/IslandRoot.kt | 10 +++++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt b/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt index 7a895fbec2..686bbb4121 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt @@ -68,7 +68,7 @@ class ExpandedContent(val content: @Composable (IslandExpandedScope) -> Unit) class InteractionOverrides( val onTap: (() -> Boolean)? = null, - val onLongPress: (() -> Unit)? = null, + val onLongPress: (() -> Any)? = null, ) { companion object { val Default = InteractionOverrides() diff --git a/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt b/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt index 79937b29f9..8c4609d257 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt @@ -122,7 +122,12 @@ class IslandController( val item = (if (current.stage == IslandStage.Compact) itemKey?.let { current.items[it] } else current.focused) ?: defaultTapTarget(current) ?: return - val action = item.interactions.onLongPress ?: item.onOpen ?: return + if (item.interactions.onLongPress != null) { + val handled = item.interactions.onLongPress.invoke() + if (handled != true) collapse() + return + } + val action = item.onOpen ?: return action() collapse() } diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt index 4f5a48708a..6c75edeb94 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt @@ -265,8 +265,16 @@ fun IslandRoot( } fun handleLongPress(itemKey: String?) { + val current = currentState + val targetedItem = (if (current.stage == IslandStage.Compact) itemKey?.let { current.items[it] } else current.focused) + ?: current.items.values.firstOrNull { it.interactions.onLongPress != null } + if (targetedItem?.interactions?.onLongPress != null) { + IslandHaptics.longPress(context) + actions.onLongPress(targetedItem.key) + return + } val gestures = actions.compactGestures - if (currentState.stage == IslandStage.Compact && gestures.hasLongPress) { + if (current.stage == IslandStage.Compact && gestures.hasLongPress) { compactLongPressed = true if (gestures.longPressOpensBrief) IslandHaptics.openClick(context) else IslandHaptics.commit(context) gestures.longPress() From e98cae7d6a3e2a88850ab05844b1c684064c47e8 Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:57:52 +0530 Subject: [PATCH 13/27] feat(snippets): add dynamic island expanded card and clipboard copy on tap - Add localized action strings for insert and copy actions - Implement SnippetExpanded composable with header badge, preview, and button row - Wire clipboard copying on tap and long-press expansion in SnippetIslandPlugin --- .../plugins/snippets/SnippetExpanded.kt | 149 ++++++++++++++++++ .../plugins/snippets/SnippetIslandPlugin.kt | 39 +++++ app/src/main/res/values/strings.xml | 3 + 3 files changed, 191 insertions(+) create mode 100644 app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetExpanded.kt diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetExpanded.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetExpanded.kt new file mode 100644 index 0000000000..42afe84718 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetExpanded.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Dynamic Island - Plugins + * File: SnippetExpanded.kt + * Description: Expanded card view for snippet suggestions in Dynamic Island. + */ + +package com.sameerasw.essentials.island.plugins.snippets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.ime.snippets.Snippet +import com.sameerasw.essentials.island.model.IslandExpandedScope +import com.sameerasw.essentials.island.ui.IslandTextStyles +import com.sameerasw.essentials.island.ui.components.CameraRow +import com.sameerasw.essentials.island.ui.components.ConnectedButtonRow +import com.sameerasw.essentials.island.ui.components.ConnectedItem +import com.sameerasw.essentials.island.ui.components.ConnectedTextLabel +import com.sameerasw.essentials.island.ui.components.IslandIcon +import com.sameerasw.essentials.island.ui.components.MarqueeText +import com.sameerasw.essentials.island.ui.components.accentGlow +import com.sameerasw.essentials.island.ui.components.cameraClearance + +@Composable +fun SnippetExpanded( + snippet: Snippet, + scope: IslandExpandedScope, + onInsert: () -> Unit, + onCopy: () -> Unit, + onDismiss: () -> Unit, +) { + val spec = scope.spec + val sidePadding = spec.expandedPadding + spec.expandedCorner * 0.35f + val accent = MaterialTheme.colorScheme.primary + + Box(propagateMinConstraints = true) { + Box(Modifier.matchParentSize().accentGlow(accent, true, scope.cameraClearance)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(spec.expandedOutset), + ) { + Spacer(Modifier.height(spec.expandedTopPadding)) + scope.CameraRow( + horizontalPadding = spec.cameraGap + spec.expandedCorner * 0.35f, + start = { + Box(Modifier.padding(horizontal = 4.dp)) { + IslandIcon( + res = R.drawable.rounded_text_snippet_24, + size = 20.dp, + tint = accent, + ) + } + val displayTitle = snippet.title.ifBlank { snippet.keyword } + MarqueeText( + text = displayTitle, + style = IslandTextStyles.title, + modifier = Modifier.weight(1f), + ) + }, + end = { + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(Color.White.copy(alpha = 0.12f)) + .padding(horizontal = 8.dp, vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = snippet.keyword, + style = IslandTextStyles.compact.copy( + fontSize = 11.sp, + color = accent, + fontWeight = FontWeight.Bold, + ), + ) + } + }, + ) + + Spacer(Modifier.height(8.dp)) + + // Body preview of snippet content + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = sidePadding) + .clip(RoundedCornerShape(14.dp)) + .background(Color.White.copy(alpha = 0.08f)) + .padding(horizontal = 14.dp, vertical = 10.dp), + ) { + Text( + text = snippet.content, + style = IslandTextStyles.body.copy( + color = Color.White, + fontSize = 14.sp, + lineHeight = 19.sp, + ), + maxLines = 6, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(Modifier.height(14.dp)) + + // Action row: Insert, Copy, Dismiss + ConnectedButtonRow( + height = 36.dp, + modifier = Modifier.padding(horizontal = sidePadding), + container = Color.White.copy(alpha = 0.2f), + items = listOf( + ConnectedItem(onInsert) { + ConnectedTextLabel(stringResource(R.string.snippets_action_insert)) + }, + ConnectedItem(onCopy) { + ConnectedTextLabel(stringResource(R.string.snippets_action_copy)) + }, + ConnectedItem(onDismiss) { + ConnectedTextLabel(stringResource(R.string.action_dismiss)) + }, + ), + ) + + Spacer(Modifier.height(spec.expandedBottomPadding)) + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetIslandPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetIslandPlugin.kt index bb02daf3f8..24d4998773 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetIslandPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetIslandPlugin.kt @@ -9,12 +9,17 @@ package com.sameerasw.essentials.island.plugins.snippets +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast import androidx.compose.material3.MaterialTheme import androidx.compose.ui.unit.dp import com.sameerasw.essentials.R import com.sameerasw.essentials.ime.snippets.Snippet import com.sameerasw.essentials.island.model.CompactCell import com.sameerasw.essentials.island.model.CompactPlacement +import com.sameerasw.essentials.island.model.ExpandedContent import com.sameerasw.essentials.island.model.InteractionOverrides import com.sameerasw.essentials.island.model.IslandItem import com.sameerasw.essentials.island.model.IslandPriority @@ -62,6 +67,15 @@ class SnippetIslandPlugin : BaseIslandPlugin() { onExpandAction = null } + private fun copyToClipboard(text: String) { + try { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + val clip = ClipData.newPlainText("Snippet", text) + cm?.setPrimaryClip(clip) + } catch (_: Exception) { + } + } + private fun render() { val snippet = activeSnippet if (snippet == null) { @@ -95,12 +109,37 @@ class SnippetIslandPlugin : BaseIslandPlugin() { start = snippet.keyword, end = snippet.title.ifEmpty { snippet.content.take(24) }, ), + expanded = ExpandedContent { scope -> + SnippetExpanded( + snippet = snippet, + scope = scope, + onInsert = { + copyToClipboard(snippet.content) + onExpandAction?.invoke() + hideSnippet() + }, + onCopy = { + copyToClipboard(snippet.content) + Toast.makeText(context, R.string.snippets_action_copied, Toast.LENGTH_SHORT).show() + hideSnippet() + }, + onDismiss = { + hideSnippet() + }, + ) + }, interactions = InteractionOverrides( onTap = { + copyToClipboard(snippet.content) onExpandAction?.invoke() hideSnippet() true }, + onLongPress = { + mainHandler.removeCallbacks(autoDismissRunnable) + ctx?.request(PluginRequest.Expand(ITEM_KEY)) + true + }, ), dismissible = true, onDismiss = { hideSnippet() }, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c2bca0c5a9..d24f045c86 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3035,6 +3035,9 @@ Dynamic Island & Duo Simultaneous suggestions on Dynamic Island and Duo camera ring Recommended + Insert + Copy + Copied to clipboard From ea0fbf53061f2f76dd2beac61299b42809129c82 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 11 Oct 2026 17:21:20 +0530 Subject: [PATCH 14/27] chore: remove unused string resources for Turkish and Spanish translations --- app/src/main/res/values-es/strings.xml | 7 ------- app/src/main/res/values-tr/strings.xml | 1 - 2 files changed, 8 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 053c1af170..538a85933f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1778,13 +1778,6 @@ Requiere permiso de administrador del dispositivo si se utiliza el bloqueo.Tiempo de espera expandido Gestos siempre disponibles Mantener activa el área de la Isla para los Gestos - Hora y bateria - Unidades - Efectos del clima - Háptica de simulación - Permiso del clima de Overcast - Essentials lee el ultimo clima de tu actual o guardada ubicacion de Overcast - Mostrar en Isla Use Gemini Nano en el dispositivo para ayudar a crear automatizaciones a partir de descripciones Activar el uso de GenAI Animación de onda diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 9c90b2d0a9..cee8174e6c 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1796,7 +1796,6 @@ Gizlendiğinde, bildirimler ekranınızın üzerinde belirmek yerine bildirim pa Dokun Ağ Bildirimler - Saat Ve Batarya Çağrı Cihazlar Müzik Oynarken Beğen From e4fdac0733f26cca0b2daee87b05cfc13be177b9 Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:03:35 +0530 Subject: [PATCH 15/27] fix(ci): resolve onLongPress type mismatch and restore missing watchface string --- .../java/com/sameerasw/essentials/island/model/IslandModels.kt | 2 +- app/src/main/res/values/strings.xml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt b/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt index bcb4c81286..3a0a8aca05 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt @@ -71,7 +71,7 @@ class ExpandedContent(val content: @Composable (IslandExpandedScope) -> Unit) class InteractionOverrides( val onTap: (() -> Boolean)? = null, - val onLongPress: (() -> Unit)? = null, + val onLongPress: (() -> Any)? = null, val onExpandedTap: (() -> Boolean)? = null, ) { companion object { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6a63e5cbc9..6dbbdc18d9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3228,6 +3228,7 @@ Join Beta Program 3. Install on Watch Open the Play Store listing directly on your watch to install + Open on Watch Weather is Now Separate Weather has been separated into my brand new app, Overcast. Install to continue having weather forecasts and effects in Island brief. From e4ad22f31e749dde892b1be26cef88a4409c2e0c Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:14:07 +0530 Subject: [PATCH 16/27] fix(snippets): protect password inputs, fix duo keyword format, and harden expander --- .../ime/snippets/SnippetExpander.kt | 20 ++++++++++++------- .../services/handlers/DuoOverlayHandler.kt | 3 ++- .../handlers/SnippetAccessibilityHandler.kt | 1 + 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetExpander.kt b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetExpander.kt index f7516876b7..d958d1a0bc 100644 --- a/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetExpander.kt +++ b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetExpander.kt @@ -11,13 +11,14 @@ package com.sameerasw.essentials.ime.snippets import android.content.ClipboardManager import android.content.Context -import java.text.SimpleDateFormat -import java.util.Date +import java.time.LocalDate +import java.time.LocalTime +import java.time.format.DateTimeFormatter import java.util.Locale object SnippetExpander { - private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) - private val TIME_FORMAT = SimpleDateFormat("h:mm a", Locale.getDefault()) + private val DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.getDefault()) + private val TIME_FORMAT = DateTimeFormatter.ofPattern("h:mm a", Locale.getDefault()) /** * Replaces dynamic placeholders such as {date}, {time}, and {clipboard} with actual values. @@ -25,10 +26,10 @@ object SnippetExpander { fun expand(template: String, context: Context?): String { var result = template if (result.contains("{date}", ignoreCase = true)) { - result = result.replace("{date}", DATE_FORMAT.format(Date()), ignoreCase = true) + result = result.replace("{date}", LocalDate.now().format(DATE_FORMAT), ignoreCase = true) } if (result.contains("{time}", ignoreCase = true)) { - result = result.replace("{time}", TIME_FORMAT.format(Date()), 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) } ?: "" @@ -40,7 +41,12 @@ object SnippetExpander { private fun getClipboardText(context: Context): String { return try { val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager - cm?.primaryClip?.getItemAt(0)?.text?.toString().orEmpty() + val clip = cm?.primaryClip + if (clip != null && clip.itemCount > 0) { + clip.getItemAt(0)?.text?.toString().orEmpty() + } else { + "" + } } catch (_: Exception) { "" } diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt index 7da1577b79..53cf191557 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt @@ -371,7 +371,8 @@ class DuoOverlayHandler( restoreTouchAnchor() } val titleText = snippet.title.ifBlank { snippet.content.take(18) } - overlayView?.setInteractiveSnippet(keyword = "!${snippet.keyword}", title = titleText) + val displayKeyword = if (snippet.keyword.startsWith("!")) snippet.keyword else "!${snippet.keyword}" + overlayView?.setInteractiveSnippet(keyword = displayKeyword, title = titleText) } } diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt index fd9c77220a..1e73441f85 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt @@ -83,6 +83,7 @@ class SnippetAccessibilityHandler( if (!isUniversalEnabled) return val sourceNode = event.source ?: return + if (event.isPassword || sourceNode.isPassword) return if (!sourceNode.isEditable) return activeNode = sourceNode From 41f7a61d913894e2a11768f25f19d31d78c5dc59 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 11 Oct 2026 20:26:23 +0530 Subject: [PATCH 17/27] refactor: clear secure settings cache on tile start listening --- .../com/sameerasw/essentials/services/tiles/BaseTileService.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/BaseTileService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/BaseTileService.kt index 7742b63057..5553fdb3e2 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/BaseTileService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/BaseTileService.kt @@ -53,6 +53,7 @@ abstract class BaseTileService : TileService() { override fun onStartListening() { super.onStartListening() + secureSettingsCache.clear() setTileAddedState(true) updateTile() } From ae72ef16c437ffaab5d4bc09a6bc52366cefe2b9 Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:35:02 +0530 Subject: [PATCH 18/27] fix(snippets): resolve review blockers on privacy, expansion safety, and data integrity --- .../data/repository/SettingsRepository.kt | 2 +- .../essentials/ime/SuggestionEngine.kt | 30 +++--- .../ime/snippets/SnippetExpander.kt | 3 - .../ime/snippets/SnippetRepository.kt | 52 +++++---- .../island/state/IslandController.kt | 17 +-- .../services/handlers/DuoTouchHandler.kt | 26 +++-- .../handlers/SnippetAccessibilityHandler.kt | 102 ++++++++++++------ .../tiles/ScreenOffAccessibilityService.kt | 14 +-- .../essentials/ime/SnippetExpanderTest.kt | 49 +++++++++ 9 files changed, 186 insertions(+), 109 deletions(-) create mode 100644 app/src/test/java/com/sameerasw/essentials/ime/SnippetExpanderTest.kt diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index c123fd2fca..1aecf909de 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -4288,7 +4288,7 @@ 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, true) + 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) diff --git a/app/src/main/java/com/sameerasw/essentials/ime/SuggestionEngine.kt b/app/src/main/java/com/sameerasw/essentials/ime/SuggestionEngine.kt index 5726f32221..2687c3e7cd 100644 --- a/app/src/main/java/com/sameerasw/essentials/ime/SuggestionEngine.kt +++ b/app/src/main/java/com/sameerasw/essentials/ime/SuggestionEngine.kt @@ -67,6 +67,7 @@ class SuggestionEngine( // Current word being looked up to handle sync/async merging private var currentWord: String = "" private var currentSymSpellSuggestions: List = emptyList() + private var currentMatchingSnippets: List = emptyList() fun initialize(scope: CoroutineScope) { // Init Android Session (Main Thread) @@ -208,6 +209,8 @@ class SuggestionEngine( ) } + currentMatchingSnippets = matchingSnippets + // 1. SymSpell (Fast, Predictive - only for letter words) // Run on default dispatcher but wait for result to show immediately val symResults = @@ -222,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() @@ -233,8 +236,8 @@ class SuggestionEngine( } currentSymSpellSuggestions = symResults - // Update immediately with Snippets + SymSpell results (Native usually takes longer) - _suggestions.value = (matchingSnippets + symResults).distinctBy { it.text } + val uniqueSymResults = symResults.distinctBy { it.text.lowercase() } + _suggestions.value = matchingSnippets + uniqueSymResults // 2. Android (Corrective, Slower - only for letter words) val s = session @@ -250,15 +253,9 @@ class SuggestionEngine( } override fun onGetSuggestions(results: Array?) { - // Runs on binder thread usually, switch to Main logic if needed, but StateFlow is thread safe. - // We want to merge with currentSymSpellSuggestions and keep snippet suggestions - - 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() val count = info.suggestionsCount if (count > 0) { @@ -268,14 +265,13 @@ class SuggestionEngine( } // Merge: Snippets + SymSpell (Prediction) + Android (Correction) - // Deduplicate - val snippetSuggestions = _suggestions.value.filter { it.type == SuggestionType.Snippet } + // Deduplicate dictionary suggestions without dropping snippets val androidSuggestionsList = androidSuggestions.map { Suggestion(it, SuggestionType.Correction) } - val merged = - (snippetSuggestions + currentSymSpellSuggestions + androidSuggestionsList) - .distinctBy { it.text } - .take(8) + val uniqueDictSuggestions = + (currentSymSpellSuggestions + androidSuggestionsList) + .distinctBy { it.text.lowercase() } + val merged = (currentMatchingSnippets + uniqueDictSuggestions).take(8) _suggestions.value = merged } @@ -285,6 +281,8 @@ class SuggestionEngine( fun clearSuggestions() { _suggestions.value = emptyList() currentSymSpellSuggestions = emptyList() + currentMatchingSnippets = emptyList() + currentWord = "" } fun getUserWords() = userWords.toMap() diff --git a/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetExpander.kt b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetExpander.kt index d958d1a0bc..278fbffa86 100644 --- a/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetExpander.kt +++ b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetExpander.kt @@ -20,9 +20,6 @@ object SnippetExpander { private val DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.getDefault()) private val TIME_FORMAT = DateTimeFormatter.ofPattern("h:mm a", Locale.getDefault()) - /** - * Replaces dynamic placeholders such as {date}, {time}, and {clipboard} with actual values. - */ fun expand(template: String, context: Context?): String { var result = template if (result.contains("{date}", ignoreCase = true)) { diff --git a/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt index 596af7f3f7..0e8619e461 100644 --- a/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt @@ -22,12 +22,18 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream +import android.util.AtomicFile +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock class SnippetRepository private constructor(context: Context) { private val appContext = context.applicationContext private val scope = CoroutineScope(Dispatchers.IO) private val gson = Gson() private val storageFile = File(appContext.filesDir, "snippets.json") + private val atomicFile = android.util.AtomicFile(storageFile) + private val fileMutex = Mutex() private val _snippets = MutableStateFlow>(emptyList()) val snippets: StateFlow> = _snippets.asStateFlow() @@ -47,9 +53,9 @@ class SnippetRepository private constructor(context: Context) { content = "{date}", ), Snippet( - title = "Quote Clipboard", - keyword = "!quote", - content = "> {clipboard}", + title = "Shrug", + keyword = "!shrug", + content = "¯\\_(ツ)_/¯", ), ) saveToFile(defaults) @@ -60,7 +66,11 @@ class SnippetRepository private constructor(context: Context) { val json = storageFile.readText() val type = object : TypeToken>() {}.type val loaded: List? = gson.fromJson(json, type) - _snippets.value = loaded ?: emptyList() + if (loaded != null) { + _snippets.value = loaded + } else { + Log.w(TAG, "Snippets file read returned null; preserving memory state") + } } catch (e: Exception) { Log.e(TAG, "Error loading snippets", e) } @@ -68,45 +78,47 @@ class SnippetRepository private constructor(context: Context) { } private suspend fun saveToFile(items: List) = withContext(Dispatchers.IO) { - try { - val json = gson.toJson(items) - FileOutputStream(storageFile).use { fos -> + fileMutex.withLock { + var fos: FileOutputStream? = null + try { + val json = gson.toJson(items) + fos = atomicFile.startWrite() fos.write(json.toByteArray(Charsets.UTF_8)) + atomicFile.finishWrite(fos) + } catch (e: Exception) { + Log.e(TAG, "Error saving snippets", e) + fos?.let { atomicFile.failWrite(it) } } - } catch (e: Exception) { - Log.e(TAG, "Error saving snippets", e) } } fun addSnippet(snippet: Snippet) { - val updated = _snippets.value + snippet - _snippets.value = updated + val updated = _snippets.updateAndGet { current -> current + snippet } scope.launch { saveToFile(updated) } } fun updateSnippet(snippet: Snippet) { - val updated = _snippets.value.map { if (it.id == snippet.id) snippet else it } - _snippets.value = updated + val updated = _snippets.updateAndGet { current -> + current.map { if (it.id == snippet.id) snippet else it } + } scope.launch { saveToFile(updated) } } fun deleteSnippet(id: String) { - val updated = _snippets.value.filterNot { it.id == id } - _snippets.value = updated + val updated = _snippets.updateAndGet { current -> + current.filterNot { it.id == id } + } scope.launch { saveToFile(updated) } } - /** - * Finds snippets matching the current typed word prefix or title. - */ fun findMatching(word: String): List { val query = word.trim() if (query.isEmpty()) return emptyList() + val isTriggerPrefix = query.startsWith("!") || query.startsWith("/") || query.startsWith(":") return _snippets.value.filter { snippet -> snippet.keyword.equals(query, ignoreCase = true) || - snippet.keyword.startsWith(query, ignoreCase = true) || - (query.length >= 2 && snippet.title.contains(query, ignoreCase = true)) + (isTriggerPrefix && snippet.keyword.startsWith(query, ignoreCase = true)) } } diff --git a/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt b/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt index f8f1389e94..e9dc917457 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/state/IslandController.kt @@ -110,15 +110,7 @@ class IslandController( collapse() true } - IslandStage.Line -> { - val focused = current.focused - if (focused?.interactions?.onTap?.invoke() == true) { - collapse() - true - } else { - current.focusedKey?.let { expand(it); true } ?: false - } - } + IslandStage.Line -> current.focusedKey?.let { expand(it); true } ?: false IslandStage.Compact -> { val target = itemKey?.let { current.items[it] }?.takeIf { it.expanded != null } ?: defaultTapTarget(current) @@ -135,12 +127,7 @@ class IslandController( val item = (if (current.stage == IslandStage.Compact) itemKey?.let { current.items[it] } else current.focused) ?: defaultTapTarget(current) ?: return - if (item.interactions.onLongPress != null) { - val handled = item.interactions.onLongPress.invoke() - if (handled != true) collapse() - return - } - val action = item.onOpen ?: return + val action = item.interactions.onLongPress ?: item.onOpen ?: return action() collapse() } diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoTouchHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoTouchHandler.kt index ae48ea70c2..1b337dcb09 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoTouchHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoTouchHandler.kt @@ -50,6 +50,7 @@ class DuoTouchHandler( var ringRadiusScale: Float = 1.0f var openBrief: (() -> Unit)? = null var onSnippetTapAction: (() -> Unit)? = null + private var wasDownInSnippetPill: Boolean = false private var downX: Float = 0f private var downY: Float = 0f @@ -115,6 +116,7 @@ class DuoTouchHandler( val dist = hypot(x - cameraCenterX, y - cameraCenterY) val isSnippet = overlayView?.isSnippetActive == true && onSnippetTapAction != null val inSnippetPill = isSnippet && overlayView?.snippetPillRect?.contains(x, y) == true + wasDownInSnippetPill = inSnippetPill if (dist > effectiveRadius && !inSnippetPill) { isTouchActiveInCutout = false return false @@ -136,8 +138,7 @@ class DuoTouchHandler( overlayView?.triggerTapAnimation() - if (isSnippet) { - // Suppress ring gestures while snippet suggestion is interactive + if (inSnippetPill) { return true } @@ -237,15 +238,18 @@ class DuoTouchHandler( isSoundModeTriggered || isVolumeOrBrightnessAdjusted - val snippetTap = onSnippetTapAction - if (snippetTap != null && overlayView?.isSnippetActive == true) { - if (totalDist < touchSlopPx * 2f && elapsed < 650L) { - onSnippetTapAction = null - overlayView?.resetInteractiveState(animate = true) - HapticUtil.performHapticForService(service, HapticFeedbackType.CLICK) - snippetTap() - isTouchActiveInCutout = false - return true + if (wasDownInSnippetPill) { + wasDownInSnippetPill = false + val snippetTap = onSnippetTapAction + if (snippetTap != null && overlayView?.isSnippetActive == true) { + if (totalDist < touchSlopPx * 2f && elapsed < 650L) { + onSnippetTapAction = null + overlayView?.resetInteractiveState(animate = true) + HapticUtil.performHapticForService(service, HapticFeedbackType.CLICK) + snippetTap() + isTouchActiveInCutout = false + return true + } } } diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt index 1e73441f85..47aa3114b2 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt @@ -70,6 +70,8 @@ class SnippetAccessibilityHandler( private val dismissRunnable = Runnable { hideFloatingPill() } + private var isPerformingReplacement = false + fun updateSettings() { isUniversalEnabled = settingsRepository.isSnippetsUniversalEnabled() isAutoExpandEnabled = settingsRepository.isSnippetsUniversalAutoExpandEnabled() @@ -80,13 +82,17 @@ class SnippetAccessibilityHandler( } fun onTextChanged(event: AccessibilityEvent) { - if (!isUniversalEnabled) return + if (!isUniversalEnabled || isPerformingReplacement) return + + val eventPkg = event.packageName?.toString().orEmpty() + if (eventPkg == service.packageName) return val sourceNode = event.source ?: return if (event.isPassword || sourceNode.isPassword) return if (!sourceNode.isEditable) return - activeNode = sourceNode + activeNode?.recycle() + activeNode = AccessibilityNodeInfo.obtain(sourceNode) val bounds = android.graphics.Rect() sourceNode.getBoundsInScreen(bounds) if (bounds.height() > 0 && bounds.top > 0) { @@ -114,19 +120,20 @@ class SnippetAccessibilityHandler( if (candidateWord.isNotBlank()) { val matchingSnippet = snippetRepository.snippets.value.firstOrNull { snippet -> - snippet.keyword.equals(candidateWord, ignoreCase = false) + snippet.keyword.equals(candidateWord, ignoreCase = true) } if (matchingSnippet != null && matchingSnippet.autoExpandOnSpace) { val expanded = SnippetExpander.expand(matchingSnippet.content, service) - replaceKeywordInNode( + val success = replaceKeywordInNode( node = sourceNode, - fullText = fullText, targetWord = "$candidateWord ", replacement = "$expanded ", ) - dismissAllSuggestions() - return + if (success) { + dismissAllSuggestions() + return + } } } } @@ -149,32 +156,50 @@ class SnippetAccessibilityHandler( private fun replaceKeywordInNode( node: AccessibilityNodeInfo, - fullText: String, targetWord: String, replacement: String, - ) { - val targetIndex = fullText.lastIndexOf(targetWord) - if (targetIndex < 0) return - - val newText = - fullText.substring(0, targetIndex) + - replacement + - fullText.substring(targetIndex + targetWord.length) - - val args = - Bundle().apply { - putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, newText) - } - node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args) - - // Set cursor position at the end of replacement - val newCursorPos = targetIndex + replacement.length - val cursorArgs = - Bundle().apply { - putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, newCursorPos) - putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, newCursorPos) - } - node.performAction(AccessibilityNodeInfo.ACTION_SET_SELECTION, cursorArgs) + ): Boolean { + isPerformingReplacement = true + try { + val fullText = node.text?.toString().orEmpty() + if (fullText.isEmpty()) return false + + val cursor = node.textSelectionEnd + val targetIndex = + if (cursor in targetWord.length..fullText.length && + fullText.substring(cursor - targetWord.length, cursor).equals(targetWord, ignoreCase = true) + ) { + cursor - targetWord.length + } else { + val lastIdx = fullText.lastIndexOf(targetWord, ignoreCase = true) + if (lastIdx < 0) return false + lastIdx + } + + val newText = + fullText.substring(0, targetIndex) + + replacement + + fullText.substring(targetIndex + targetWord.length) + + val args = + Bundle().apply { + putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, newText) + } + val success = node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args) + if (!success) return false + + // Set cursor position at the end of replacement + val newCursorPos = targetIndex + replacement.length + val cursorArgs = + Bundle().apply { + putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, newCursorPos) + putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, newCursorPos) + } + node.performAction(AccessibilityNodeInfo.ACTION_SET_SELECTION, cursorArgs) + return true + } finally { + mainHandler.postDelayed({ isPerformingReplacement = false }, 150L) + } } private fun calculateImeOffset(): Int { @@ -221,12 +246,20 @@ class SnippetAccessibilityHandler( } ?: service.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) if (targetNode != null) { - val text = targetNode.text?.toString().orEmpty() + val fullText = targetNode.text?.toString().orEmpty() + val cursor = targetNode.textSelectionEnd + val textBeforeCursor = if (cursor in 0..fullText.length) fullText.substring(0, cursor) else fullText + val candidateWord = textBeforeCursor.takeLastWhile { !it.isWhitespace() } + val targetWord = if (candidateWord.isNotBlank() && (snippet.keyword.startsWith(candidateWord, ignoreCase = true) || candidateWord.equals(lastTypedWord, ignoreCase = true))) { + candidateWord + } else { + lastTypedWord.ifBlank { snippet.keyword } + } + val expanded = SnippetExpander.expand(snippet.content, service) replaceKeywordInNode( node = targetNode, - fullText = text, - targetWord = lastTypedWord, + targetWord = targetWord, replacement = "$expanded ", ) } @@ -443,6 +476,7 @@ class SnippetAccessibilityHandler( fun destroy() { dismissAllSuggestions() + activeNode?.recycle() activeNode = null } } diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt index 948ebdaddd..9b48656099 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt @@ -424,12 +424,12 @@ class ScreenOffAccessibilityService : } "CONSCIOUS_GATE_CONFIRMED" -> { - intent.getStringExtra("package_name")?.let { appFlowHandler.onConsciousGateConfirmed(it) } + intent?.getStringExtra("package_name")?.let { appFlowHandler.onConsciousGateConfirmed(it) } islandOverlayHandler.updateConsciousGateState() } "CONSCIOUS_GATE_CLOSED" -> { - intent.getStringExtra("package_name")?.let { appFlowHandler.onConsciousGateClosed(it) } + intent?.getStringExtra("package_name")?.let { appFlowHandler.onConsciousGateClosed(it) } islandOverlayHandler.updateConsciousGateState() } @@ -705,13 +705,9 @@ class ScreenOffAccessibilityService : private fun isKeyboardPackage(pkg: String): Boolean { if (pkg.isEmpty()) return false - return pkg.contains("inputmethod", ignoreCase = true) || - pkg.contains("keyboard", ignoreCase = true) || - pkg == "com.google.android.inputmethod.latin" || - pkg == "com.sec.android.inputmethod" || - pkg == "com.touchtype.swiftkey" || - pkg == "com.samsung.android.honeyboard" || - pkg == packageName + if (pkg == packageName) return true + val imm = getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager + return imm?.inputMethodList?.any { it.packageName == pkg } == true } override fun onInterrupt() {} diff --git a/app/src/test/java/com/sameerasw/essentials/ime/SnippetExpanderTest.kt b/app/src/test/java/com/sameerasw/essentials/ime/SnippetExpanderTest.kt new file mode 100644 index 0000000000..38348663f5 --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/ime/SnippetExpanderTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Unit Tests - IME + * File: SnippetExpanderTest.kt + * Description: Unit tests for SnippetExpander dynamic placeholder evaluation. + */ + +package com.sameerasw.essentials.ime + +import com.sameerasw.essentials.ime.snippets.SnippetExpander +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + +class SnippetExpanderTest { + @Test + fun expand_staticText_returnsUnchanged() { + val template = "Hello, world!" + val result = SnippetExpander.expand(template, null) + assertEquals("Hello, world!", result) + } + + @Test + fun expand_datePlaceholder_replacesWithFormattedDate() { + val today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.getDefault())) + val template = "Today is {date}." + val result = SnippetExpander.expand(template, null) + assertEquals("Today is $today.", result) + } + + @Test + fun expand_timePlaceholder_replacesWithTime() { + val template = "Current time is {time}." + val result = SnippetExpander.expand(template, null) + assertTrue(result.contains("AM") || result.contains("PM")) + } + + @Test + fun expand_nullContextClipboard_replacesWithEmptyString() { + val template = "Quoted: {clipboard}" + val result = SnippetExpander.expand(template, null) + assertEquals("Quoted: ", result) + } +} From bdc6201b35ef1f5ae69b92b9edc8efd918b7318e Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:07:20 +0530 Subject: [PATCH 19/27] refactor(snippets): redesign engine for Raycast-style shortcuts, safe a11y replacement, and touch isolation --- .../ime/snippets/SnippetRepository.kt | 23 ++++-- .../essentials/island/ui/IslandRoot.kt | 10 +-- .../services/handlers/DuoOverlayHandler.kt | 6 ++ .../services/handlers/DuoTouchHandler.kt | 4 + .../handlers/SnippetAccessibilityHandler.kt | 49 +++++++---- .../tiles/ScreenOffAccessibilityService.kt | 1 - .../essentials/ime/SnippetMatchingTest.kt | 82 +++++++++++++++++++ 7 files changed, 141 insertions(+), 34 deletions(-) create mode 100644 app/src/test/java/com/sameerasw/essentials/ime/SnippetMatchingTest.kt diff --git a/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt index 0e8619e461..e32404a87a 100644 --- a/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt @@ -10,29 +10,29 @@ package com.sameerasw.essentials.ime.snippets import android.content.Context +import android.util.AtomicFile import android.util.Log import com.google.gson.Gson import com.google.gson.reflect.TypeToken +import java.io.File +import java.io.FileOutputStream import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.io.FileOutputStream -import android.util.AtomicFile import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext class SnippetRepository private constructor(context: Context) { private val appContext = context.applicationContext private val scope = CoroutineScope(Dispatchers.IO) private val gson = Gson() private val storageFile = File(appContext.filesDir, "snippets.json") - private val atomicFile = android.util.AtomicFile(storageFile) + private val atomicFile = AtomicFile(storageFile) private val fileMutex = Mutex() private val _snippets = MutableStateFlow>(emptyList()) @@ -52,6 +52,11 @@ class SnippetRepository private constructor(context: Context) { keyword = "!date", content = "{date}", ), + Snippet( + title = "Current Time", + keyword = "!time", + content = "{time}", + ), Snippet( title = "Shrug", keyword = "!shrug", @@ -66,8 +71,10 @@ class SnippetRepository private constructor(context: Context) { val json = storageFile.readText() val type = object : TypeToken>() {}.type val loaded: List? = gson.fromJson(json, type) - if (loaded != null) { + if (!loaded.isNullOrEmpty()) { _snippets.value = loaded + } else if (loaded != null) { + _snippets.value = emptyList() } else { Log.w(TAG, "Snippets file read returned null; preserving memory state") } @@ -115,7 +122,7 @@ class SnippetRepository private constructor(context: Context) { val query = word.trim() if (query.isEmpty()) return emptyList() - val isTriggerPrefix = query.startsWith("!") || query.startsWith("/") || query.startsWith(":") + val isTriggerPrefix = query.startsWith("!") || query.startsWith("/") || query.startsWith(":") || query.startsWith(";") return _snippets.value.filter { snippet -> snippet.keyword.equals(query, ignoreCase = true) || (isTriggerPrefix && snippet.keyword.startsWith(query, ignoreCase = true)) diff --git a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt index 5367ac0f9e..9064024f38 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/ui/IslandRoot.kt @@ -354,16 +354,8 @@ fun IslandRoot( } fun handleLongPress(itemKey: String?) { - val current = currentState - val targetedItem = (if (current.stage == IslandStage.Compact) itemKey?.let { current.items[it] } else current.focused) - ?: current.items.values.firstOrNull { it.interactions.onLongPress != null } - if (targetedItem?.interactions?.onLongPress != null) { - IslandHaptics.longPress(context) - actions.onLongPress(targetedItem.key) - return - } val gestures = actions.compactGestures - if (current.stage == IslandStage.Compact && gestures.hasLongPress) { + if (currentState.stage == IslandStage.Compact && gestures.hasLongPress) { compactLongPressed = true if (gestures.longPressOpensBrief) IslandHaptics.openClick(context) else IslandHaptics.commit(context) gestures.longPress() diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt index 6733a6d6f2..0d0cc9bf91 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt @@ -374,21 +374,27 @@ class DuoOverlayHandler( var openBrief: (() -> Unit)? = null + private val snippetAutoDismissRunnable = Runnable { hideSnippetSuggestion() } + fun showSnippetSuggestion(snippet: com.sameerasw.essentials.ime.snippets.Snippet, onExpand: () -> Unit) { mainHandler.post { + mainHandler.removeCallbacks(snippetAutoDismissRunnable) ensureTouchAnchor(isSnippet = true) duoTouchHandler?.onSnippetTapAction = { + mainHandler.removeCallbacks(snippetAutoDismissRunnable) onExpand() restoreTouchAnchor() } val titleText = snippet.title.ifBlank { snippet.content.take(18) } val displayKeyword = if (snippet.keyword.startsWith("!")) snippet.keyword else "!${snippet.keyword}" overlayView?.setInteractiveSnippet(keyword = displayKeyword, title = titleText) + mainHandler.postDelayed(snippetAutoDismissRunnable, 5000L) } } fun hideSnippetSuggestion() { mainHandler.post { + mainHandler.removeCallbacks(snippetAutoDismissRunnable) duoTouchHandler?.onSnippetTapAction = null if (overlayView?.isSnippetActive == true) { overlayView?.resetInteractiveState(animate = true) diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoTouchHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoTouchHandler.kt index 1b337dcb09..68e4df401b 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoTouchHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoTouchHandler.kt @@ -254,6 +254,10 @@ class DuoTouchHandler( } if (!didPerformAnyGesture && totalDist < touchSlopPx && elapsed < 350L) { + if (overlayView?.isSnippetActive == true) { + onSnippetTapAction = null + overlayView?.resetInteractiveState(animate = true) + } val doubleTapAction = settingsRepository.getDuoDoubleTapAction() val tapAction = if (settingsRepository.isDuoTapForBriefActive() && openBrief != null) null else settingsRepository.getDuoTapAction() diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt index 47aa3114b2..a541c8cccb 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt @@ -15,6 +15,7 @@ import android.graphics.PixelFormat import android.os.Bundle import android.os.Handler import android.os.Looper +import android.provider.Settings import android.util.Log import android.view.Gravity import android.view.MotionEvent @@ -49,6 +50,11 @@ class SnippetAccessibilityHandler( companion object { private const val TAG = "SnippetA11yHandler" private const val AUTO_DISMISS_TIMEOUT_MS = 6000L + private const val REPLACEMENT_DEBOUNCE_MS = 300L + private const val DEFAULT_KEYBOARD_HEIGHT_DP = 290 + private const val FALLBACK_IME_PADDING_DP = 72 + private const val MIN_INPUT_BOUNDS_TOP_DP = 80 + private const val FLOATING_PILL_OFFSET_DP = 12 } private val windowManager = service.getSystemService(Context.WINDOW_SERVICE) as WindowManager @@ -70,6 +76,7 @@ class SnippetAccessibilityHandler( private val dismissRunnable = Runnable { hideFloatingPill() } + @Volatile private var isPerformingReplacement = false fun updateSettings() { @@ -81,6 +88,18 @@ class SnippetAccessibilityHandler( } } + private fun isEssentialsImeActive(): Boolean { + return try { + val currentIme = Settings.Secure.getString( + service.contentResolver, + Settings.Secure.DEFAULT_INPUT_METHOD, + ).orEmpty() + currentIme.startsWith(service.packageName) + } catch (_: Exception) { + false + } + } + fun onTextChanged(event: AccessibilityEvent) { if (!isUniversalEnabled || isPerformingReplacement) return @@ -112,8 +131,8 @@ class SnippetAccessibilityHandler( return } - // 1. Check for Option A: Space-based instant auto-expand - if (fullText.endsWith(" ") && isAutoExpandEnabled) { + // 1. Space-based instant auto-expand (skip if Essentials IME is typing, to prevent double expansion) + if (fullText.endsWith(" ") && isAutoExpandEnabled && !isEssentialsImeActive()) { val textBeforeSpace = fullText.dropLast(1) val candidateWord = textBeforeSpace.takeLastWhile { !it.isWhitespace() } @@ -138,7 +157,7 @@ class SnippetAccessibilityHandler( } } - // 2. Check for Option B: Suggestion trigger (Pill, Island, Duo, or Both) + // 2. Suggestion trigger (Pill, Island, Duo, or Both) val currentWord = fullText.takeLastWhile { !it.isWhitespace() } lastTypedWord = currentWord @@ -165,15 +184,14 @@ class SnippetAccessibilityHandler( if (fullText.isEmpty()) return false val cursor = node.textSelectionEnd + // Verify cursor is at the end of targetWord to prevent replacing arbitrary occurrences val targetIndex = if (cursor in targetWord.length..fullText.length && fullText.substring(cursor - targetWord.length, cursor).equals(targetWord, ignoreCase = true) ) { cursor - targetWord.length } else { - val lastIdx = fullText.lastIndexOf(targetWord, ignoreCase = true) - if (lastIdx < 0) return false - lastIdx + return false } val newText = @@ -188,7 +206,6 @@ class SnippetAccessibilityHandler( val success = node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args) if (!success) return false - // Set cursor position at the end of replacement val newCursorPos = targetIndex + replacement.length val cursorArgs = Bundle().apply { @@ -198,14 +215,14 @@ class SnippetAccessibilityHandler( node.performAction(AccessibilityNodeInfo.ACTION_SET_SELECTION, cursorArgs) return true } finally { - mainHandler.postDelayed({ isPerformingReplacement = false }, 150L) + mainHandler.postDelayed({ isPerformingReplacement = false }, REPLACEMENT_DEBOUNCE_MS) } } private fun calculateImeOffset(): Int { val density = service.resources.displayMetrics.density val displayHeight = service.resources.displayMetrics.heightPixels - var keyboardHeight = (290 * density).toInt() + var keyboardHeight = (DEFAULT_KEYBOARD_HEIGHT_DP * density).toInt() try { val currentWindows = service.windows @@ -226,17 +243,15 @@ class SnippetAccessibilityHandler( Log.e(TAG, "Error calculating IME window bounds", e) } - // Dynamically measure active input field to ensure the pill floats comfortably above it val bounds = lastInputBounds if (bounds != null && bounds.height() > 0 && bounds.top > 0 && bounds.top < displayHeight) { val nodeTopFromBottom = displayHeight - bounds.top - if (nodeTopFromBottom >= keyboardHeight && bounds.top > (80 * density)) { - return nodeTopFromBottom + (12 * density).toInt() + if (nodeTopFromBottom >= keyboardHeight && bounds.top > (MIN_INPUT_BOUNDS_TOP_DP * density)) { + return nodeTopFromBottom + (FLOATING_PILL_OFFSET_DP * density).toInt() } } - // Fallback: place comfortably above standard multi-line input bar - return keyboardHeight + (72 * density).toInt() + return keyboardHeight + (FALLBACK_IME_PADDING_DP * density).toInt() } private fun expandSnippet(snippet: Snippet) { @@ -250,7 +265,7 @@ class SnippetAccessibilityHandler( val cursor = targetNode.textSelectionEnd val textBeforeCursor = if (cursor in 0..fullText.length) fullText.substring(0, cursor) else fullText val candidateWord = textBeforeCursor.takeLastWhile { !it.isWhitespace() } - val targetWord = if (candidateWord.isNotBlank() && (snippet.keyword.startsWith(candidateWord, ignoreCase = true) || candidateWord.equals(lastTypedWord, ignoreCase = true))) { + val targetWord = if (candidateWord.isNotBlank() && snippet.keyword.startsWith(candidateWord, ignoreCase = true)) { candidateWord } else { lastTypedWord.ifBlank { snippet.keyword } @@ -320,6 +335,9 @@ class SnippetAccessibilityHandler( hideFloatingPill() islandCoordinator?.hideSnippet() duoOverlayHandler?.hideSnippetSuggestion() + activeNode?.recycle() + activeNode = null + lastTypedWord = "" } private fun showFloatingPill(snippet: Snippet, onExpand: () -> Unit) { @@ -329,7 +347,6 @@ class SnippetAccessibilityHandler( mainHandler.postDelayed(dismissRunnable, AUTO_DISMISS_TIMEOUT_MS) if (overlayView != null) { - // Overlay already visible; update positioning dynamically if keyboard height changed try { val frame = overlayView ?: return@post val lp = frame.layoutParams as? WindowManager.LayoutParams diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt index f7ad058376..be3147f53b 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt @@ -762,7 +762,6 @@ class ScreenOffAccessibilityService : return imm?.inputMethodList?.any { it.packageName == pkg } == true } - override fun onInterrupt() {} override fun onInterrupt() { if (::volumeStepsHandler.isInitialized) volumeStepsHandler.stop() } diff --git a/app/src/test/java/com/sameerasw/essentials/ime/SnippetMatchingTest.kt b/app/src/test/java/com/sameerasw/essentials/ime/SnippetMatchingTest.kt new file mode 100644 index 0000000000..e4b3a6b11d --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/ime/SnippetMatchingTest.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Unit Tests - IME + * File: SnippetMatchingTest.kt + * Description: Unit tests for Raycast-style snippet prefix matching and case-insensitivity. + */ + +package com.sameerasw.essentials.ime + +import com.sameerasw.essentials.ime.snippets.Snippet +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SnippetMatchingTest { + + private val snippets = listOf( + Snippet(title = "Current Date", keyword = "!date", content = "{date}"), + Snippet(title = "Shrug", keyword = "!shrug", content = "¯\\_(ツ)_/¯"), + Snippet(title = "Email Address", keyword = "/email", content = "user@example.com"), + Snippet(title = "Phone Number", keyword = ":phone", content = "+123456789"), + Snippet(title = "Meeting Link", keyword = ";meet", content = "https://meet.google.com/xyz"), + ) + + private fun findMatching(word: String, list: List): List { + val query = word.trim() + if (query.isEmpty()) return emptyList() + + val isTriggerPrefix = query.startsWith("!") || query.startsWith("/") || query.startsWith(":") || query.startsWith(";") + return list.filter { snippet -> + snippet.keyword.equals(query, ignoreCase = true) || + (isTriggerPrefix && snippet.keyword.startsWith(query, ignoreCase = true)) + } + } + + @Test + fun findMatching_exactKeyword_matchesCaseInsensitive() { + val resultsLower = findMatching("!date", snippets) + assertEquals(1, resultsLower.size) + assertEquals("!date", resultsLower.first().keyword) + + val resultsUpper = findMatching("!DATE", snippets) + assertEquals(1, resultsUpper.size) + assertEquals("!date", resultsUpper.first().keyword) + } + + @Test + fun findMatching_triggerPrefix_matchesCandidates() { + val resultsExclamation = findMatching("!", snippets) + assertEquals(2, resultsExclamation.size) + + val resultsSlash = findMatching("/em", snippets) + assertEquals(1, resultsSlash.size) + assertEquals("/email", resultsSlash.first().keyword) + + val resultsColon = findMatching(":ph", snippets) + assertEquals(1, resultsColon.size) + assertEquals(":phone", resultsColon.first().keyword) + + val resultsSemicolon = findMatching(";me", snippets) + assertEquals(1, resultsSemicolon.size) + assertEquals(";meet", resultsSemicolon.first().keyword) + } + + @Test + fun findMatching_ordinaryText_doesNotTriggerSubstringMatching() { + // Normal typing like "the", "date", "shrug" should not trigger prefix matching unless explicit trigger prefix is typed + val resultsOrdinary = findMatching("sh", snippets) + assertTrue(resultsOrdinary.isEmpty()) + + val resultsTitleSubstring = findMatching("Date", snippets) + assertTrue(resultsTitleSubstring.isEmpty()) + } + + @Test + fun findMatching_emptyQuery_returnsEmpty() { + val results = findMatching(" ", snippets) + assertTrue(results.isEmpty()) + } +} From 431d3bc92cd50313426f9e90755cb4ce2541e505 Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:11:41 +0530 Subject: [PATCH 20/27] fix(snippets): dynamically register typeViewTextChanged only when opt-in is enabled --- .../tiles/ScreenOffAccessibilityService.kt | 18 ++++++++++++++++++ .../res/xml/accessibility_service_config.xml | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt index be3147f53b..8f4ccf3727 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt @@ -306,6 +306,7 @@ class ScreenOffAccessibilityService : } else if (key?.startsWith("snippets_") == true) { if (::snippetAccessibilityHandler.isInitialized) { snippetAccessibilityHandler.updateSettings() + updateSnippetEventRegistration() } } else if (key == SettingsRepository.KEY_LOCKSCREEN_SHORTCUTS_ENABLED || key == SettingsRepository.KEY_LOCKSCREEN_SHORTCUT_LEFT_ACTIONS || @@ -532,6 +533,7 @@ class ScreenOffAccessibilityService : duoOverlayHandler.restart() statusGlanceHandler.restart() islandOverlayHandler.restart() + updateSnippetEventRegistration() } private fun updateOmniOverlay() { @@ -762,6 +764,22 @@ class ScreenOffAccessibilityService : return imm?.inputMethodList?.any { it.packageName == pkg } == true } + private fun updateSnippetEventRegistration() { + val info = serviceInfo ?: return + val isSnippetsEnabled = SettingsRepository(this).isSnippetsUniversalEnabled() + val currentEvents = info.eventTypes + val desiredEvents = + if (isSnippetsEnabled) { + currentEvents or AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED + } else { + currentEvents and AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED.inv() + } + if (currentEvents != desiredEvents) { + info.eventTypes = desiredEvents + serviceInfo = info + } + } + override fun onInterrupt() { if (::volumeStepsHandler.isInitialized) volumeStepsHandler.stop() } diff --git a/app/src/main/res/xml/accessibility_service_config.xml b/app/src/main/res/xml/accessibility_service_config.xml index 8c7706919c..514fb2ab8c 100644 --- a/app/src/main/res/xml/accessibility_service_config.xml +++ b/app/src/main/res/xml/accessibility_service_config.xml @@ -1,6 +1,6 @@ Date: Sun, 18 Oct 2026 11:16:50 +0530 Subject: [PATCH 21/27] fix(snippets): harden atomic recovery, clipboard isolation, and cursor fallback --- .../ime/snippets/SnippetRepository.kt | 2 +- .../plugins/snippets/SnippetIslandPlugin.kt | 5 ++- .../handlers/SnippetAccessibilityHandler.kt | 2 ++ .../essentials/ime/SnippetMatchingTest.kt | 34 +++++++++++++++++++ 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt index e32404a87a..49e6d181cb 100644 --- a/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt @@ -68,7 +68,7 @@ class SnippetRepository private constructor(context: Context) { return@launch } - val json = storageFile.readText() + val json = atomicFile.openRead().bufferedReader(Charsets.UTF_8).use { it.readText() } val type = object : TypeToken>() {}.type val loaded: List? = gson.fromJson(json, type) if (!loaded.isNullOrEmpty()) { diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetIslandPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetIslandPlugin.kt index 24d4998773..71a87d247d 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetIslandPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/snippets/SnippetIslandPlugin.kt @@ -114,12 +114,12 @@ class SnippetIslandPlugin : BaseIslandPlugin() { snippet = snippet, scope = scope, onInsert = { - copyToClipboard(snippet.content) onExpandAction?.invoke() hideSnippet() }, onCopy = { - copyToClipboard(snippet.content) + val expanded = com.sameerasw.essentials.ime.snippets.SnippetExpander.expand(snippet.content, context) + copyToClipboard(expanded) Toast.makeText(context, R.string.snippets_action_copied, Toast.LENGTH_SHORT).show() hideSnippet() }, @@ -130,7 +130,6 @@ class SnippetIslandPlugin : BaseIslandPlugin() { }, interactions = InteractionOverrides( onTap = { - copyToClipboard(snippet.content) onExpandAction?.invoke() hideSnippet() true diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt index a541c8cccb..a1de9d0e66 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt @@ -190,6 +190,8 @@ class SnippetAccessibilityHandler( fullText.substring(cursor - targetWord.length, cursor).equals(targetWord, ignoreCase = true) ) { cursor - targetWord.length + } else if (fullText.endsWith(targetWord, ignoreCase = true)) { + fullText.length - targetWord.length } else { return false } diff --git a/app/src/test/java/com/sameerasw/essentials/ime/SnippetMatchingTest.kt b/app/src/test/java/com/sameerasw/essentials/ime/SnippetMatchingTest.kt index e4b3a6b11d..a721285d36 100644 --- a/app/src/test/java/com/sameerasw/essentials/ime/SnippetMatchingTest.kt +++ b/app/src/test/java/com/sameerasw/essentials/ime/SnippetMatchingTest.kt @@ -79,4 +79,38 @@ class SnippetMatchingTest { val results = findMatching(" ", snippets) assertTrue(results.isEmpty()) } + + private fun calculateReplacementIndex(fullText: String, cursor: Int, targetWord: String): Int { + return if (cursor in targetWord.length..fullText.length && + fullText.substring(cursor - targetWord.length, cursor).equals(targetWord, ignoreCase = true) + ) { + cursor - targetWord.length + } else if (fullText.endsWith(targetWord, ignoreCase = true)) { + fullText.length - targetWord.length + } else { + -1 + } + } + + @Test + fun replaceKeyword_cursorAtEnd_calculatesCorrectIndex() { + val text = "Meeting today at !time " + val index = calculateReplacementIndex(text, text.length, "!time ") + assertEquals(text.indexOf("!time "), index) + } + + @Test + fun replaceKeyword_cursorInMiddle_doesNotReplaceEarlierOccurrence() { + val text = "!date and some text and !date" + val secondIndex = text.lastIndexOf("!date") + val index = calculateReplacementIndex(text, text.length, "!date") + assertEquals(secondIndex, index) + } + + @Test + fun replaceKeyword_cursorMismatched_returnsNegativeOne() { + val text = "Here is !date and typing here" + val index = calculateReplacementIndex(text, text.length, "!date") + assertEquals(-1, index) + } } From 428b6280f4f2630eb388cba62ccb160ade7deab7 Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:21:10 +0530 Subject: [PATCH 22/27] feat(snippets): add silent mode option and refine trigger matching --- .../essentials/data/repository/SettingsRepository.kt | 1 + .../services/handlers/SnippetAccessibilityHandler.kt | 5 +++++ .../ui/features/apps/sheets/SnippetsBottomSheet.kt | 10 ++++++++++ .../essentials/ui/features/display/DuoSettingsUI.kt | 1 + .../essentials/ui/features/display/IslandSettingsUI.kt | 1 + .../ui/features/system/KeyboardSettingsUI.kt | 2 ++ app/src/main/res/values/strings.xml | 2 ++ 7 files changed, 22 insertions(+) diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 56fb3d2153..ca37c4b77b 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -821,6 +821,7 @@ class SettingsRepository( 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" diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt index a1de9d0e66..d3685df287 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt @@ -288,6 +288,11 @@ class SnippetAccessibilityHandler( val onExpand = { expandSnippet(snippet) } when (mode) { + SettingsRepository.SNIPPETS_DISPLAY_NONE -> { + hideFloatingPill() + islandCoordinator?.hideSnippet() + duoOverlayHandler?.hideSnippetSuggestion() + } SettingsRepository.SNIPPETS_DISPLAY_FLOATING_PILL -> { islandCoordinator?.hideSnippet() duoOverlayHandler?.hideSnippetSuggestion() diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt index 6ecc5db8d0..03fb4928f0 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt @@ -243,12 +243,14 @@ fun SnippetsBottomSheet( ) val modeLabel = when (suggestionDisplayMode) { + SettingsRepository.SNIPPETS_DISPLAY_NONE -> stringResource(R.string.snippets_display_none_title) SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> stringResource(R.string.snippets_display_island_title) SettingsRepository.SNIPPETS_DISPLAY_DUO -> stringResource(R.string.snippets_display_duo_title) SettingsRepository.SNIPPETS_DISPLAY_BOTH -> stringResource(R.string.snippets_display_both_title) else -> stringResource(R.string.snippets_display_pill_title) } val modeIcon = when (suggestionDisplayMode) { + SettingsRepository.SNIPPETS_DISPLAY_NONE -> R.drawable.rounded_visibility_off_24 SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> R.drawable.rounded_bolt_24 SettingsRepository.SNIPPETS_DISPLAY_DUO -> R.drawable.rounded_circle_24 SettingsRepository.SNIPPETS_DISPLAY_BOTH -> R.drawable.rounded_auto_awesome_24 @@ -614,6 +616,14 @@ fun SnippetDisplayStyleSheet( RoundedCardContainer(spacing = 2.dp) { val options = listOf( + Triple( + SettingsRepository.SNIPPETS_DISPLAY_NONE, + Pair( + stringResource(R.string.snippets_display_none_title), + stringResource(R.string.snippets_display_none_desc), + ), + R.drawable.rounded_visibility_off_24, + ), Triple( SettingsRepository.SNIPPETS_DISPLAY_FLOATING_PILL, Pair( diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt index a2ff975cc1..7817dd9dd4 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt @@ -491,6 +491,7 @@ fun DuoSettingsUI( iconRes = R.drawable.rounded_text_snippet_24, title = stringResource(R.string.label_keyboard_snippets), description = when (snippetDisplayMode) { + SettingsRepository.SNIPPETS_DISPLAY_NONE -> stringResource(R.string.snippets_display_none_title) SettingsRepository.SNIPPETS_DISPLAY_DUO -> stringResource(R.string.snippets_display_duo_title) SettingsRepository.SNIPPETS_DISPLAY_BOTH -> stringResource(R.string.snippets_display_both_title) SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> stringResource(R.string.snippets_display_island_title) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt index b81c2a18ae..744c255ac2 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt @@ -249,6 +249,7 @@ fun IslandSettingsUI( iconRes = R.drawable.rounded_text_snippet_24, title = stringResource(R.string.label_keyboard_snippets), description = when (snippetDisplayMode) { + SettingsRepository.SNIPPETS_DISPLAY_NONE -> stringResource(R.string.snippets_display_none_title) SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> stringResource(R.string.snippets_display_island_title) SettingsRepository.SNIPPETS_DISPLAY_BOTH -> stringResource(R.string.snippets_display_both_title) SettingsRepository.SNIPPETS_DISPLAY_DUO -> stringResource(R.string.snippets_display_duo_title) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt index b03910ccd8..82672638e5 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt @@ -372,12 +372,14 @@ fun KeyboardSettingsUI( if (universalOn) { val modeLabel = when (suggestionDisplayMode) { + com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_NONE -> stringResource(R.string.snippets_display_none_title) com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> stringResource(R.string.snippets_display_island_title) com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_DUO -> stringResource(R.string.snippets_display_duo_title) com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_BOTH -> stringResource(R.string.snippets_display_both_title) else -> stringResource(R.string.snippets_display_pill_title) } val modeIcon = when (suggestionDisplayMode) { + com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_NONE -> R.drawable.rounded_visibility_off_24 com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> R.drawable.rounded_bolt_24 com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_DUO -> R.drawable.rounded_circle_24 com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_BOTH -> R.drawable.rounded_auto_awesome_24 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e6e923ba75..d36fb1c3a9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3592,6 +3592,8 @@ Show floating pill above keyboard when trigger prefix is typed Suggestion display style Choose where snippet suggestions appear when typing + None (Silent) + Expand on space only without showing suggestion overlays Floating Pill Floating chip positioned directly above keyboard Dynamic Island From 4c2030caadeb9ed7e79108d86b98c0105a9f5439 Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:25:36 +0530 Subject: [PATCH 23/27] perf(snippets): cache IME lookup, harden password detection, and add pill haptics --- .../handlers/SnippetAccessibilityHandler.kt | 23 +++++++++++++++++-- .../ime/snippets/SnippetFloatingPillView.kt | 11 +++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt index d3685df287..7ffda8ad27 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt @@ -88,8 +88,16 @@ class SnippetAccessibilityHandler( } } + private var lastImeCheckTime = 0L + private var cachedIsEssentialsIme = false + private fun isEssentialsImeActive(): Boolean { - return try { + val now = System.currentTimeMillis() + if (now - lastImeCheckTime < 2000L) { + return cachedIsEssentialsIme + } + lastImeCheckTime = now + cachedIsEssentialsIme = try { val currentIme = Settings.Secure.getString( service.contentResolver, Settings.Secure.DEFAULT_INPUT_METHOD, @@ -98,6 +106,7 @@ class SnippetAccessibilityHandler( } catch (_: Exception) { false } + return cachedIsEssentialsIme } fun onTextChanged(event: AccessibilityEvent) { @@ -107,7 +116,7 @@ class SnippetAccessibilityHandler( if (eventPkg == service.packageName) return val sourceNode = event.source ?: return - if (event.isPassword || sourceNode.isPassword) return + if (event.isPassword || sourceNode.isPassword || isPasswordField(sourceNode)) return if (!sourceNode.isEditable) return activeNode?.recycle() @@ -221,6 +230,16 @@ class SnippetAccessibilityHandler( } } + private fun isPasswordField(node: AccessibilityNodeInfo): Boolean { + val inputType = node.inputType + val variation = inputType and android.text.InputType.TYPE_MASK_VARIATION + return variation == android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD || + variation == android.text.InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD || + variation == android.text.InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD || + (inputType and android.text.InputType.TYPE_MASK_CLASS == android.text.InputType.TYPE_CLASS_NUMBER && + variation == android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD) + } + private fun calculateImeOffset(): Int { val density = service.resources.displayMetrics.density val displayHeight = service.resources.displayMetrics.heightPixels diff --git a/app/src/main/java/com/sameerasw/essentials/ui/ime/snippets/SnippetFloatingPillView.kt b/app/src/main/java/com/sameerasw/essentials/ui/ime/snippets/SnippetFloatingPillView.kt index cbd99a47d8..9fec19807d 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/ime/snippets/SnippetFloatingPillView.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/ime/snippets/SnippetFloatingPillView.kt @@ -49,6 +49,7 @@ fun SnippetFloatingPillView( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { + val view = androidx.compose.ui.platform.LocalView.current AnimatedVisibility( visible = true, enter = fadeIn() + scaleIn(initialScale = 0.88f), @@ -59,7 +60,10 @@ fun SnippetFloatingPillView( modifier .shadow(elevation = 10.dp, shape = CircleShape) .clip(CircleShape) - .clickable { onExpand() }, + .clickable { + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + onExpand() + }, color = MaterialTheme.colorScheme.surfaceBright, contentColor = MaterialTheme.colorScheme.onSurface, tonalElevation = 6.dp, @@ -121,7 +125,10 @@ fun SnippetFloatingPillView( .size(24.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceContainerHighest) - .clickable { onDismiss() }, + .clickable { + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + onDismiss() + }, contentAlignment = Alignment.Center, ) { Icon( From a5bebbb5287e8216e2ff16cb6ff10a29e774ff86 Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:00:14 +0530 Subject: [PATCH 24/27] fix(snippets): resolve search box accessibility expansion and IPC node caching --- .../ime/snippets/SnippetRepository.kt | 41 ++++++++++--------- .../handlers/SnippetAccessibilityHandler.kt | 28 ++++++++++--- .../res/xml/accessibility_service_config.xml | 2 +- .../essentials/ime/SnippetMatchingTest.kt | 10 +++++ 4 files changed, 54 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt index 49e6d181cb..6a2f10b286 100644 --- a/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/ime/snippets/SnippetRepository.kt @@ -35,7 +35,7 @@ class SnippetRepository private constructor(context: Context) { private val atomicFile = AtomicFile(storageFile) private val fileMutex = Mutex() - private val _snippets = MutableStateFlow>(emptyList()) + private val _snippets = MutableStateFlow>(DEFAULT_SNIPPETS) val snippets: StateFlow> = _snippets.asStateFlow() init { @@ -46,25 +46,8 @@ class SnippetRepository private constructor(context: Context) { scope.launch { try { if (!storageFile.exists()) { - val defaults = listOf( - Snippet( - title = "Current Date", - keyword = "!date", - content = "{date}", - ), - Snippet( - title = "Current Time", - keyword = "!time", - content = "{time}", - ), - Snippet( - title = "Shrug", - keyword = "!shrug", - content = "¯\\_(ツ)_/¯", - ), - ) - saveToFile(defaults) - _snippets.value = defaults + saveToFile(DEFAULT_SNIPPETS) + _snippets.value = DEFAULT_SNIPPETS return@launch } @@ -132,6 +115,24 @@ class SnippetRepository private constructor(context: Context) { companion object { private const val TAG = "SnippetRepository" + val DEFAULT_SNIPPETS = listOf( + Snippet( + title = "Current Date", + keyword = "!date", + content = "{date}", + ), + Snippet( + title = "Current Time", + keyword = "!time", + content = "{time}", + ), + Snippet( + title = "Shrug", + keyword = "!shrug", + content = "¯\\_(ツ)_/¯", + ), + ) + @Volatile private var INSTANCE: SnippetRepository? = null diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt index 7ffda8ad27..806b2182d0 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt @@ -70,6 +70,7 @@ class SnippetAccessibilityHandler( private var activeNode: AccessibilityNodeInfo? = null private var lastInputBounds: android.graphics.Rect? = null private var lastTypedWord: String = "" + private var lastKnownText: String = "" private var overlayView: FrameLayout? = null private var lifecycleOwner: OverlayLifecycleOwner? = null @@ -117,7 +118,11 @@ class SnippetAccessibilityHandler( val sourceNode = event.source ?: return if (event.isPassword || sourceNode.isPassword || isPasswordField(sourceNode)) return - if (!sourceNode.isEditable) return + + val canEdit = sourceNode.isEditable || + sourceNode.actionList.any { it.id == AccessibilityNodeInfo.ACTION_SET_TEXT } || + sourceNode.className?.contains("EditText", ignoreCase = true) == true + if (!canEdit) return activeNode?.recycle() activeNode = AccessibilityNodeInfo.obtain(sourceNode) @@ -135,6 +140,8 @@ class SnippetAccessibilityHandler( sourceNode.text?.toString().orEmpty() } + lastKnownText = fullText + if (fullText.isEmpty()) { dismissAllSuggestions() return @@ -157,6 +164,7 @@ class SnippetAccessibilityHandler( node = sourceNode, targetWord = "$candidateWord ", replacement = "$expanded ", + currentText = fullText, ) if (success) { dismissAllSuggestions() @@ -186,11 +194,14 @@ class SnippetAccessibilityHandler( node: AccessibilityNodeInfo, targetWord: String, replacement: String, + currentText: String = "", ): Boolean { isPerformingReplacement = true try { - val fullText = node.text?.toString().orEmpty() - if (fullText.isEmpty()) return false + val fullText = node.text?.toString().takeIf { !it.isNullOrEmpty() } + ?: currentText.takeIf { it.isNotEmpty() } + ?: lastKnownText.takeIf { it.isNotEmpty() } + ?: return false val cursor = node.textSelectionEnd // Verify cursor is at the end of targetWord to prevent replacing arbitrary occurrences @@ -201,6 +212,9 @@ class SnippetAccessibilityHandler( cursor - targetWord.length } else if (fullText.endsWith(targetWord, ignoreCase = true)) { fullText.length - targetWord.length + } else if (fullText.trimEnd().endsWith(targetWord.trimEnd(), ignoreCase = true)) { + val trimmed = fullText.trimEnd() + trimmed.length - targetWord.trimEnd().length } else { return false } @@ -277,12 +291,12 @@ class SnippetAccessibilityHandler( private fun expandSnippet(snippet: Snippet) { overlayView?.let { HapticUtil.performUIHaptic(it) } - val targetNode = activeNode?.takeIf { - try { it.refresh() } catch (_: Exception) { false } + val targetNode = activeNode?.also { + try { it.refresh() } catch (_: Exception) {} } ?: service.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) if (targetNode != null) { - val fullText = targetNode.text?.toString().orEmpty() + val fullText = targetNode.text?.toString().takeIf { !it.isNullOrEmpty() } ?: lastKnownText val cursor = targetNode.textSelectionEnd val textBeforeCursor = if (cursor in 0..fullText.length) fullText.substring(0, cursor) else fullText val candidateWord = textBeforeCursor.takeLastWhile { !it.isWhitespace() } @@ -297,6 +311,7 @@ class SnippetAccessibilityHandler( node = targetNode, targetWord = targetWord, replacement = "$expanded ", + currentText = fullText, ) } dismissAllSuggestions() @@ -364,6 +379,7 @@ class SnippetAccessibilityHandler( activeNode?.recycle() activeNode = null lastTypedWord = "" + lastKnownText = "" } private fun showFloatingPill(snippet: Snippet, onExpand: () -> Unit) { diff --git a/app/src/main/res/xml/accessibility_service_config.xml b/app/src/main/res/xml/accessibility_service_config.xml index 514fb2ab8c..8c7706919c 100644 --- a/app/src/main/res/xml/accessibility_service_config.xml +++ b/app/src/main/res/xml/accessibility_service_config.xml @@ -1,6 +1,6 @@ Date: Sun, 18 Oct 2026 12:10:54 +0530 Subject: [PATCH 25/27] fix(snippets): harden widget detection, reactive pill expansion, and boundary replacement --- .../handlers/SnippetAccessibilityHandler.kt | 25 +++++++++--- .../essentials/ime/SnippetMatchingTest.kt | 40 +++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt index 806b2182d0..349afabc82 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetAccessibilityHandler.kt @@ -119,9 +119,15 @@ class SnippetAccessibilityHandler( val sourceNode = event.source ?: return if (event.isPassword || sourceNode.isPassword || isPasswordField(sourceNode)) return - val canEdit = sourceNode.isEditable || - sourceNode.actionList.any { it.id == AccessibilityNodeInfo.ACTION_SET_TEXT } || - sourceNode.className?.contains("EditText", ignoreCase = true) == true + val hasSetTextAction = (sourceNode.actions and AccessibilityNodeInfo.ACTION_SET_TEXT) != 0 || + sourceNode.actionList.any { it.id == AccessibilityNodeInfo.ACTION_SET_TEXT } + val isTextClass = sourceNode.className?.let { name -> + name.contains("EditText", ignoreCase = true) || + name.contains("AutoComplete", ignoreCase = true) || + name.contains("Search", ignoreCase = true) + } == true + + val canEdit = sourceNode.isEditable || hasSetTextAction || isTextClass if (!canEdit) return activeNode?.recycle() @@ -219,10 +225,17 @@ class SnippetAccessibilityHandler( return false } + val actualWordEnd = + if (fullText.trimEnd().endsWith(targetWord.trimEnd(), ignoreCase = true)) { + fullText.length + } else { + (targetIndex + targetWord.length).coerceAtMost(fullText.length) + } + val newText = fullText.substring(0, targetIndex) + replacement + - fullText.substring(targetIndex + targetWord.length) + fullText.substring(actualWordEnd) val args = Bundle().apply { @@ -231,7 +244,7 @@ class SnippetAccessibilityHandler( val success = node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args) if (!success) return false - val newCursorPos = targetIndex + replacement.length + val newCursorPos = (targetIndex + replacement.length).coerceIn(0, newText.length) val cursorArgs = Bundle().apply { putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, newCursorPos) @@ -416,7 +429,7 @@ class SnippetAccessibilityHandler( if (currentSnippet != null) { SnippetFloatingPillView( snippet = currentSnippet, - onExpand = onExpand, + onExpand = { expandSnippet(currentSnippet) }, onDismiss = { dismissAllSuggestions() }, ) } diff --git a/app/src/test/java/com/sameerasw/essentials/ime/SnippetMatchingTest.kt b/app/src/test/java/com/sameerasw/essentials/ime/SnippetMatchingTest.kt index ae82d97477..033b1707fe 100644 --- a/app/src/test/java/com/sameerasw/essentials/ime/SnippetMatchingTest.kt +++ b/app/src/test/java/com/sameerasw/essentials/ime/SnippetMatchingTest.kt @@ -123,4 +123,44 @@ class SnippetMatchingTest { val index = calculateReplacementIndex(text, text.length, "!date") assertEquals(-1, index) } + + private fun buildReplacementText(fullText: String, cursor: Int, targetWord: String, replacement: String): String? { + val targetIndex = + if (cursor in targetWord.length..fullText.length && + fullText.substring(cursor - targetWord.length, cursor).equals(targetWord, ignoreCase = true) + ) { + cursor - targetWord.length + } else if (fullText.endsWith(targetWord, ignoreCase = true)) { + fullText.length - targetWord.length + } else if (fullText.trimEnd().endsWith(targetWord.trimEnd(), ignoreCase = true)) { + val trimmed = fullText.trimEnd() + trimmed.length - targetWord.trimEnd().length + } else { + return null + } + + val actualWordEnd = + if (fullText.trimEnd().endsWith(targetWord.trimEnd(), ignoreCase = true)) { + fullText.length + } else { + (targetIndex + targetWord.length).coerceAtMost(fullText.length) + } + + return fullText.substring(0, targetIndex) + replacement + fullText.substring(actualWordEnd) + } + + @Test + fun buildReplacementText_replacesCleanlyWithoutDoubleTrailingSpaces() { + val text = "Search !date " + val replaced = buildReplacementText(text, -1, "!date", "2026-10-18 ") + assertEquals("Search 2026-10-18 ", replaced) + } + + @Test + fun buildReplacementText_middleCursor_preservesSurroundingText() { + val text = "Before !date after" + val cursor = text.indexOf("!date") + "!date".length + val replaced = buildReplacementText(text, cursor, "!date", "2026-10-18 ") + assertEquals("Before 2026-10-18 after", replaced) + } } From 013aadcc94f38207cb61c97a9b67afd817782c0a Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:59:39 +0530 Subject: [PATCH 26/27] feat(snippets): gate external keyboard behind accessibility permission and deduplicate settings --- .../apps/sheets/SnippetsBottomSheet.kt | 72 ---------------- .../ui/features/system/KeyboardSettingsUI.kt | 82 ++++++++++++++++--- 2 files changed, 72 insertions(+), 82 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt index 03fb4928f0..6b9e2a8ec2 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/apps/sheets/SnippetsBottomSheet.kt @@ -73,15 +73,8 @@ fun SnippetsBottomSheet( val context = LocalContext.current val view = LocalView.current val repository = remember { SnippetRepository.getInstance(context) } - val settingsRepository = remember { SettingsRepository(context) } val snippets by repository.snippets.collectAsState() - var universalEnabled by remember { mutableStateOf(settingsRepository.isSnippetsUniversalEnabled()) } - var autoExpandEnabled by remember { mutableStateOf(settingsRepository.isSnippetsUniversalAutoExpandEnabled()) } - var floatingPillEnabled by remember { mutableStateOf(settingsRepository.isSnippetsUniversalFloatingPillEnabled()) } - var suggestionDisplayMode by remember { mutableStateOf(settingsRepository.getSnippetsSuggestionDisplayMode()) } - var isDisplayStyleSheetVisible by remember { mutableStateOf(false) } - var searchQuery by remember { mutableStateOf("") } var editingSnippet by remember { mutableStateOf(null) } var isCreatingNew by remember { mutableStateOf(false) } @@ -148,17 +141,6 @@ fun SnippetsBottomSheet( ) } - if (isDisplayStyleSheetVisible) { - SnippetDisplayStyleSheet( - currentMode = suggestionDisplayMode, - onModeSelected = { newMode -> - suggestionDisplayMode = newMode - settingsRepository.setSnippetsSuggestionDisplayMode(newMode) - }, - onDismissRequest = { isDisplayStyleSheetVisible = false }, - ) - } - EssentialsBottomSheet( onDismissRequest = onDismissRequest, ) { @@ -216,60 +198,6 @@ fun SnippetsBottomSheet( } } - // Settings: External Keyboard Support Group - item(key = "external_keyboard_settings") { - RoundedCardContainer(spacing = 2.dp) { - IconToggleItem( - iconRes = R.drawable.rounded_keyboard_24, - title = stringResource(R.string.snippets_universal_title), - description = stringResource(R.string.snippets_universal_desc), - isChecked = universalEnabled, - onCheckedChange = { checked -> - universalEnabled = checked - settingsRepository.setSnippetsUniversalEnabled(checked) - }, - ) - - if (universalEnabled) { - IconToggleItem( - iconRes = R.drawable.rounded_bolt_24, - title = stringResource(R.string.snippets_universal_auto_expand), - description = stringResource(R.string.snippets_universal_auto_expand_desc), - isChecked = autoExpandEnabled, - onCheckedChange = { checked -> - autoExpandEnabled = checked - settingsRepository.setSnippetsUniversalAutoExpandEnabled(checked) - }, - ) - - val modeLabel = when (suggestionDisplayMode) { - SettingsRepository.SNIPPETS_DISPLAY_NONE -> stringResource(R.string.snippets_display_none_title) - SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> stringResource(R.string.snippets_display_island_title) - SettingsRepository.SNIPPETS_DISPLAY_DUO -> stringResource(R.string.snippets_display_duo_title) - SettingsRepository.SNIPPETS_DISPLAY_BOTH -> stringResource(R.string.snippets_display_both_title) - else -> stringResource(R.string.snippets_display_pill_title) - } - val modeIcon = when (suggestionDisplayMode) { - SettingsRepository.SNIPPETS_DISPLAY_NONE -> R.drawable.rounded_visibility_off_24 - SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> R.drawable.rounded_bolt_24 - SettingsRepository.SNIPPETS_DISPLAY_DUO -> R.drawable.rounded_circle_24 - SettingsRepository.SNIPPETS_DISPLAY_BOTH -> R.drawable.rounded_auto_awesome_24 - else -> R.drawable.rounded_keyboard_24 - } - - IconToggleItem( - iconRes = modeIcon, - title = stringResource(R.string.snippets_display_style_title), - description = modeLabel, - isChecked = false, - showToggle = false, - onClick = { isDisplayStyleSheetVisible = true }, - onCheckedChange = { isDisplayStyleSheetVisible = true }, - ) - } - } - } - // Search Bar item(key = "search_bar") { OutlinedTextField( diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt index 82672638e5..143bfc7b9f 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt @@ -59,10 +59,18 @@ fun KeyboardSettingsUI( var text by remember { mutableStateOf("") } val isKeyboardEnabled by viewModel.isKeyboardEnabled val isKeyboardSelected by viewModel.isKeyboardSelected + val isAccessibilityEnabled by viewModel.isAccessibilityEnabled var isSnippetsSheetVisible by remember { mutableStateOf(false) } val settingsRepo = remember { com.sameerasw.essentials.data.repository.SettingsRepository(context) } var suggestionDisplayMode by remember { mutableStateOf(settingsRepo.getSnippetsSuggestionDisplayMode()) } var isDisplayStyleSheetVisible by remember { mutableStateOf(false) } + var requestingPermissionsFor by remember { mutableStateOf>?>(null) } + var universalOn by remember { mutableStateOf(settingsRepo.isSnippetsUniversalEnabled()) } + var autoExpandEnabled by remember { mutableStateOf(settingsRepo.isSnippetsUniversalAutoExpandEnabled()) } + + LaunchedEffect(isAccessibilityEnabled) { + universalOn = settingsRepo.isSnippetsUniversalEnabled() + } LaunchedEffect(Unit) { while (true) { @@ -356,21 +364,42 @@ fun KeyboardSettingsUI( ) RoundedCardContainer(spacing = 2.dp) { - var universalOn by remember { mutableStateOf(settingsRepo.isSnippetsUniversalEnabled()) } - IconToggleItem( iconRes = R.drawable.rounded_keyboard_24, title = stringResource(R.string.snippets_universal_title), description = stringResource(R.string.snippets_universal_desc), - isChecked = universalOn, + isChecked = universalOn && isAccessibilityEnabled, onCheckedChange = { checked -> - universalOn = checked - settingsRepo.setSnippetsUniversalEnabled(checked) + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + if (checked) { + if (!isAccessibilityEnabled) { + requestingPermissionsFor = Pair(R.string.snippets_universal_title, listOf("ACCESSIBILITY")) + } else { + universalOn = true + settingsRepo.setSnippetsUniversalEnabled(true) + } + } else { + universalOn = false + settingsRepo.setSnippetsUniversalEnabled(false) + } }, modifier = Modifier.highlight(highlightSetting == "snippets_universal"), ) - if (universalOn) { + if (universalOn && isAccessibilityEnabled) { + IconToggleItem( + iconRes = R.drawable.rounded_bolt_24, + title = stringResource(R.string.snippets_universal_auto_expand), + description = stringResource(R.string.snippets_universal_auto_expand_desc), + isChecked = autoExpandEnabled, + onCheckedChange = { checked -> + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + autoExpandEnabled = checked + settingsRepo.setSnippetsUniversalAutoExpandEnabled(checked) + }, + modifier = Modifier.highlight(highlightSetting == "snippets_universal_auto_expand"), + ) + val modeLabel = when (suggestionDisplayMode) { com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_NONE -> stringResource(R.string.snippets_display_none_title) com.sameerasw.essentials.data.repository.SettingsRepository.SNIPPETS_DISPLAY_DYNAMIC_ISLAND -> stringResource(R.string.snippets_display_island_title) @@ -392,8 +421,14 @@ fun KeyboardSettingsUI( description = modeLabel, isChecked = false, showToggle = false, - onClick = { isDisplayStyleSheetVisible = true }, - onCheckedChange = { isDisplayStyleSheetVisible = true }, + onClick = { + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + isDisplayStyleSheetVisible = true + }, + onCheckedChange = { + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + isDisplayStyleSheetVisible = true + }, modifier = Modifier.highlight(highlightSetting == "snippets_display_style"), ) } @@ -404,8 +439,14 @@ fun KeyboardSettingsUI( description = stringResource(R.string.desc_keyboard_snippets), isChecked = false, showToggle = false, - onClick = { isSnippetsSheetVisible = true }, - onCheckedChange = { isSnippetsSheetVisible = true }, + onClick = { + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + isSnippetsSheetVisible = true + }, + onCheckedChange = { + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + isSnippetsSheetVisible = true + }, modifier = Modifier.highlight(highlightSetting == "keyboard_snippets"), ) } @@ -438,5 +479,26 @@ fun KeyboardSettingsUI( onDismissRequest = { isDisplayStyleSheetVisible = false }, ) } + + if (requestingPermissionsFor != null) { + val (titleRes, permKeys) = requestingPermissionsFor!! + val permissionItems = com.sameerasw.essentials.utils.PermissionUIHelper.getPermissionItems( + permKeys, + context, + viewModel, + ) + com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet( + onDismissRequest = { + requestingPermissionsFor = null + viewModel.check(context) + if (viewModel.isAccessibilityEnabled.value) { + universalOn = true + settingsRepo.setSnippetsUniversalEnabled(true) + } + }, + featureTitle = stringResource(titleRes), + permissions = permissionItems, + ) + } } } From 678b88443bcc38624fabe58524e8eb84dbc1aae3 Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:04:52 +0530 Subject: [PATCH 27/27] fix(snippets): remove static typeViewTextChanged from xml in favor of runtime opt-in --- app/src/main/res/xml/accessibility_service_config.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/xml/accessibility_service_config.xml b/app/src/main/res/xml/accessibility_service_config.xml index 8c7706919c..514fb2ab8c 100644 --- a/app/src/main/res/xml/accessibility_service_config.xml +++ b/app/src/main/res/xml/accessibility_service_config.xml @@ -1,6 +1,6 @@