From a8647832c08b3a76716f997cea5ebc8896b007a3 Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:41:45 +0530 Subject: [PATCH 1/7] feat(data): add snippet data model and SharedPreferences persistence --- .../data/repository/SettingsRepository.kt | 31 +++++++++++++++++++ .../essentials/domain/model/Snippet.kt | 17 ++++++++++ 2 files changed, 48 insertions(+) create mode 100644 app/src/main/java/com/sameerasw/essentials/domain/model/Snippet.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 b14fa5f60..2430e7b67 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 @@ -36,6 +36,7 @@ import com.sameerasw.essentials.domain.model.NotificationLightingSweepPosition import com.sameerasw.essentials.domain.model.PowerChoice import com.sameerasw.essentials.domain.model.RemapSlot import com.sameerasw.essentials.domain.model.ScaleAnimationsProfile +import com.sameerasw.essentials.domain.model.Snippet import com.sameerasw.essentials.domain.model.SystemShortcutsState import com.sameerasw.essentials.domain.model.TrackedRepo import com.sameerasw.essentials.domain.model.github.GitHubUser @@ -403,6 +404,9 @@ class SettingsRepository( const val KEY_KEYBOARD_CLIPBOARD_ENABLED = "keyboard_clipboard_enabled" const val KEY_KEYBOARD_LONG_PRESS_SYMBOLS = "keyboard_long_press_symbols" const val KEY_KEYBOARD_ACCENTED_CHARACTERS = "keyboard_accented_characters" + const val KEY_KEYBOARD_TEXT_SNIPPETS_ENABLED = "keyboard_text_snippets_enabled" + const val KEY_KEYBOARD_SNIPPET_PREFIX = "keyboard_snippet_prefix" + const val KEY_KEYBOARD_SNIPPETS_DATA = "keyboard_snippets_data" // Essentials-AirSync Bridge const val KEY_AIRSYNC_CONNECTION_ENABLED = "airsync_connection_enabled" @@ -4890,4 +4894,31 @@ class SettingsRepository( } ?: emptyMap() fun setAppVolumeLevels(levels: Map) = putString(KEY_APP_VOLUME_LEVELS, gson.toJson(levels)) + + fun isTextSnippetsEnabled(): Boolean = getBoolean(KEY_KEYBOARD_TEXT_SNIPPETS_ENABLED, false) + fun setTextSnippetsEnabled(enabled: Boolean) = putBoolean(KEY_KEYBOARD_TEXT_SNIPPETS_ENABLED, enabled) + + fun getSnippetPrefix(): String = getString(KEY_KEYBOARD_SNIPPET_PREFIX, ";") ?: ";" + fun setSnippetPrefix(prefix: String) = putString(KEY_KEYBOARD_SNIPPET_PREFIX, prefix) + + fun getTextSnippets(): List { + val json = getString(KEY_KEYBOARD_SNIPPETS_DATA, null) ?: return defaultSnippets() + return try { + val type = object : TypeToken>() {}.type + gson.fromJson>(json, type) ?: defaultSnippets() + } catch (_: Exception) { + defaultSnippets() + } + } + + fun saveTextSnippets(snippets: List) { + putString(KEY_KEYBOARD_SNIPPETS_DATA, gson.toJson(snippets)) + } + + fun defaultSnippets(): List = + listOf( + Snippet(id = 1L, keyword = "addr", title = "Home Address", content = "123 Main Street, Springfield"), + Snippet(id = 2L, keyword = "brb", title = "Be Right Back", content = "Be right back!"), + Snippet(id = 3L, keyword = "email", title = "Personal Email", content = "hello@example.com"), + ) } diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/Snippet.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/Snippet.kt new file mode 100644 index 000000000..393614415 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/Snippet.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Domain Model Layer + * File: Snippet.kt + * Description: Data class representing an expandable text snippet with trigger keyword. + */ + +package com.sameerasw.essentials.domain.model + +data class Snippet( + val id: Long = System.currentTimeMillis(), + val keyword: String, + val title: String = "", + val content: String, +) From 990c883ba714ac28154f526425cfddb531c09f9a Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:41:48 +0530 Subject: [PATCH 2/7] feat(ime): add snippet expansion engine with configurable triggers and unit tests --- .../essentials/ime/SnippetExpansionEngine.kt | 64 +++++++++ .../ime/SnippetExpansionEngineTest.kt | 134 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 app/src/main/java/com/sameerasw/essentials/ime/SnippetExpansionEngine.kt create mode 100644 app/src/test/java/com/sameerasw/essentials/ime/SnippetExpansionEngineTest.kt diff --git a/app/src/main/java/com/sameerasw/essentials/ime/SnippetExpansionEngine.kt b/app/src/main/java/com/sameerasw/essentials/ime/SnippetExpansionEngine.kt new file mode 100644 index 000000000..ede7ace95 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ime/SnippetExpansionEngine.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Ime + * File: SnippetExpansionEngine.kt + * Description: Engine for detecting snippet prefixes/keywords and expanding text. + */ + +package com.sameerasw.essentials.ime + +import com.sameerasw.essentials.domain.model.Snippet + +class SnippetExpansionEngine( + private val getSnippets: () -> List, + private val getPrefix: () -> String, + private val isEnabled: () -> Boolean, +) { + /** + * Checks if the text before cursor matches any active snippet trigger. + * When matched, calls onExpand with the number of characters to delete and the replacement string. + * + * @param textBeforeCursor Text preceding the cursor. + * @param onExpand Callback returning deleteCount and replacement content. + * @return True if a snippet was matched and expanded, false otherwise. + */ + fun checkAndExpand( + textBeforeCursor: String, + onExpand: (deleteCount: Int, replacement: String) -> Unit, + ): Boolean { + if (!isEnabled() || textBeforeCursor.isEmpty()) return false + val prefix = getPrefix() + val snippets = getSnippets() + if (snippets.isEmpty()) return false + + val sortedSnippets = snippets.sortedByDescending { it.keyword.length } + + for (snippet in sortedSnippets) { + if (snippet.keyword.isEmpty()) continue + + if (prefix.isNotEmpty()) { + val trigger = "$prefix${snippet.keyword}" + if (textBeforeCursor.endsWith(trigger)) { + onExpand(trigger.length, snippet.content) + return true + } + } else { + val lastChar = textBeforeCursor.last() + if (lastChar.isWhitespace()) { + val trimmed = textBeforeCursor.dropLast(1) + if (trimmed.endsWith(snippet.keyword)) { + val beforeKeywordIndex = trimmed.length - snippet.keyword.length - 1 + val isBoundary = beforeKeywordIndex < 0 || !trimmed[beforeKeywordIndex].isLetterOrDigit() + if (isBoundary) { + onExpand(snippet.keyword.length + 1, snippet.content + lastChar) + return true + } + } + } + } + } + return false + } +} diff --git a/app/src/test/java/com/sameerasw/essentials/ime/SnippetExpansionEngineTest.kt b/app/src/test/java/com/sameerasw/essentials/ime/SnippetExpansionEngineTest.kt new file mode 100644 index 000000000..c8009fb3e --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/ime/SnippetExpansionEngineTest.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Test - IME + * File: SnippetExpansionEngineTest.kt + * Description: Unit tests for SnippetExpansionEngine. + */ + +package com.sameerasw.essentials.ime + +import com.sameerasw.essentials.domain.model.Snippet +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SnippetExpansionEngineTest { + private val sampleSnippets = + listOf( + Snippet(id = 1L, keyword = "brb", title = "Be Right Back", content = "Be right back!"), + Snippet(id = 2L, keyword = "addr", title = "Address", content = "123 Main Street"), + Snippet(id = 3L, keyword = "email", title = "Email", content = "user@test.com"), + ) + + @Test + fun testExpansionWithSemicolonPrefix() { + var prefix = ";" + var enabled = true + val engine = + SnippetExpansionEngine( + getSnippets = { sampleSnippets }, + getPrefix = { prefix }, + isEnabled = { enabled }, + ) + + var deleted = 0 + var replacement = "" + + val matched = + engine.checkAndExpand("Hello ;brb") { deleteCount, text -> + deleted = deleteCount + replacement = text + } + + assertTrue(matched) + assertEquals(4, deleted) // ";brb".length == 4 + assertEquals("Be right back!", replacement) + } + + @Test + fun testExpansionWithDoubleAtPrefix() { + val engine = + SnippetExpansionEngine( + getSnippets = { sampleSnippets }, + getPrefix = { "@@" }, + isEnabled = { true }, + ) + + var deleted = 0 + var replacement = "" + + val matched = + engine.checkAndExpand("Contact me at @@email") { deleteCount, text -> + deleted = deleteCount + replacement = text + } + + assertTrue(matched) + assertEquals(7, deleted) // "@@email".length == 7 + assertEquals("user@test.com", replacement) + } + + @Test + fun testExpansionOnSpaceWhenPrefixIsEmpty() { + val engine = + SnippetExpansionEngine( + getSnippets = { sampleSnippets }, + getPrefix = { "" }, + isEnabled = { true }, + ) + + var deleted = 0 + var replacement = "" + + val matched = + engine.checkAndExpand("My address is addr ") { deleteCount, text -> + deleted = deleteCount + replacement = text + } + + assertTrue(matched) + assertEquals(5, deleted) // "addr ".length == 5 + assertEquals("123 Main Street ", replacement) + } + + @Test + fun testDisabledEngineDoesNotExpand() { + val engine = + SnippetExpansionEngine( + getSnippets = { sampleSnippets }, + getPrefix = { ";" }, + isEnabled = { false }, + ) + + var called = false + val matched = + engine.checkAndExpand(";brb") { _, _ -> + called = true + } + + assertFalse(matched) + assertFalse(called) + } + + @Test + fun testNonMatchingPrefixDoesNotExpand() { + val engine = + SnippetExpansionEngine( + getSnippets = { sampleSnippets }, + getPrefix = { ";" }, + isEnabled = { true }, + ) + + var called = false + val matched = + engine.checkAndExpand(":brb") { _, _ -> + called = true + } + + assertFalse(matched) + assertFalse(called) + } +} From 62a911931347a7a622ccd51d56c25cbe6a64fbcc Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:41:48 +0530 Subject: [PATCH 3/7] feat(accessibility): enable system-wide text snippet expansion across keyboards --- .../ime/EssentialsInputMethodService.kt | 27 +++++- .../handlers/SnippetExpansionHandler.kt | 88 +++++++++++++++++++ .../tiles/ScreenOffAccessibilityService.kt | 8 ++ .../res/xml/accessibility_service_config.xml | 2 +- 4 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetExpansionHandler.kt 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 6240cf886..7aa2619cb 100644 --- a/app/src/main/java/com/sameerasw/essentials/ime/EssentialsInputMethodService.kt +++ b/app/src/main/java/com/sameerasw/essentials/ime/EssentialsInputMethodService.kt @@ -71,6 +71,16 @@ class EssentialsInputMethodService : // Undo Manager private val undoRedoManager = UndoRedoManager() + // Settings & Snippet Expansion Engine + private val settingsRepository by lazy { SettingsRepository(this) } + private val snippetEngine by lazy { + SnippetExpansionEngine( + getSnippets = { settingsRepository.getTextSnippets() }, + getPrefix = { settingsRepository.getSnippetPrefix() }, + isEnabled = { settingsRepository.isTextSnippetsEnabled() }, + ) + } + // Suggestion Lookup Job private var lookupJob: Job? = null @@ -470,8 +480,21 @@ class EssentialsInputMethodService : onType = { text -> undoRedoManager.recordInsert(text) currentInputConnection?.commitText(text, 1) - if (isUserDictionaryEnabled && text.length == 1 && !text[0].isLetterOrDigit()) { - val ic = currentInputConnection + + val ic = currentInputConnection + var expanded = false + if (ic != null) { + val textBefore = ic.getTextBeforeCursor(100, 0)?.toString() ?: "" + expanded = + snippetEngine.checkAndExpand(textBefore) { deleteCount, replacement -> + undoRedoManager.recordDelete(textBefore.takeLast(deleteCount)) + ic.deleteSurroundingText(deleteCount, 0) + undoRedoManager.recordInsert(replacement) + ic.commitText(replacement, 1) + } + } + + if (!expanded && isUserDictionaryEnabled && text.length == 1 && !text[0].isLetterOrDigit()) { if (ic != null) { val textBefore = ic.getTextBeforeCursor(50, 0)?.toString() if (!textBefore.isNullOrEmpty()) { diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetExpansionHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetExpansionHandler.kt new file mode 100644 index 000000000..241d90b6f --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetExpansionHandler.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Background Services & Receivers + * File: SnippetExpansionHandler.kt + * Description: Accessibility event handler for expanding text snippets across any input method or app. + */ + +package com.sameerasw.essentials.services.handlers + +import android.accessibilityservice.AccessibilityService +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.ime.SnippetExpansionEngine +import kotlinx.coroutines.CoroutineScope + +class SnippetExpansionHandler( + private val service: AccessibilityService, + private val scope: CoroutineScope, +) { + private val settings = SettingsRepository(service) + private val handler = Handler(Looper.getMainLooper()) + private var isUpdatingText = false + + private val snippetEngine = + SnippetExpansionEngine( + getSnippets = { settings.getTextSnippets() }, + getPrefix = { settings.getSnippetPrefix() }, + isEnabled = { settings.isTextSnippetsEnabled() }, + ) + + fun onTextChanged(event: AccessibilityEvent) { + if (!settings.isTextSnippetsEnabled()) return + if (isUpdatingText) return + if (event.isPassword) return + + val source = event.source ?: return + if (source.isPassword || !source.isEditable) return + + val text = source.text?.toString() ?: event.text.joinToString("") + if (text.isEmpty()) { + return + } + + var cursor = source.textSelectionEnd + if (cursor < 0 || cursor > text.length) { + cursor = text.length + } + + val textBeforeCursor = text.substring(0, cursor) + + val matched = + snippetEngine.checkAndExpand(textBeforeCursor) { deleteCount, replacement -> + val start = cursor - deleteCount + if (start < 0) return@checkAndExpand + + val newText = text.substring(0, start) + replacement + text.substring(cursor) + val newCursor = start + replacement.length + + isUpdatingText = true + try { + val args = + Bundle().apply { + putCharSequence( + AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, + newText, + ) + } + val setSuccess = source.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args) + if (setSuccess) { + val selArgs = + Bundle().apply { + putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, newCursor) + putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, newCursor) + } + source.performAction(AccessibilityNodeInfo.ACTION_SET_SELECTION, selArgs) + } + } finally { + handler.postDelayed({ isUpdatingText = false }, 250) + } + } + } +} 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 fff4dc102..42b2215fa 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 @@ -48,6 +48,7 @@ import com.sameerasw.essentials.services.handlers.DuoOverlayHandler import com.sameerasw.essentials.services.handlers.FlashlightHandler import com.sameerasw.essentials.island.service.IslandCoordinator import com.sameerasw.essentials.services.handlers.NotificationLightingHandler +import com.sameerasw.essentials.services.handlers.SnippetExpansionHandler import com.sameerasw.essentials.services.handlers.OmniGestureOverlayHandler import com.sameerasw.essentials.services.handlers.FaceUnlockBrightnessHandler import com.sameerasw.essentials.services.handlers.PocketModeHandler @@ -102,6 +103,7 @@ class ScreenOffAccessibilityService : private lateinit var screenshotAutoCleanHandler: ScreenshotAutoCleanHandler private lateinit var lockscreenShortcutsHandler: LockscreenShortcutsHandler private lateinit var appVolumeOverlayHandler: AppVolumeOverlayHandler + private lateinit var snippetExpansionHandler: SnippetExpansionHandler private lateinit var statusBarSectionsDebugHandler: com.sameerasw.essentials.services.handlers.StatusBarSectionsDebugHandler private var lightSensor: Sensor? = null @@ -445,6 +447,7 @@ class ScreenOffAccessibilityService : appVolumeOverlayHandler = AppVolumeOverlayHandler(this) screenshotShareCleanupHandler = ScreenshotShareCleanupHandler(this, serviceScope) screenshotAutoCleanHandler = ScreenshotAutoCleanHandler(this, serviceScope) + snippetExpansionHandler = SnippetExpansionHandler(this, serviceScope) flashlightHandler.register() statusBarIconHandler.register() @@ -621,6 +624,7 @@ class ScreenOffAccessibilityService : isA11yConnected = true serviceInfo = serviceInfo.apply { + eventTypes = eventTypes or AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED flags = flags or AccessibilityServiceInfo.FLAG_REQUEST_FILTER_KEY_EVENTS or AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS @@ -746,6 +750,10 @@ class ScreenOffAccessibilityService : screenshotShareCleanupHandler.onViewClicked(event, appFlowHandler.currentPackage) } + if (event.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED) { + snippetExpansionHandler.onTextChanged(event) + } + if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED || event.eventType == AccessibilityEvent.TYPE_WINDOWS_CHANGED ) { diff --git a/app/src/main/res/xml/accessibility_service_config.xml b/app/src/main/res/xml/accessibility_service_config.xml index 514fb2ab8..8c7706919 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: Wed, 11 Nov 2026 19:41:48 +0530 Subject: [PATCH 4/7] feat(registry): register text snippets feature and accessibility permission bindings --- .../domain/registry/FeatureRegistry.kt | 35 +++++++++++++ .../domain/registry/PermissionRegistry.kt | 1 + .../ui/activities/FeatureSettingsActivity.kt | 17 +++++++ .../essentials/viewmodels/MainViewModel.kt | 50 +++++++++++++++++++ app/src/main/res/values/strings.xml | 29 +++++++++++ 5 files changed, 132 insertions(+) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index a5172cad3..0099cae0b 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt @@ -2440,6 +2440,41 @@ object FeatureRegistry { enabled: Boolean, ) {} }, + object : Feature( + id = "Text Snippets", + title = R.string.feat_text_snippets_title, + iconRes = R.drawable.rounded_edit_note_24, + category = R.string.cat_system, + description = R.string.feat_text_snippets_desc, + aboutDescription = R.string.about_desc_text_snippets, + permissionKeys = listOf("ACCESSIBILITY"), + hasMoreSettings = true, + showToggle = false, + ) { + override fun isEnabled(viewModel: MainViewModel) = viewModel.isTextSnippetsEnabled.value + + override fun onToggle( + viewModel: MainViewModel, + context: Context, + enabled: Boolean, + ) { + viewModel.setTextSnippetsEnabled(enabled, context) + } + + override fun onClick( + context: Context, + viewModel: MainViewModel, + ) { + val intent = + Intent( + context, + com.sameerasw.essentials.FeatureSettingsActivity::class.java, + ).apply { + putExtra("feature", "Text Snippets") + } + context.startActivity(intent) + } + }, object : Feature( id = "Batteries", title = R.string.feat_batteries_title, diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt index 4200c59d6..2fbe16350 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt @@ -62,6 +62,7 @@ fun initPermissionRegistry() { PermissionRegistry.register("ACCESSIBILITY", R.string.feat_status_glance_title) PermissionRegistry.register("READ_CALENDAR", R.string.feat_status_glance_title) PermissionRegistry.register("ACCESSIBILITY", R.string.feat_conscious_gate_title) + PermissionRegistry.register("ACCESSIBILITY", R.string.feat_text_snippets_title) // Write secure settings permission PermissionRegistry.register("WRITE_SECURE_SETTINGS", R.string.feat_statusbar_icons_title) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt index 9522fafc4..cf407b7d4 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt @@ -116,6 +116,7 @@ import com.sameerasw.essentials.ui.features.system.HilightProgressSettingsUI import com.sameerasw.essentials.ui.features.system.HilightSettingsUI import com.sameerasw.essentials.ui.features.system.HilightStatus import com.sameerasw.essentials.ui.features.system.KeyboardSettingsUI +import com.sameerasw.essentials.ui.features.system.SnippetsSettingsUI import com.sameerasw.essentials.ui.features.system.LiveWallpaperSettingsUI import com.sameerasw.essentials.ui.features.system.LocationReachedSettingsUI import com.sameerasw.essentials.ui.features.system.LockScreenClockSettingsUI @@ -428,6 +429,7 @@ fun FeatureSettingsScreen( "Shut-Up!" -> !isWriteSecureSettingsEnabled || !viewModel.isUsageStatsPermissionGranted.value "Power and Battery" -> !isWriteSecureSettingsEnabled + "Text Snippets" -> !isAccessibilityEnabled "Networks" -> !isWriteSecureSettingsEnabled && !com.sameerasw.essentials.utils.ShellUtils.hasPermission( @@ -1236,6 +1238,13 @@ fun FeatureSettingsScreen( ) } + "Text Snippets" -> { + SnippetsSettingsUI( + viewModel = viewModel, + modifier = Modifier.padding(top = 16.dp), + ) + } + "Batteries" -> { BatteriesSettingsUI( viewModel = viewModel, @@ -1535,6 +1544,7 @@ fun FeatureSettingsScreen( when { isStandbyMultiSelecting -> R.drawable.rounded_mobiledata_arrows_24 (featureId == "Island" || featureId in ISLAND_SUB_PAGE_IDS) -> R.drawable.rounded_play_arrow_24 + featureId == "Text Snippets" -> R.drawable.rounded_add_24 else -> null }, fabAction = @@ -1545,12 +1555,19 @@ fun FeatureSettingsScreen( viewModel.triggerIslandPreview(context) } } + featureId == "Text Snippets" -> { + { + viewModel.editingSnippet.value = null + viewModel.isAddSnippetSheetVisible.value = true + } + } else -> null }, fabContentDescription = when { isStandbyMultiSelecting -> stringResource(R.string.action_move_bucket) (featureId == "Island" || featureId in ISLAND_SUB_PAGE_IDS) -> stringResource(R.string.action_preview) + featureId == "Text Snippets" -> stringResource(R.string.snippets_dialog_new_title) else -> null }, onHelpClick = diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index fd414f895..7be328144 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -72,6 +72,7 @@ import com.sameerasw.essentials.domain.model.PowerChoice import com.sameerasw.essentials.domain.model.RemapSlot import com.sameerasw.essentials.domain.model.ScaleAnimationsProfile import com.sameerasw.essentials.domain.model.SearchableItem +import com.sameerasw.essentials.domain.model.Snippet import com.sameerasw.essentials.domain.model.SystemShortcutsState import com.sameerasw.essentials.domain.model.UpdateInfo import com.sameerasw.essentials.domain.registry.SearchRegistry @@ -649,6 +650,11 @@ class MainViewModel : ViewModel() { val isUserDictionarySheetVisible = mutableStateOf(false) val isLongPressSymbolsEnabled = mutableStateOf(false) val isAccentedCharactersEnabled = mutableStateOf(false) + val isTextSnippetsEnabled = mutableStateOf(false) + val snippetPrefix = mutableStateOf(";") + val textSnippets = mutableStateOf>(emptyList()) + val isAddSnippetSheetVisible = mutableStateOf(false) + val editingSnippet = mutableStateOf(null) // AirSync Bridge val isAirSyncConnectionEnabled = mutableStateOf(false) @@ -1251,6 +1257,13 @@ class MainViewModel : ViewModel() { isLocationReachedFullScreenAlarmEnabled.value = settingsRepository.getLocationReachedFullScreenAlarmEnabled() + SettingsRepository.KEY_KEYBOARD_TEXT_SNIPPETS_ENABLED, + SettingsRepository.KEY_KEYBOARD_SNIPPET_PREFIX, + SettingsRepository.KEY_KEYBOARD_SNIPPETS_DATA, + -> { + loadTextSnippets() + } + SettingsRepository.KEY_ENABLE_UNSUPPORTED_FEATURES -> { isEnableUnsupportedFeatures.value = settingsRepository.isEnableUnsupportedFeatures() @@ -2044,6 +2057,7 @@ class MainViewModel : ViewModel() { settingsRepository.getLockScreenClockSelectedColorId() lockScreenClockSeedColor.intValue = settingsRepository.getLockScreenClockSeedColor() loadShutUpConfigs() + loadTextSnippets() recentSearches.value = settingsRepository.getRecentSearches() loadCachedWallpaper() isDailyWallpaperAutoUpdateEnabled.value = @@ -10845,6 +10859,42 @@ class MainViewModel : ViewModel() { } } + fun loadTextSnippets() { + if (::settingsRepository.isInitialized) { + isTextSnippetsEnabled.value = settingsRepository.isTextSnippetsEnabled() + snippetPrefix.value = settingsRepository.getSnippetPrefix() + textSnippets.value = settingsRepository.getTextSnippets() + } + } + + fun setTextSnippetsEnabled(enabled: Boolean, context: Context) { + isTextSnippetsEnabled.value = enabled + settingsRepository.setTextSnippetsEnabled(enabled) + } + + fun setSnippetPrefix(prefix: String) { + snippetPrefix.value = prefix + settingsRepository.setSnippetPrefix(prefix) + } + + fun saveSnippet(snippet: Snippet) { + val current = textSnippets.value.toMutableList() + val index = current.indexOfFirst { it.id == snippet.id } + if (index >= 0) { + current[index] = snippet + } else { + current.add(0, snippet) + } + textSnippets.value = current + settingsRepository.saveTextSnippets(current) + } + + fun deleteSnippet(id: Long) { + val updated = textSnippets.value.filter { it.id != id } + textSnippets.value = updated + settingsRepository.saveTextSnippets(updated) + } + companion object { var cachedIsUpdateAvailable: Boolean = false var cachedUpdateInfo: UpdateInfo? = null diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c105c9901..58f73f256 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -405,6 +405,35 @@ Overheating prevention Lower brightness on longer period of use Settings + Text Snippets + Expand customizable shortcuts with prefix trigger + Create customizable shortcuts that expand into full phrases, links, or templates when you type a trigger prefix (like ;, :, or @@) or press space. + Manage Snippets + Prefix: \"%1$s\" • %2$d active snippets + Trigger Prefix + Expansion on Space + Prefix symbol: \"%1$s\" + Saved Snippets (%1$d) + No snippets yet + Tap + below to create your first shortcut + Trigger Prefix + Select a symbol that activates snippet expansion. Selecting Space expands the keyword when pressing space. + Prefix Symbol + Semicolon (;) + Colon (:) + Double At (@@) + Double Slash (//) + Space (␣) + New Snippet + Edit Snippet + Shortcut Trigger + Keyword + e.g., brb, addr, email + Label (Optional) + e.g., Be Right Back + Expanded Text + Enter replacement text or multi-line snippet + Change Show Notification From 5a41d4d7f2e4d73c64aa4ea170967d9af3ef3241 Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:41:48 +0530 Subject: [PATCH 5/7] feat(ui): add text snippets management UI conforming to Essentials conventions --- .../ui/core/cards/SelectionCardItem.kt | 107 ++++++ .../ui/features/system/KeyboardSettingsUI.kt | 57 +++ .../ui/features/system/SnippetsSettingsUI.kt | 347 ++++++++++++++++++ 3 files changed, 511 insertions(+) create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/system/SnippetsSettingsUI.kt diff --git a/app/src/main/java/com/sameerasw/essentials/ui/core/cards/SelectionCardItem.kt b/app/src/main/java/com/sameerasw/essentials/ui/core/cards/SelectionCardItem.kt index a4f1d115c..d73964b5a 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/core/cards/SelectionCardItem.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/core/cards/SelectionCardItem.kt @@ -9,19 +9,35 @@ package com.sameerasw.essentials.ui.core.cards +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.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults 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.platform.LocalView import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import com.sameerasw.essentials.R import com.sameerasw.essentials.translation.TranslatableText import com.sameerasw.essentials.utils.HapticUtil @@ -70,3 +86,94 @@ fun SelectionCardItem( ), ) } + +@Composable +fun SnippetCardItem( + trigger: String, + content: String, + title: String? = null, + onClick: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + val view = LocalView.current + + ListItem( + modifier = + modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.extraSmall) + .clickable { + HapticUtil.performUIHaptic(view) + onClick() + }, + headlineContent = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .weight(1f, fill = false) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) { + Text( + text = trigger, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (!title.isNullOrBlank()) { + Spacer(Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + } + }, + supportingContent = { + Text( + text = content, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp), + ) + }, + trailingContent = { + IconButton( + onClick = { + HapticUtil.performUIHaptic(view) + onDelete() + }, + modifier = Modifier.size(36.dp), + ) { + Icon( + painter = painterResource(R.drawable.rounded_delete_24), + contentDescription = stringResource(R.string.action_delete), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.size(20.dp), + ) + } + }, + colors = + ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceBright, + ), + ) +} 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 3a487d360..e60f67b01 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 @@ -9,6 +9,7 @@ package com.sameerasw.essentials.ui.features.system +import android.content.Intent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -59,6 +60,8 @@ fun KeyboardSettingsUI( var text by remember { mutableStateOf("") } val isKeyboardEnabled by viewModel.isKeyboardEnabled val isKeyboardSelected by viewModel.isKeyboardSelected + val isAccessibilityEnabled by viewModel.isAccessibilityEnabled + var showPermissionSheet by remember { mutableStateOf(false) } LaunchedEffect(Unit) { while (true) { @@ -342,8 +345,62 @@ fun KeyboardSettingsUI( } } + // Text Snippets Container + RoundedCardContainer(spacing = 2.dp) { + IconToggleItem( + iconRes = R.drawable.rounded_edit_note_24, + title = stringResource(R.string.feat_text_snippets_title), + isChecked = viewModel.isTextSnippetsEnabled.value, + onCheckedChange = { checked -> + if (checked && !isAccessibilityEnabled) { + showPermissionSheet = true + } else { + viewModel.setTextSnippetsEnabled(checked, context) + } + }, + modifier = Modifier.highlight(highlightSetting == "keyboard_text_snippets"), + ) + + if (viewModel.isTextSnippetsEnabled.value) { + com.sameerasw.essentials.ui.core.cards.SelectionCardItem( + title = stringResource(R.string.title_manage_snippets), + description = + stringResource( + R.string.desc_snippets_summary, + viewModel.snippetPrefix.value.ifEmpty { "␣" }, + viewModel.textSnippets.value.size, + ), + iconRes = R.drawable.rounded_settings_24, + onClick = { + val intent = + Intent(context, com.sameerasw.essentials.FeatureSettingsActivity::class.java).apply { + putExtra("feature", "Text Snippets") + } + context.startActivity(intent) + }, + ) + } + } + Spacer(Modifier.height(32.dp)) + if (showPermissionSheet) { + val permissionItems = + com.sameerasw.essentials.utils.PermissionUIHelper.getPermissionItems( + listOf("ACCESSIBILITY"), + context, + viewModel, + context as? android.app.Activity, + ) + if (permissionItems.isNotEmpty()) { + com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet( + onDismissRequest = { showPermissionSheet = false }, + featureTitle = stringResource(R.string.feat_text_snippets_title), + permissions = permissionItems, + ) + } + } + if (viewModel.isUserDictionarySheetVisible.value) { com.sameerasw.essentials.ui.features.apps.sheets.UserDictionaryBottomSheet( viewModel = viewModel, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/system/SnippetsSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/system/SnippetsSettingsUI.kt new file mode 100644 index 000000000..0622d1b63 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/system/SnippetsSettingsUI.kt @@ -0,0 +1,347 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - System + * File: SnippetsSettingsUI.kt + * Description: UI settings composable for managing text snippets and customizable prefix trigger using app core components. + */ + +package com.sameerasw.essentials.ui.features.system + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.model.Snippet +import com.sameerasw.essentials.ui.components.StackedActionCard +import com.sameerasw.essentials.ui.core.cards.IconToggleItem +import com.sameerasw.essentials.ui.core.cards.SelectionCardItem +import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.core.sheets.EssentialsBottomSheet +import com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet +import com.sameerasw.essentials.utils.HapticUtil +import com.sameerasw.essentials.utils.PermissionUIHelper +import com.sameerasw.essentials.viewmodels.MainViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SnippetsSettingsUI( + viewModel: MainViewModel, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val prefix by viewModel.snippetPrefix + val snippets by viewModel.textSnippets + val isAccessibilityEnabled by viewModel.isAccessibilityEnabled + + var showPrefixSheet by remember { mutableStateOf(false) } + var showPermissionSheet by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.loadTextSnippets() + } + + Column( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Missing Accessibility Permission Warning + if (!isAccessibilityEnabled) { + RoundedCardContainer(spacing = 2.dp) { + SelectionCardItem( + title = stringResource(R.string.perm_accessibility_title), + description = stringResource(R.string.perm_accessibility_desc_common), + iconRes = R.drawable.rounded_settings_accessibility_24, + onClick = { showPermissionSheet = true }, + ) + } + } + + // Trigger Prefix Hero Card with Wide Action Button + StackedActionCard( + iconRes = R.drawable.rounded_edit_note_24, + title = stringResource(R.string.snippets_trigger_prefix), + description = + if (prefix.isEmpty()) { + stringResource(R.string.snippets_prefix_space_expansion) + } else { + stringResource(R.string.snippets_prefix_symbol_desc, prefix) + }, + actionLabel = stringResource(R.string.snippets_action_change), + actionIconRes = R.drawable.rounded_edit_24, + onAction = { showPrefixSheet = true }, + ) + + // Snippets Section + Text( + text = stringResource(R.string.snippets_saved_title, snippets.size), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (snippets.isNotEmpty()) { + RoundedCardContainer(spacing = 2.dp) { + snippets.forEach { snippet -> + val triggerText = if (prefix.isNotEmpty()) "$prefix${snippet.keyword}" else "${snippet.keyword} ␣" + com.sameerasw.essentials.ui.core.cards.SnippetCardItem( + trigger = triggerText, + content = snippet.content, + title = snippet.title, + onClick = { + viewModel.editingSnippet.value = snippet + viewModel.isAddSnippetSheetVisible.value = true + }, + onDelete = { viewModel.deleteSnippet(snippet.id) }, + ) + } + } + } else { + RoundedCardContainer(spacing = 2.dp) { + SelectionCardItem( + title = stringResource(R.string.snippets_empty_title), + description = stringResource(R.string.snippets_empty_subtitle), + iconRes = R.drawable.rounded_add_24, + onClick = { + viewModel.editingSnippet.value = null + viewModel.isAddSnippetSheetVisible.value = true + }, + ) + } + } + } + + // Set Prefix Bottom Sheet using EssentialsBottomSheet (Options Only) + if (showPrefixSheet) { + val options = listOf( + Triple(";", stringResource(R.string.snippets_prefix_semicolon), ";brb"), + Triple(":", stringResource(R.string.snippets_prefix_colon), ":brb"), + Triple("@@", stringResource(R.string.snippets_prefix_at), "@@email"), + Triple("//", stringResource(R.string.snippets_prefix_slash), "//addr"), + Triple("", stringResource(R.string.snippets_prefix_space), "brb ␣"), + ) + + EssentialsBottomSheet(onDismissRequest = { showPrefixSheet = false }) { + val sheetView = LocalView.current + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.snippets_set_prefix_dialog_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource(R.string.snippets_set_prefix_dialog_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + RoundedCardContainer(spacing = 2.dp) { + options.forEach { (sym, label, example) -> + val isSelected = prefix == sym + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + HapticUtil.performUIHaptic(sheetView) + viewModel.setSnippetPrefix(sym) + showPrefixSheet = false + } + .background(MaterialTheme.colorScheme.surfaceBright) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + RadioButton( + selected = isSelected, + onClick = { + HapticUtil.performUIHaptic(sheetView) + viewModel.setSnippetPrefix(sym) + showPrefixSheet = false + }, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "e.g., $example", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + Spacer(Modifier.height(8.dp)) + } + } + } + + // Add / Edit Snippet Bottom Sheet using EssentialsBottomSheet + if (viewModel.isAddSnippetSheetVisible.value) { + val closeSheet = { + viewModel.isAddSnippetSheetVisible.value = false + viewModel.editingSnippet.value = null + } + SnippetBottomSheet( + snippet = viewModel.editingSnippet.value, + prefix = prefix, + onDismiss = closeSheet, + onSave = { updatedSnippet -> + viewModel.saveSnippet(updatedSnippet) + closeSheet() + }, + onDelete = { snippetToDelete -> + viewModel.deleteSnippet(snippetToDelete.id) + closeSheet() + }, + ) + } + + // Missing Permission Bottom Sheet + if (showPermissionSheet) { + val permissionItems = PermissionUIHelper.getPermissionItems( + listOf("ACCESSIBILITY"), + context, + viewModel, + context as? android.app.Activity, + ) + if (permissionItems.isNotEmpty()) { + PermissionsBottomSheet( + onDismissRequest = { showPermissionSheet = false }, + featureTitle = stringResource(R.string.feat_text_snippets_title), + permissions = permissionItems, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SnippetBottomSheet( + snippet: Snippet?, + prefix: String, + onDismiss: () -> Unit, + onSave: (Snippet) -> Unit, + onDelete: ((Snippet) -> Unit)? = null, +) { + var keyword by remember { mutableStateOf(snippet?.keyword ?: "") } + var title by remember { mutableStateOf(snippet?.title ?: "") } + var content by remember { mutableStateOf(snippet?.content ?: "") } + + EssentialsBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(if (snippet == null) R.string.snippets_dialog_new_title else R.string.snippets_dialog_edit_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + + OutlinedTextField( + value = keyword, + onValueChange = { keyword = it.trim().filterNot { c -> c.isWhitespace() } }, + label = { Text(stringResource(R.string.snippets_keyword_label)) }, + placeholder = { Text(stringResource(R.string.snippets_keyword_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) + + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text(stringResource(R.string.snippets_title_label)) }, + placeholder = { Text(stringResource(R.string.snippets_title_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) + + OutlinedTextField( + value = content, + onValueChange = { content = it }, + label = { Text(stringResource(R.string.snippets_content_label)) }, + placeholder = { Text(stringResource(R.string.snippets_content_placeholder)) }, + minLines = 3, + maxLines = 6, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + if (snippet != null && onDelete != null) { + TextButton(onClick = { onDelete(snippet) }) { + Text(text = stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error) + } + Spacer(Modifier.weight(1f)) + } + + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + Spacer(Modifier.width(8.dp)) + Button( + onClick = { + if (keyword.isNotBlank() && content.isNotBlank()) { + onSave(Snippet(id = snippet?.id ?: System.currentTimeMillis(), keyword = keyword, title = title.trim(), content = content)) + } + }, + enabled = keyword.isNotBlank() && content.isNotBlank(), + ) { + Text(stringResource(R.string.action_save)) + } + } + } + } +} From f0ebe9292cc4b368127172166542831e925dad4a Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:33:53 +0530 Subject: [PATCH 6/7] feat(data): update default snippets to shrug, essentials repo, and email --- .../essentials/data/repository/SettingsRepository.kt | 4 ++-- .../essentials/ui/features/system/SnippetsSettingsUI.kt | 8 ++++---- 2 files changed, 6 insertions(+), 6 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 2430e7b67..b0b84efa4 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 @@ -4917,8 +4917,8 @@ class SettingsRepository( fun defaultSnippets(): List = listOf( - Snippet(id = 1L, keyword = "addr", title = "Home Address", content = "123 Main Street, Springfield"), - Snippet(id = 2L, keyword = "brb", title = "Be Right Back", content = "Be right back!"), + Snippet(id = 1L, keyword = "shrug", title = "Shrug", content = "¯\\_(ツ)_/¯"), + Snippet(id = 2L, keyword = "app", title = "Essentials", content = "https://github.com/sameerasw/essentials"), Snippet(id = 3L, keyword = "email", title = "Personal Email", content = "hello@example.com"), ) } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/system/SnippetsSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/system/SnippetsSettingsUI.kt index 0622d1b63..e6c032228 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/system/SnippetsSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/system/SnippetsSettingsUI.kt @@ -151,11 +151,11 @@ fun SnippetsSettingsUI( // Set Prefix Bottom Sheet using EssentialsBottomSheet (Options Only) if (showPrefixSheet) { val options = listOf( - Triple(";", stringResource(R.string.snippets_prefix_semicolon), ";brb"), - Triple(":", stringResource(R.string.snippets_prefix_colon), ":brb"), + Triple(";", stringResource(R.string.snippets_prefix_semicolon), ";shrug"), + Triple(":", stringResource(R.string.snippets_prefix_colon), ":shrug"), Triple("@@", stringResource(R.string.snippets_prefix_at), "@@email"), - Triple("//", stringResource(R.string.snippets_prefix_slash), "//addr"), - Triple("", stringResource(R.string.snippets_prefix_space), "brb ␣"), + Triple("//", stringResource(R.string.snippets_prefix_slash), "//app"), + Triple("", stringResource(R.string.snippets_prefix_space), "shrug ␣"), ) EssentialsBottomSheet(onDismissRequest = { showPrefixSheet = false }) { From 0320961b5b8cb6a80f61ddda1381814dcc88596c Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:14:52 +0530 Subject: [PATCH 7/7] refactor(snippet): address review feedback on expansion safety, performance, and UI conventions (#1743) --- .../data/repository/SettingsRepository.kt | 49 +++-- .../essentials/domain/model/Snippet.kt | 4 +- .../domain/registry/FeatureRegistry.kt | 35 ---- .../ime/EssentialsInputMethodService.kt | 9 + .../essentials/ime/SnippetExpansionEngine.kt | 39 ++-- .../handlers/SnippetExpansionHandler.kt | 85 ++++---- .../tiles/ScreenOffAccessibilityService.kt | 37 +++- .../ui/activities/FeatureSettingsActivity.kt | 11 +- .../ui/core/cards/SelectionCardItem.kt | 107 ---------- .../ui/features/system/KeyboardSettingsUI.kt | 14 +- .../ui/features/system/SnippetsSettingsUI.kt | 197 ++++++++++++++---- .../essentials/viewmodels/MainViewModel.kt | 6 +- app/src/main/res/values/strings.xml | 6 +- .../res/xml/accessibility_service_config.xml | 2 +- .../ime/SnippetExpansionEngineTest.kt | 96 ++++++++- 15 files changed, 420 insertions(+), 277 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 b0b84efa4..0ed3c2044 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 @@ -38,6 +38,8 @@ import com.sameerasw.essentials.domain.model.RemapSlot import com.sameerasw.essentials.domain.model.ScaleAnimationsProfile import com.sameerasw.essentials.domain.model.Snippet import com.sameerasw.essentials.domain.model.SystemShortcutsState +import com.sameerasw.essentials.services.tiles.ScreenOffAccessibilityService +import java.util.UUID import com.sameerasw.essentials.domain.model.TrackedRepo import com.sameerasw.essentials.domain.model.github.GitHubUser import com.sameerasw.essentials.utils.RootUtils @@ -199,6 +201,12 @@ class SettingsRepository( companion object { const val PREFS_NAME = "essentials_prefs" + @Volatile + private var cachedSnippets: List? = null + + @Volatile + private var cachedPrefix: String? = null + // Keys const val KEY_DEBUGGING_TILE_TAP_ACTION = "debugging_tile_tap_action" const val KEY_GENAI_AUTOMATION_ENABLED = "genai_automation_enabled" @@ -4896,29 +4904,44 @@ class SettingsRepository( fun setAppVolumeLevels(levels: Map) = putString(KEY_APP_VOLUME_LEVELS, gson.toJson(levels)) fun isTextSnippetsEnabled(): Boolean = getBoolean(KEY_KEYBOARD_TEXT_SNIPPETS_ENABLED, false) - fun setTextSnippetsEnabled(enabled: Boolean) = putBoolean(KEY_KEYBOARD_TEXT_SNIPPETS_ENABLED, enabled) + fun setTextSnippetsEnabled(enabled: Boolean) { + putBoolean(KEY_KEYBOARD_TEXT_SNIPPETS_ENABLED, enabled) + ScreenOffAccessibilityService.updateSnippetTextListening(enabled) + } - fun getSnippetPrefix(): String = getString(KEY_KEYBOARD_SNIPPET_PREFIX, ";") ?: ";" - fun setSnippetPrefix(prefix: String) = putString(KEY_KEYBOARD_SNIPPET_PREFIX, prefix) + fun getSnippetPrefix(): String { + cachedPrefix?.let { return it } + val prefix = getString(KEY_KEYBOARD_SNIPPET_PREFIX, ";") ?: ";" + cachedPrefix = prefix + return prefix + } + + fun setSnippetPrefix(prefix: String) { + cachedPrefix = prefix + putString(KEY_KEYBOARD_SNIPPET_PREFIX, prefix) + } fun getTextSnippets(): List { - val json = getString(KEY_KEYBOARD_SNIPPETS_DATA, null) ?: return defaultSnippets() - return try { - val type = object : TypeToken>() {}.type - gson.fromJson>(json, type) ?: defaultSnippets() - } catch (_: Exception) { - defaultSnippets() - } + cachedSnippets?.let { return it } + val json = getString(KEY_KEYBOARD_SNIPPETS_DATA, null) ?: return defaultSnippets().also { cachedSnippets = it } + val list = + try { + val type = object : TypeToken>() {}.type + gson.fromJson>(json, type) ?: defaultSnippets() + } catch (_: Exception) { + defaultSnippets() + } + cachedSnippets = list + return list } fun saveTextSnippets(snippets: List) { + cachedSnippets = snippets putString(KEY_KEYBOARD_SNIPPETS_DATA, gson.toJson(snippets)) } fun defaultSnippets(): List = listOf( - Snippet(id = 1L, keyword = "shrug", title = "Shrug", content = "¯\\_(ツ)_/¯"), - Snippet(id = 2L, keyword = "app", title = "Essentials", content = "https://github.com/sameerasw/essentials"), - Snippet(id = 3L, keyword = "email", title = "Personal Email", content = "hello@example.com"), + Snippet(id = UUID.randomUUID().toString(), keyword = "shrug", title = "Shrug", content = "¯\\_(ツ)_/¯"), ) } diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/Snippet.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/Snippet.kt index 393614415..1325c2080 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/model/Snippet.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/Snippet.kt @@ -9,8 +9,10 @@ package com.sameerasw.essentials.domain.model +import java.util.UUID + data class Snippet( - val id: Long = System.currentTimeMillis(), + val id: String = UUID.randomUUID().toString(), val keyword: String, val title: String = "", val content: String, diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index 0099cae0b..a5172cad3 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt @@ -2440,41 +2440,6 @@ object FeatureRegistry { enabled: Boolean, ) {} }, - object : Feature( - id = "Text Snippets", - title = R.string.feat_text_snippets_title, - iconRes = R.drawable.rounded_edit_note_24, - category = R.string.cat_system, - description = R.string.feat_text_snippets_desc, - aboutDescription = R.string.about_desc_text_snippets, - permissionKeys = listOf("ACCESSIBILITY"), - hasMoreSettings = true, - showToggle = false, - ) { - override fun isEnabled(viewModel: MainViewModel) = viewModel.isTextSnippetsEnabled.value - - override fun onToggle( - viewModel: MainViewModel, - context: Context, - enabled: Boolean, - ) { - viewModel.setTextSnippetsEnabled(enabled, context) - } - - override fun onClick( - context: Context, - viewModel: MainViewModel, - ) { - val intent = - Intent( - context, - com.sameerasw.essentials.FeatureSettingsActivity::class.java, - ).apply { - putExtra("feature", "Text Snippets") - } - context.startActivity(intent) - } - }, object : Feature( id = "Batteries", title = R.string.feat_batteries_title, 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 7aa2619cb..05c11228d 100644 --- a/app/src/main/java/com/sameerasw/essentials/ime/EssentialsInputMethodService.kt +++ b/app/src/main/java/com/sameerasw/essentials/ime/EssentialsInputMethodService.kt @@ -641,6 +641,7 @@ class EssentialsInputMethodService : restarting: Boolean, ) { super.onStartInputView(info, restarting) + isInputActive = true // Ensure lifecycle is in RESUMED state when view becomes visible if (lifecycleRegistry.currentState != Lifecycle.State.RESUMED) { lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) @@ -651,12 +652,14 @@ class EssentialsInputMethodService : override fun onFinishInputView(finishingInput: Boolean) { super.onFinishInputView(finishingInput) + isInputActive = false lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) kbdResetTrigger.value++ } override fun onDestroy() { super.onDestroy() + isInputActive = false lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) if (::clipboardManager.isInitialized) { @@ -781,4 +784,10 @@ class EssentialsInputMethodService : _clipboardHistory.value = current } } + + companion object { + @Volatile + var isInputActive: Boolean = false + internal set + } } diff --git a/app/src/main/java/com/sameerasw/essentials/ime/SnippetExpansionEngine.kt b/app/src/main/java/com/sameerasw/essentials/ime/SnippetExpansionEngine.kt index ede7ace95..99abaa2a2 100644 --- a/app/src/main/java/com/sameerasw/essentials/ime/SnippetExpansionEngine.kt +++ b/app/src/main/java/com/sameerasw/essentials/ime/SnippetExpansionEngine.kt @@ -16,14 +16,6 @@ class SnippetExpansionEngine( private val getPrefix: () -> String, private val isEnabled: () -> Boolean, ) { - /** - * Checks if the text before cursor matches any active snippet trigger. - * When matched, calls onExpand with the number of characters to delete and the replacement string. - * - * @param textBeforeCursor Text preceding the cursor. - * @param onExpand Callback returning deleteCount and replacement content. - * @return True if a snippet was matched and expanded, false otherwise. - */ fun checkAndExpand( textBeforeCursor: String, onExpand: (deleteCount: Int, replacement: String) -> Unit, @@ -33,24 +25,41 @@ class SnippetExpansionEngine( val snippets = getSnippets() if (snippets.isEmpty()) return false - val sortedSnippets = snippets.sortedByDescending { it.keyword.length } + val sorted = snippets.sortedByDescending { it.keyword.length } - for (snippet in sortedSnippets) { + for (snippet in sorted) { if (snippet.keyword.isEmpty()) continue if (prefix.isNotEmpty()) { val trigger = "$prefix${snippet.keyword}" - if (textBeforeCursor.endsWith(trigger)) { - onExpand(trigger.length, snippet.content) + val hasDelimiter = textBeforeCursor.endsWith(" ") || textBeforeCursor.endsWith("\t") + val candidate = if (hasDelimiter) textBeforeCursor.dropLast(1) else textBeforeCursor + + if (candidate.endsWith(trigger, ignoreCase = true)) { + val triggerStart = candidate.length - trigger.length + val isBoundary = triggerStart == 0 || candidate[triggerStart - 1].isWhitespace() + if (!isBoundary) continue + + if (!hasDelimiter) { + val hasLongerSibling = sorted.any { + it.keyword.length > snippet.keyword.length && + it.keyword.startsWith(snippet.keyword, ignoreCase = true) + } + if (hasLongerSibling) continue + } + + val matchLen = if (hasDelimiter) trigger.length + 1 else trigger.length + val replacement = if (hasDelimiter) snippet.content + " " else snippet.content + onExpand(matchLen, replacement) return true } } else { val lastChar = textBeforeCursor.last() if (lastChar.isWhitespace()) { val trimmed = textBeforeCursor.dropLast(1) - if (trimmed.endsWith(snippet.keyword)) { - val beforeKeywordIndex = trimmed.length - snippet.keyword.length - 1 - val isBoundary = beforeKeywordIndex < 0 || !trimmed[beforeKeywordIndex].isLetterOrDigit() + if (trimmed.endsWith(snippet.keyword, ignoreCase = true)) { + val beforeIndex = trimmed.length - snippet.keyword.length - 1 + val isBoundary = beforeIndex < 0 || trimmed[beforeIndex].isWhitespace() if (isBoundary) { onExpand(snippet.keyword.length + 1, snippet.content + lastChar) return true diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetExpansionHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetExpansionHandler.kt index 241d90b6f..259a14c6d 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetExpansionHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetExpansionHandler.kt @@ -10,22 +10,20 @@ package com.sameerasw.essentials.services.handlers import android.accessibilityservice.AccessibilityService +import android.os.Build import android.os.Bundle -import android.os.Handler -import android.os.Looper +import android.text.InputType import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.ime.EssentialsInputMethodService import com.sameerasw.essentials.ime.SnippetExpansionEngine -import kotlinx.coroutines.CoroutineScope class SnippetExpansionHandler( private val service: AccessibilityService, - private val scope: CoroutineScope, ) { private val settings = SettingsRepository(service) - private val handler = Handler(Looper.getMainLooper()) - private var isUpdatingText = false + @Volatile private var expectedSelfText: String? = null private val snippetEngine = SnippetExpansionEngine( @@ -36,15 +34,30 @@ class SnippetExpansionHandler( fun onTextChanged(event: AccessibilityEvent) { if (!settings.isTextSnippetsEnabled()) return - if (isUpdatingText) return + if (EssentialsInputMethodService.isInputActive) return if (event.isPassword) return val source = event.source ?: return if (source.isPassword || !source.isEditable) return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && source.isShowingHintText) return - val text = source.text?.toString() ?: event.text.joinToString("") - if (text.isEmpty()) { - return + val inputType = source.inputType + val isPasswordType = + (inputType and InputType.TYPE_MASK_VARIATION) == InputType.TYPE_TEXT_VARIATION_PASSWORD || + (inputType and InputType.TYPE_MASK_VARIATION) == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD || + (inputType and InputType.TYPE_MASK_VARIATION) == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD || + ((inputType and InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_NUMBER && + (inputType and InputType.TYPE_MASK_VARIATION) == InputType.TYPE_NUMBER_VARIATION_PASSWORD) + if (isPasswordType) return + + val text = source.text?.toString() ?: return + if (text.isEmpty()) return + + if (expectedSelfText != null) { + if (text == expectedSelfText) { + expectedSelfText = null + return + } } var cursor = source.textSelectionEnd @@ -54,35 +67,35 @@ class SnippetExpansionHandler( val textBeforeCursor = text.substring(0, cursor) - val matched = - snippetEngine.checkAndExpand(textBeforeCursor) { deleteCount, replacement -> - val start = cursor - deleteCount - if (start < 0) return@checkAndExpand + snippetEngine.checkAndExpand(textBeforeCursor) { deleteCount, replacement -> + val start = cursor - deleteCount + if (start < 0) return@checkAndExpand - val newText = text.substring(0, start) + replacement + text.substring(cursor) - val newCursor = start + replacement.length + val newText = text.substring(0, start) + replacement + text.substring(cursor) + val newCursor = start + replacement.length - isUpdatingText = true - try { - val args = - Bundle().apply { - putCharSequence( - AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, - newText, - ) - } - val setSuccess = source.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args) - if (setSuccess) { - val selArgs = - Bundle().apply { - putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, newCursor) - putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, newCursor) - } - source.performAction(AccessibilityNodeInfo.ACTION_SET_SELECTION, selArgs) - } - } finally { - handler.postDelayed({ isUpdatingText = false }, 250) + val supportsSetText = source.actionList.any { it.id == AccessibilityNodeInfo.ACTION_SET_TEXT } + if (!supportsSetText) return@checkAndExpand + + expectedSelfText = newText + val args = + Bundle().apply { + putCharSequence( + AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, + newText, + ) } + val setSuccess = source.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args) + if (setSuccess) { + val selArgs = + Bundle().apply { + putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, newCursor) + putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, newCursor) + } + source.performAction(AccessibilityNodeInfo.ACTION_SET_SELECTION, selArgs) + } else { + expectedSelfText = null } + } } } 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 42b2215fa..3ca13e864 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 @@ -49,6 +49,8 @@ import com.sameerasw.essentials.services.handlers.FlashlightHandler import com.sameerasw.essentials.island.service.IslandCoordinator import com.sameerasw.essentials.services.handlers.NotificationLightingHandler import com.sameerasw.essentials.services.handlers.SnippetExpansionHandler +import com.sameerasw.essentials.services.handlers.SmartPixelsHandler +import com.sameerasw.essentials.services.handlers.StatusBarSectionsDebugHandler import com.sameerasw.essentials.services.handlers.OmniGestureOverlayHandler import com.sameerasw.essentials.services.handlers.FaceUnlockBrightnessHandler import com.sameerasw.essentials.services.handlers.PocketModeHandler @@ -95,7 +97,7 @@ class ScreenOffAccessibilityService : private lateinit var statusBarIconHandler: StatusBarIconHandler private lateinit var pocketModeHandler: PocketModeHandler private lateinit var faceUnlockBrightnessHandler: FaceUnlockBrightnessHandler - private lateinit var smartPixelsHandler: com.sameerasw.essentials.services.handlers.SmartPixelsHandler + private lateinit var smartPixelsHandler: SmartPixelsHandler private lateinit var duoOverlayHandler: DuoOverlayHandler lateinit var islandOverlayHandler: IslandCoordinator private lateinit var statusGlanceHandler: StatusGlanceHandler @@ -104,7 +106,7 @@ class ScreenOffAccessibilityService : private lateinit var lockscreenShortcutsHandler: LockscreenShortcutsHandler private lateinit var appVolumeOverlayHandler: AppVolumeOverlayHandler private lateinit var snippetExpansionHandler: SnippetExpansionHandler - private lateinit var statusBarSectionsDebugHandler: com.sameerasw.essentials.services.handlers.StatusBarSectionsDebugHandler + private lateinit var statusBarSectionsDebugHandler: StatusBarSectionsDebugHandler private var lightSensor: Sensor? = null private var lightSensorLux: Float = 100f @@ -393,6 +395,8 @@ class ScreenOffAccessibilityService : key == SettingsRepository.KEY_LOCKSCREEN_SHORTCUT_RIGHT_ACTIONS ) { lockscreenShortcutsHandler.updateState() + } else if (key == SettingsRepository.KEY_KEYBOARD_TEXT_SNIPPETS_ENABLED) { + updateSnippetTextListening(SettingsRepository(this).isTextSnippetsEnabled()) } else if (key == SettingsRepository.KEY_APP_VOLUME_ENABLED || key == SettingsRepository.KEY_APP_VOLUME_LEVELS ) { @@ -431,9 +435,7 @@ class ScreenOffAccessibilityService : statusBarIconHandler = StatusBarIconHandler(this) pocketModeHandler = PocketModeHandler(this) faceUnlockBrightnessHandler = FaceUnlockBrightnessHandler(this) - smartPixelsHandler = - com.sameerasw.essentials.services.handlers - .SmartPixelsHandler(this) + smartPixelsHandler = SmartPixelsHandler(this) duoOverlayHandler = DuoOverlayHandler(this) islandOverlayHandler = IslandCoordinator(this) islandOverlayHandler.onVisibilityChanged = { @@ -442,12 +444,12 @@ class ScreenOffAccessibilityService : } duoOverlayHandler.openBrief = { islandOverlayHandler.openBrief() } statusGlanceHandler = StatusGlanceHandler(this) - statusBarSectionsDebugHandler = com.sameerasw.essentials.services.handlers.StatusBarSectionsDebugHandler(this) + statusBarSectionsDebugHandler = StatusBarSectionsDebugHandler(this) lockscreenShortcutsHandler = LockscreenShortcutsHandler(this, flashlightHandler) appVolumeOverlayHandler = AppVolumeOverlayHandler(this) screenshotShareCleanupHandler = ScreenshotShareCleanupHandler(this, serviceScope) screenshotAutoCleanHandler = ScreenshotAutoCleanHandler(this, serviceScope) - snippetExpansionHandler = SnippetExpansionHandler(this, serviceScope) + snippetExpansionHandler = SnippetExpansionHandler(this) flashlightHandler.register() statusBarIconHandler.register() @@ -624,7 +626,9 @@ class ScreenOffAccessibilityService : isA11yConnected = true serviceInfo = serviceInfo.apply { - eventTypes = eventTypes or AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED + if (SettingsRepository(this@ScreenOffAccessibilityService).isTextSnippetsEnabled()) { + eventTypes = eventTypes or AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED + } flags = flags or AccessibilityServiceInfo.FLAG_REQUEST_FILTER_KEY_EVENTS or AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS @@ -673,6 +677,7 @@ class ScreenOffAccessibilityService : } override fun onDestroy() { + if (instance == this) instance = null Shizuku.removeBinderReceivedListener(shizukuBinderListener) try { unregisterReceiver(screenReceiver) @@ -1253,5 +1258,21 @@ class ScreenOffAccessibilityService : fun updateSmartPixelsState() { instance?.smartPixelsHandler?.updateState() } + + fun updateSnippetTextListening(enabled: Boolean) { + val service = instance ?: return + val info = service.serviceInfo ?: return + val current = info.eventTypes + val target = + if (enabled) { + current or AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED + } else { + current and AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED.inv() + } + if (current != target) { + info.eventTypes = target + service.serviceInfo = info + } + } } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt index cf407b7d4..7fc633b70 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt @@ -69,6 +69,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import com.sameerasw.essentials.domain.HapticFeedbackType +import com.sameerasw.essentials.domain.model.Snippet import com.sameerasw.essentials.domain.registry.FeatureRegistry import com.sameerasw.essentials.ui.components.EssentialsFloatingToolbar import com.sameerasw.essentials.ui.components.animations.LottieFeatureAnimation @@ -269,6 +270,8 @@ fun FeatureSettingsScreen( var standbyAppsSelectedPackages by remember { mutableStateOf(setOf()) } var activityLauncherQuery by remember { mutableStateOf("") } var isStandbyMoveSheetVisible by remember { mutableStateOf(false) } + var isAddSnippetSheetVisible by remember { mutableStateOf(false) } + var editingSnippet by remember { mutableStateOf(null) } val isAccessibilityEnabled by viewModel.isAccessibilityEnabled val isWriteSecureSettingsEnabled by viewModel.isWriteSecureSettingsEnabled @@ -1242,6 +1245,10 @@ fun FeatureSettingsScreen( SnippetsSettingsUI( viewModel = viewModel, modifier = Modifier.padding(top = 16.dp), + isAddSheetVisible = isAddSnippetSheetVisible, + onAddSheetVisibleChange = { isAddSnippetSheetVisible = it }, + editingSnippet = editingSnippet, + onEditingSnippetChange = { editingSnippet = it }, ) } @@ -1557,8 +1564,8 @@ fun FeatureSettingsScreen( } featureId == "Text Snippets" -> { { - viewModel.editingSnippet.value = null - viewModel.isAddSnippetSheetVisible.value = true + editingSnippet = null + isAddSnippetSheetVisible = true } } else -> null diff --git a/app/src/main/java/com/sameerasw/essentials/ui/core/cards/SelectionCardItem.kt b/app/src/main/java/com/sameerasw/essentials/ui/core/cards/SelectionCardItem.kt index d73964b5a..a4f1d115c 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/core/cards/SelectionCardItem.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/core/cards/SelectionCardItem.kt @@ -9,35 +9,19 @@ package com.sameerasw.essentials.ui.core.cards -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.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults 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.platform.LocalView import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.width -import com.sameerasw.essentials.R import com.sameerasw.essentials.translation.TranslatableText import com.sameerasw.essentials.utils.HapticUtil @@ -86,94 +70,3 @@ fun SelectionCardItem( ), ) } - -@Composable -fun SnippetCardItem( - trigger: String, - content: String, - title: String? = null, - onClick: () -> Unit, - onDelete: () -> Unit, - modifier: Modifier = Modifier, -) { - val view = LocalView.current - - ListItem( - modifier = - modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.extraSmall) - .clickable { - HapticUtil.performUIHaptic(view) - onClick() - }, - headlineContent = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = - Modifier - .weight(1f, fill = false) - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.secondaryContainer) - .padding(horizontal = 8.dp, vertical = 2.dp), - ) { - Text( - text = trigger, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSecondaryContainer, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - if (!title.isNullOrBlank()) { - Spacer(Modifier.width(8.dp)) - Text( - text = title, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - } - } - }, - supportingContent = { - Text( - text = content, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 4.dp), - ) - }, - trailingContent = { - IconButton( - onClick = { - HapticUtil.performUIHaptic(view) - onDelete() - }, - modifier = Modifier.size(36.dp), - ) { - Icon( - painter = painterResource(R.drawable.rounded_delete_24), - contentDescription = stringResource(R.string.action_delete), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - modifier = Modifier.size(20.dp), - ) - } - }, - colors = - ListItemDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceBright, - ), - ) -} 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 e60f67b01..74fff8b80 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 @@ -39,12 +39,16 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.FeatureSettingsActivity import com.sameerasw.essentials.R import com.sameerasw.essentials.translation.TranslatableText import com.sameerasw.essentials.ui.components.sliders.ConfigSliderItem import com.sameerasw.essentials.ui.core.cards.IconToggleItem +import com.sameerasw.essentials.ui.core.cards.SelectionCardItem import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet import com.sameerasw.essentials.ui.modifiers.highlight +import com.sameerasw.essentials.utils.PermissionUIHelper import com.sameerasw.essentials.viewmodels.MainViewModel import kotlinx.coroutines.delay @@ -355,14 +359,14 @@ fun KeyboardSettingsUI( if (checked && !isAccessibilityEnabled) { showPermissionSheet = true } else { - viewModel.setTextSnippetsEnabled(checked, context) + viewModel.setTextSnippetsEnabled(checked) } }, modifier = Modifier.highlight(highlightSetting == "keyboard_text_snippets"), ) if (viewModel.isTextSnippetsEnabled.value) { - com.sameerasw.essentials.ui.core.cards.SelectionCardItem( + SelectionCardItem( title = stringResource(R.string.title_manage_snippets), description = stringResource( @@ -373,7 +377,7 @@ fun KeyboardSettingsUI( iconRes = R.drawable.rounded_settings_24, onClick = { val intent = - Intent(context, com.sameerasw.essentials.FeatureSettingsActivity::class.java).apply { + Intent(context, FeatureSettingsActivity::class.java).apply { putExtra("feature", "Text Snippets") } context.startActivity(intent) @@ -386,14 +390,14 @@ fun KeyboardSettingsUI( if (showPermissionSheet) { val permissionItems = - com.sameerasw.essentials.utils.PermissionUIHelper.getPermissionItems( + PermissionUIHelper.getPermissionItems( listOf("ACCESSIBILITY"), context, viewModel, context as? android.app.Activity, ) if (permissionItems.isNotEmpty()) { - com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet( + PermissionsBottomSheet( onDismissRequest = { showPermissionSheet = false }, featureTitle = stringResource(R.string.feat_text_snippets_title), permissions = permissionItems, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/system/SnippetsSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/system/SnippetsSettingsUI.kt index e6c032228..4adf2fbe3 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/system/SnippetsSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/system/SnippetsSettingsUI.kt @@ -4,7 +4,7 @@ * * Feature Module: UI Feature - System * File: SnippetsSettingsUI.kt - * Description: UI settings composable for managing text snippets and customizable prefix trigger using app core components. + * Description: UI screen for managing text expansion snippets and prefix settings. */ package com.sameerasw.essentials.ui.features.system @@ -12,6 +12,7 @@ package com.sameerasw.essentials.ui.features.system 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.Row import androidx.compose.foundation.layout.Spacer @@ -25,6 +26,8 @@ import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton @@ -43,12 +46,13 @@ 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.FontFamily 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.domain.model.Snippet import com.sameerasw.essentials.ui.components.StackedActionCard -import com.sameerasw.essentials.ui.core.cards.IconToggleItem import com.sameerasw.essentials.ui.core.cards.SelectionCardItem import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.ui.core.sheets.EssentialsBottomSheet @@ -56,12 +60,17 @@ import com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.utils.PermissionUIHelper import com.sameerasw.essentials.viewmodels.MainViewModel +import java.util.UUID @OptIn(ExperimentalMaterial3Api::class) @Composable fun SnippetsSettingsUI( viewModel: MainViewModel, modifier: Modifier = Modifier, + isAddSheetVisible: Boolean = false, + onAddSheetVisibleChange: (Boolean) -> Unit = {}, + editingSnippet: Snippet? = null, + onEditingSnippetChange: (Snippet?) -> Unit = {}, ) { val context = LocalContext.current val prefix by viewModel.snippetPrefix @@ -76,13 +85,9 @@ fun SnippetsSettingsUI( } Column( - modifier = - modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - // Missing Accessibility Permission Warning if (!isAccessibilityEnabled) { RoundedCardContainer(spacing = 2.dp) { SelectionCardItem( @@ -94,22 +99,19 @@ fun SnippetsSettingsUI( } } - // Trigger Prefix Hero Card with Wide Action Button StackedActionCard( iconRes = R.drawable.rounded_edit_note_24, title = stringResource(R.string.snippets_trigger_prefix), - description = - if (prefix.isEmpty()) { - stringResource(R.string.snippets_prefix_space_expansion) - } else { - stringResource(R.string.snippets_prefix_symbol_desc, prefix) - }, + description = if (prefix.isEmpty()) { + stringResource(R.string.snippets_prefix_space_expansion) + } else { + stringResource(R.string.snippets_prefix_symbol_desc, prefix) + }, actionLabel = stringResource(R.string.snippets_action_change), actionIconRes = R.drawable.rounded_edit_24, onAction = { showPrefixSheet = true }, ) - // Snippets Section Text( text = stringResource(R.string.snippets_saved_title, snippets.size), style = MaterialTheme.typography.titleMedium, @@ -119,15 +121,17 @@ fun SnippetsSettingsUI( if (snippets.isNotEmpty()) { RoundedCardContainer(spacing = 2.dp) { - snippets.forEach { snippet -> + snippets.forEachIndexed { index, snippet -> val triggerText = if (prefix.isNotEmpty()) "$prefix${snippet.keyword}" else "${snippet.keyword} ␣" - com.sameerasw.essentials.ui.core.cards.SnippetCardItem( + SnippetCardItem( trigger = triggerText, content = snippet.content, title = snippet.title, + isFirst = index == 0, + isLast = index == snippets.lastIndex, onClick = { - viewModel.editingSnippet.value = snippet - viewModel.isAddSnippetSheetVisible.value = true + onEditingSnippetChange(snippet) + onAddSheetVisibleChange(true) }, onDelete = { viewModel.deleteSnippet(snippet.id) }, ) @@ -140,21 +144,19 @@ fun SnippetsSettingsUI( description = stringResource(R.string.snippets_empty_subtitle), iconRes = R.drawable.rounded_add_24, onClick = { - viewModel.editingSnippet.value = null - viewModel.isAddSnippetSheetVisible.value = true + onEditingSnippetChange(null) + onAddSheetVisibleChange(true) }, ) } } } - // Set Prefix Bottom Sheet using EssentialsBottomSheet (Options Only) if (showPrefixSheet) { val options = listOf( Triple(";", stringResource(R.string.snippets_prefix_semicolon), ";shrug"), Triple(":", stringResource(R.string.snippets_prefix_colon), ":shrug"), - Triple("@@", stringResource(R.string.snippets_prefix_at), "@@email"), - Triple("//", stringResource(R.string.snippets_prefix_slash), "//app"), + Triple("@@", stringResource(R.string.snippets_prefix_at), "@@shrug"), Triple("", stringResource(R.string.snippets_prefix_space), "shrug ␣"), ) @@ -176,12 +178,17 @@ fun SnippetsSettingsUI( ) RoundedCardContainer(spacing = 2.dp) { - options.forEach { (sym, label, example) -> + options.forEachIndexed { index, (sym, label, example) -> val isSelected = prefix == sym + val shape = RoundedCornerShape( + topStart = if (index == 0) 24.dp else 4.dp, + topEnd = if (index == 0) 24.dp else 4.dp, + bottomStart = if (index == options.lastIndex) 24.dp else 4.dp, + bottomEnd = if (index == options.lastIndex) 24.dp else 4.dp, + ) Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) + modifier = Modifier.fillMaxWidth() + .clip(shape) .clickable { HapticUtil.performUIHaptic(sheetView) viewModel.setSnippetPrefix(sym) @@ -221,14 +228,14 @@ fun SnippetsSettingsUI( } } - // Add / Edit Snippet Bottom Sheet using EssentialsBottomSheet - if (viewModel.isAddSnippetSheetVisible.value) { + if (isAddSheetVisible) { val closeSheet = { - viewModel.isAddSnippetSheetVisible.value = false - viewModel.editingSnippet.value = null + onAddSheetVisibleChange(false) + onEditingSnippetChange(null) } SnippetBottomSheet( - snippet = viewModel.editingSnippet.value, + snippet = editingSnippet, + existingSnippets = snippets, prefix = prefix, onDismiss = closeSheet, onSave = { updatedSnippet -> @@ -242,7 +249,6 @@ fun SnippetsSettingsUI( ) } - // Missing Permission Bottom Sheet if (showPermissionSheet) { val permissionItems = PermissionUIHelper.getPermissionItems( listOf("ACCESSIBILITY"), @@ -260,10 +266,101 @@ fun SnippetsSettingsUI( } } +@Composable +private fun SnippetCardItem( + trigger: String, + content: String, + title: String?, + isFirst: Boolean, + isLast: Boolean, + onClick: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + val view = LocalView.current + val shape = RoundedCornerShape( + topStart = if (isFirst) 24.dp else 4.dp, + topEnd = if (isFirst) 24.dp else 4.dp, + bottomStart = if (isLast) 24.dp else 4.dp, + bottomEnd = if (isLast) 24.dp else 4.dp, + ) + + ListItem( + modifier = modifier.fillMaxWidth().clip(shape).clickable { + HapticUtil.performUIHaptic(view) + onClick() + }, + headlineContent = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.weight(1f, fill = false) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) { + Text( + text = trigger, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (!title.isNullOrBlank()) { + Spacer(Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + } + }, + supportingContent = { + Text( + text = content, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp), + ) + }, + trailingContent = { + IconButton( + onClick = { + HapticUtil.performUIHaptic(view) + onDelete() + }, + modifier = Modifier.size(36.dp), + ) { + Icon( + painter = painterResource(R.drawable.rounded_delete_24), + contentDescription = stringResource(R.string.action_delete), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.size(20.dp), + ) + } + }, + colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceBright), + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun SnippetBottomSheet( snippet: Snippet?, + existingSnippets: List, prefix: String, onDismiss: () -> Unit, onSave: (Snippet) -> Unit, @@ -273,6 +370,21 @@ fun SnippetBottomSheet( var title by remember { mutableStateOf(snippet?.title ?: "") } var content by remember { mutableStateOf(snippet?.content ?: "") } + val trimmedKeyword = keyword.trim() + val hasWhitespace = keyword.any { it.isWhitespace() } + val containsPrefix = prefix.isNotEmpty() && keyword.contains(prefix) + val isDuplicate = existingSnippets.any { + it.id != (snippet?.id ?: "") && it.keyword.equals(trimmedKeyword, ignoreCase = true) + } + + val errorMessage = when { + hasWhitespace -> stringResource(R.string.snippets_error_whitespace) + containsPrefix -> stringResource(R.string.snippets_error_prefix) + isDuplicate -> stringResource(R.string.snippets_error_duplicate) + else -> null + } + val isSaveEnabled = errorMessage == null && trimmedKeyword.isNotEmpty() && content.isNotBlank() + EssentialsBottomSheet(onDismissRequest = onDismiss) { Column( modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp), @@ -286,9 +398,11 @@ fun SnippetBottomSheet( OutlinedTextField( value = keyword, - onValueChange = { keyword = it.trim().filterNot { c -> c.isWhitespace() } }, + onValueChange = { keyword = it }, label = { Text(stringResource(R.string.snippets_keyword_label)) }, placeholder = { Text(stringResource(R.string.snippets_keyword_placeholder)) }, + isError = errorMessage != null, + supportingText = if (errorMessage != null) { { Text(errorMessage) } } else null, singleLine = true, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), @@ -333,11 +447,18 @@ fun SnippetBottomSheet( Spacer(Modifier.width(8.dp)) Button( onClick = { - if (keyword.isNotBlank() && content.isNotBlank()) { - onSave(Snippet(id = snippet?.id ?: System.currentTimeMillis(), keyword = keyword, title = title.trim(), content = content)) + if (isSaveEnabled) { + onSave( + Snippet( + id = snippet?.id ?: UUID.randomUUID().toString(), + keyword = trimmedKeyword, + title = title.trim(), + content = content, + ), + ) } }, - enabled = keyword.isNotBlank() && content.isNotBlank(), + enabled = isSaveEnabled, ) { Text(stringResource(R.string.action_save)) } diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index 7be328144..8998993ec 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -653,8 +653,6 @@ class MainViewModel : ViewModel() { val isTextSnippetsEnabled = mutableStateOf(false) val snippetPrefix = mutableStateOf(";") val textSnippets = mutableStateOf>(emptyList()) - val isAddSnippetSheetVisible = mutableStateOf(false) - val editingSnippet = mutableStateOf(null) // AirSync Bridge val isAirSyncConnectionEnabled = mutableStateOf(false) @@ -10867,7 +10865,7 @@ class MainViewModel : ViewModel() { } } - fun setTextSnippetsEnabled(enabled: Boolean, context: Context) { + fun setTextSnippetsEnabled(enabled: Boolean) { isTextSnippetsEnabled.value = enabled settingsRepository.setTextSnippetsEnabled(enabled) } @@ -10889,7 +10887,7 @@ class MainViewModel : ViewModel() { settingsRepository.saveTextSnippets(current) } - fun deleteSnippet(id: Long) { + fun deleteSnippet(id: String) { val updated = textSnippets.value.filter { it.id != id } textSnippets.value = updated settingsRepository.saveTextSnippets(updated) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 58f73f256..d804d2565 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -422,18 +422,20 @@ Semicolon (;) Colon (:) Double At (@@) - Double Slash (//) Space (␣) New Snippet Edit Snippet Shortcut Trigger Keyword - e.g., brb, addr, email + e.g., brb, addr, shrug Label (Optional) e.g., Be Right Back Expanded Text Enter replacement text or multi-line snippet Change + Keyword cannot contain spaces + Do not include prefix in keyword + Keyword already in use Show Notification diff --git a/app/src/main/res/xml/accessibility_service_config.xml b/app/src/main/res/xml/accessibility_service_config.xml index 8c7706919..514fb2ab8 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 @@ + replacement = text + } + + assertTrue(matched) assertEquals("Be right back!", replacement) } + @Test + fun testWordBoundaryPreventsUnintendedExpansion() { + val engine = + SnippetExpansionEngine( + getSnippets = { sampleSnippets }, + getPrefix = { ":" }, + isEnabled = { true }, + ) + + var called = false + val matchedTime = + engine.checkAndExpand("Time is 10:email") { _, _ -> + called = true + } + assertFalse(matchedTime) + assertFalse(called) + + val matchedBoundary = + engine.checkAndExpand("Time is :email") { _, text -> + called = true + } + assertTrue(matchedBoundary) + assertTrue(called) + } + + @Test + fun testShortKeywordDoesNotBlockLongerSiblingWithoutDelimiter() { + val engine = + SnippetExpansionEngine( + getSnippets = { sampleSnippets }, + getPrefix = { ";" }, + isEnabled = { true }, + ) + + var expandedText = "" + + val matchedShortWithoutDelimiter = + engine.checkAndExpand("Check ;app") { _, text -> + expandedText = text + } + assertFalse(matchedShortWithoutDelimiter) + + val matchedShortWithDelimiter = + engine.checkAndExpand("Check ;app ") { _, text -> + expandedText = text + } + assertTrue(matchedShortWithDelimiter) + assertEquals("Application ", expandedText) + + val matchedLongerImmediate = + engine.checkAndExpand("Check ;apple") { _, text -> + expandedText = text + } + assertTrue(matchedLongerImmediate) + assertEquals("Fresh Apple", expandedText) + } + @Test fun testExpansionWithDoubleAtPrefix() { val engine = @@ -67,7 +143,7 @@ class SnippetExpansionEngineTest { } assertTrue(matched) - assertEquals(7, deleted) // "@@email".length == 7 + assertEquals(7, deleted) assertEquals("user@test.com", replacement) } @@ -90,7 +166,7 @@ class SnippetExpansionEngineTest { } assertTrue(matched) - assertEquals(5, deleted) // "addr ".length == 5 + assertEquals(5, deleted) assertEquals("123 Main Street ", replacement) }