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 6a00da2050..ad3f3abe1c 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,7 +36,10 @@ import com.sameerasw.essentials.domain.model.NotificationLightingSweepPosition import com.sameerasw.essentials.domain.model.PowerChoice import com.sameerasw.essentials.domain.model.RemapSlot import com.sameerasw.essentials.domain.model.ScaleAnimationsProfile +import com.sameerasw.essentials.domain.model.Snippet import com.sameerasw.essentials.domain.model.SystemShortcutsState +import com.sameerasw.essentials.services.tiles.ScreenOffAccessibilityService +import java.util.UUID import com.sameerasw.essentials.domain.model.TrackedRepo import com.sameerasw.essentials.domain.model.github.GitHubUser import com.sameerasw.essentials.utils.RootUtils @@ -198,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" @@ -403,6 +412,9 @@ class SettingsRepository( const val KEY_KEYBOARD_CLIPBOARD_ENABLED = "keyboard_clipboard_enabled" const val KEY_KEYBOARD_LONG_PRESS_SYMBOLS = "keyboard_long_press_symbols" const val KEY_KEYBOARD_ACCENTED_CHARACTERS = "keyboard_accented_characters" + const val KEY_KEYBOARD_TEXT_SNIPPETS_ENABLED = "keyboard_text_snippets_enabled" + const val KEY_KEYBOARD_SNIPPET_PREFIX = "keyboard_snippet_prefix" + const val KEY_KEYBOARD_SNIPPETS_DATA = "keyboard_snippets_data" // Essentials-AirSync Bridge const val KEY_AIRSYNC_CONNECTION_ENABLED = "airsync_connection_enabled" @@ -4883,4 +4895,46 @@ 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) + ScreenOffAccessibilityService.updateSnippetTextListening(enabled) + } + + fun getSnippetPrefix(): String { + cachedPrefix?.let { return it } + val prefix = getString(KEY_KEYBOARD_SNIPPET_PREFIX, ";") ?: ";" + cachedPrefix = prefix + return prefix + } + + fun setSnippetPrefix(prefix: String) { + cachedPrefix = prefix + putString(KEY_KEYBOARD_SNIPPET_PREFIX, prefix) + } + + fun getTextSnippets(): List { + 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 = 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 new file mode 100644 index 0000000000..1325c2080f --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/Snippet.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Domain Model Layer + * File: Snippet.kt + * Description: Data class representing an expandable text snippet with trigger keyword. + */ + +package com.sameerasw.essentials.domain.model + +import java.util.UUID + +data class Snippet( + val id: String = UUID.randomUUID().toString(), + val keyword: String, + val title: String = "", + val content: String, +) 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 4200c59d60..2fbe163500 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/ime/EssentialsInputMethodService.kt b/app/src/main/java/com/sameerasw/essentials/ime/EssentialsInputMethodService.kt index 6240cf8863..05c11228dd 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()) { @@ -618,6 +641,7 @@ class EssentialsInputMethodService : restarting: Boolean, ) { super.onStartInputView(info, restarting) + isInputActive = true // Ensure lifecycle is in RESUMED state when view becomes visible if (lifecycleRegistry.currentState != Lifecycle.State.RESUMED) { lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) @@ -628,12 +652,14 @@ class EssentialsInputMethodService : override fun onFinishInputView(finishingInput: Boolean) { super.onFinishInputView(finishingInput) + isInputActive = false lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) kbdResetTrigger.value++ } override fun onDestroy() { super.onDestroy() + isInputActive = false lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) if (::clipboardManager.isInitialized) { @@ -758,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 new file mode 100644 index 0000000000..99abaa2a2e --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ime/SnippetExpansionEngine.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Ime + * File: SnippetExpansionEngine.kt + * Description: Engine for detecting snippet prefixes/keywords and expanding text. + */ + +package com.sameerasw.essentials.ime + +import com.sameerasw.essentials.domain.model.Snippet + +class SnippetExpansionEngine( + private val getSnippets: () -> List, + private val getPrefix: () -> String, + private val isEnabled: () -> Boolean, +) { + fun checkAndExpand( + textBeforeCursor: String, + onExpand: (deleteCount: Int, replacement: String) -> Unit, + ): Boolean { + if (!isEnabled() || textBeforeCursor.isEmpty()) return false + val prefix = getPrefix() + val snippets = getSnippets() + if (snippets.isEmpty()) return false + + val sorted = snippets.sortedByDescending { it.keyword.length } + + for (snippet in sorted) { + if (snippet.keyword.isEmpty()) continue + + if (prefix.isNotEmpty()) { + val trigger = "$prefix${snippet.keyword}" + val hasDelimiter = textBeforeCursor.endsWith(" ") || textBeforeCursor.endsWith("\t") + val candidate = if (hasDelimiter) textBeforeCursor.dropLast(1) else textBeforeCursor + + if (candidate.endsWith(trigger, ignoreCase = true)) { + val triggerStart = candidate.length - trigger.length + val isBoundary = triggerStart == 0 || candidate[triggerStart - 1].isWhitespace() + if (!isBoundary) continue + + if (!hasDelimiter) { + val hasLongerSibling = sorted.any { + it.keyword.length > snippet.keyword.length && + it.keyword.startsWith(snippet.keyword, ignoreCase = true) + } + if (hasLongerSibling) continue + } + + val matchLen = if (hasDelimiter) trigger.length + 1 else trigger.length + val replacement = if (hasDelimiter) snippet.content + " " else snippet.content + onExpand(matchLen, replacement) + return true + } + } else { + val lastChar = textBeforeCursor.last() + if (lastChar.isWhitespace()) { + val trimmed = textBeforeCursor.dropLast(1) + if (trimmed.endsWith(snippet.keyword, ignoreCase = true)) { + val beforeIndex = trimmed.length - snippet.keyword.length - 1 + val isBoundary = beforeIndex < 0 || trimmed[beforeIndex].isWhitespace() + if (isBoundary) { + onExpand(snippet.keyword.length + 1, snippet.content + lastChar) + return true + } + } + } + } + } + return false + } +} 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 0000000000..259a14c6d5 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/SnippetExpansionHandler.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Background Services & Receivers + * File: SnippetExpansionHandler.kt + * Description: Accessibility event handler for expanding text snippets across any input method or app. + */ + +package com.sameerasw.essentials.services.handlers + +import android.accessibilityservice.AccessibilityService +import android.os.Build +import android.os.Bundle +import android.text.InputType +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.ime.EssentialsInputMethodService +import com.sameerasw.essentials.ime.SnippetExpansionEngine + +class SnippetExpansionHandler( + private val service: AccessibilityService, +) { + private val settings = SettingsRepository(service) + @Volatile private var expectedSelfText: String? = null + + private val snippetEngine = + SnippetExpansionEngine( + getSnippets = { settings.getTextSnippets() }, + getPrefix = { settings.getSnippetPrefix() }, + isEnabled = { settings.isTextSnippetsEnabled() }, + ) + + fun onTextChanged(event: AccessibilityEvent) { + if (!settings.isTextSnippetsEnabled()) return + if (EssentialsInputMethodService.isInputActive) return + if (event.isPassword) return + + val source = event.source ?: return + if (source.isPassword || !source.isEditable) return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && source.isShowingHintText) return + + val inputType = source.inputType + val isPasswordType = + (inputType and InputType.TYPE_MASK_VARIATION) == InputType.TYPE_TEXT_VARIATION_PASSWORD || + (inputType and InputType.TYPE_MASK_VARIATION) == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD || + (inputType and InputType.TYPE_MASK_VARIATION) == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD || + ((inputType and InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_NUMBER && + (inputType and InputType.TYPE_MASK_VARIATION) == InputType.TYPE_NUMBER_VARIATION_PASSWORD) + if (isPasswordType) return + + val text = source.text?.toString() ?: return + if (text.isEmpty()) return + + if (expectedSelfText != null) { + if (text == expectedSelfText) { + expectedSelfText = null + return + } + } + + var cursor = source.textSelectionEnd + if (cursor < 0 || cursor > text.length) { + cursor = text.length + } + + val textBeforeCursor = text.substring(0, cursor) + + snippetEngine.checkAndExpand(textBeforeCursor) { deleteCount, replacement -> + val start = cursor - deleteCount + if (start < 0) return@checkAndExpand + + val newText = text.substring(0, start) + replacement + text.substring(cursor) + val newCursor = start + replacement.length + + val supportsSetText = source.actionList.any { it.id == AccessibilityNodeInfo.ACTION_SET_TEXT } + if (!supportsSetText) return@checkAndExpand + + expectedSelfText = newText + val args = + Bundle().apply { + putCharSequence( + AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, + newText, + ) + } + val setSuccess = source.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args) + if (setSuccess) { + val selArgs = + Bundle().apply { + putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, newCursor) + putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, newCursor) + } + source.performAction(AccessibilityNodeInfo.ACTION_SET_SELECTION, selArgs) + } else { + expectedSelfText = null + } + } + } +} 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 fff4dc1024..3ca13e864e 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,9 @@ 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.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 @@ -94,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 @@ -102,7 +105,8 @@ class ScreenOffAccessibilityService : private lateinit var screenshotAutoCleanHandler: ScreenshotAutoCleanHandler private lateinit var lockscreenShortcutsHandler: LockscreenShortcutsHandler private lateinit var appVolumeOverlayHandler: AppVolumeOverlayHandler - private lateinit var statusBarSectionsDebugHandler: com.sameerasw.essentials.services.handlers.StatusBarSectionsDebugHandler + private lateinit var snippetExpansionHandler: SnippetExpansionHandler + private lateinit var statusBarSectionsDebugHandler: StatusBarSectionsDebugHandler private var lightSensor: Sensor? = null private var lightSensorLux: Float = 100f @@ -391,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 ) { @@ -429,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 = { @@ -440,11 +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) flashlightHandler.register() statusBarIconHandler.register() @@ -621,6 +626,9 @@ class ScreenOffAccessibilityService : isA11yConnected = true serviceInfo = serviceInfo.apply { + 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 @@ -669,6 +677,7 @@ class ScreenOffAccessibilityService : } override fun onDestroy() { + if (instance == this) instance = null Shizuku.removeBinderReceivedListener(shizukuBinderListener) try { unregisterReceiver(screenReceiver) @@ -746,6 +755,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 ) { @@ -1245,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 298f654149..effd011389 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 @@ -118,6 +119,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 @@ -270,6 +272,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 @@ -430,6 +434,7 @@ fun FeatureSettingsScreen( "Shut-Up!" -> !isWriteSecureSettingsEnabled || !viewModel.isUsageStatsPermissionGranted.value "Power and Battery" -> !isWriteSecureSettingsEnabled + "Text Snippets" -> !isAccessibilityEnabled "Networks" -> !isWriteSecureSettingsEnabled && !com.sameerasw.essentials.utils.ShellUtils.hasPermission( @@ -1238,6 +1243,17 @@ fun FeatureSettingsScreen( ) } + "Text Snippets" -> { + SnippetsSettingsUI( + viewModel = viewModel, + modifier = Modifier.padding(top = 16.dp), + isAddSheetVisible = isAddSnippetSheetVisible, + onAddSheetVisibleChange = { isAddSnippetSheetVisible = it }, + editingSnippet = editingSnippet, + onEditingSnippetChange = { editingSnippet = it }, + ) + } + "Batteries" -> { BatteriesSettingsUI( viewModel = viewModel, @@ -1545,6 +1561,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 = @@ -1555,12 +1572,19 @@ fun FeatureSettingsScreen( viewModel.triggerIslandPreview(context) } } + featureId == "Text Snippets" -> { + { + editingSnippet = null + isAddSnippetSheetVisible = 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/ui/features/system/KeyboardSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/system/KeyboardSettingsUI.kt index 3a487d360e..74fff8b806 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 @@ -38,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 @@ -59,6 +64,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 +349,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) + } + }, + modifier = Modifier.highlight(highlightSetting == "keyboard_text_snippets"), + ) + + if (viewModel.isTextSnippetsEnabled.value) { + 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, FeatureSettingsActivity::class.java).apply { + putExtra("feature", "Text Snippets") + } + context.startActivity(intent) + }, + ) + } + } + Spacer(Modifier.height(32.dp)) + 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, + ) + } + } + 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 0000000000..4adf2fbe30 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/system/SnippetsSettingsUI.kt @@ -0,0 +1,468 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: UI Feature - System + * File: SnippetsSettingsUI.kt + * Description: UI screen for managing text expansion snippets and prefix settings. + */ + +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 +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.ListItem +import androidx.compose.material3.ListItemDefaults +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.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.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 +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 + 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), + ) { + 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 }, + ) + } + } + + 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 }, + ) + + 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.forEachIndexed { index, snippet -> + val triggerText = if (prefix.isNotEmpty()) "$prefix${snippet.keyword}" else "${snippet.keyword} ␣" + SnippetCardItem( + trigger = triggerText, + content = snippet.content, + title = snippet.title, + isFirst = index == 0, + isLast = index == snippets.lastIndex, + onClick = { + onEditingSnippetChange(snippet) + onAddSheetVisibleChange(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 = { + onEditingSnippetChange(null) + onAddSheetVisibleChange(true) + }, + ) + } + } + } + + 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), "@@shrug"), + Triple("", stringResource(R.string.snippets_prefix_space), "shrug ␣"), + ) + + 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.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(shape) + .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)) + } + } + } + + if (isAddSheetVisible) { + val closeSheet = { + onAddSheetVisibleChange(false) + onEditingSnippetChange(null) + } + SnippetBottomSheet( + snippet = editingSnippet, + existingSnippets = snippets, + prefix = prefix, + onDismiss = closeSheet, + onSave = { updatedSnippet -> + viewModel.saveSnippet(updatedSnippet) + closeSheet() + }, + onDelete = { snippetToDelete -> + viewModel.deleteSnippet(snippetToDelete.id) + closeSheet() + }, + ) + } + + 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, + ) + } + } +} + +@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, + 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 ?: "") } + + 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), + 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 }, + 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), + ) + + 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 (isSaveEnabled) { + onSave( + Snippet( + id = snippet?.id ?: UUID.randomUUID().toString(), + keyword = trimmedKeyword, + title = title.trim(), + content = content, + ), + ) + } + }, + 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 effdbcc1f8..38b2a122fd 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -73,6 +73,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,9 @@ 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()) // AirSync Bridge val isAirSyncConnectionEnabled = mutableStateOf(false) @@ -1248,6 +1252,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() @@ -2041,6 +2052,7 @@ class MainViewModel : ViewModel() { settingsRepository.getLockScreenClockSelectedColorId() lockScreenClockSeedColor.intValue = settingsRepository.getLockScreenClockSeedColor() loadShutUpConfigs() + loadTextSnippets() recentSearches.value = settingsRepository.getRecentSearches() loadCachedWallpaper() isDailyWallpaperAutoUpdateEnabled.value = @@ -10833,6 +10845,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) { + 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: String) { + 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 364900a05d..bc416eaf70 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -405,6 +405,37 @@ 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 (@@) + Space (␣) + New Snippet + Edit Snippet + Shortcut Trigger + Keyword + 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/test/java/com/sameerasw/essentials/ime/SnippetExpansionEngineTest.kt b/app/src/test/java/com/sameerasw/essentials/ime/SnippetExpansionEngineTest.kt new file mode 100644 index 0000000000..682bc4ade3 --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/ime/SnippetExpansionEngineTest.kt @@ -0,0 +1,210 @@ +/* + * 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 = "1", keyword = "brb", title = "Be Right Back", content = "Be right back!"), + Snippet(id = "2", keyword = "addr", title = "Address", content = "123 Main Street"), + Snippet(id = "3", keyword = "email", title = "Email", content = "user@test.com"), + Snippet(id = "4", keyword = "app", title = "App", content = "Application"), + Snippet(id = "5", keyword = "apple", title = "Apple", content = "Fresh Apple"), + ) + + @Test + fun testExpansionWithSemicolonPrefix() { + val engine = + SnippetExpansionEngine( + getSnippets = { sampleSnippets }, + getPrefix = { ";" }, + isEnabled = { true }, + ) + + var deleted = 0 + var replacement = "" + + val matched = + engine.checkAndExpand("Hello ;brb") { deleteCount, text -> + deleted = deleteCount + replacement = text + } + + assertTrue(matched) + assertEquals(4, deleted) + assertEquals("Be right back!", replacement) + } + + @Test + fun testCaseInsensitiveExpansion() { + val engine = + SnippetExpansionEngine( + getSnippets = { sampleSnippets }, + getPrefix = { ";" }, + isEnabled = { true }, + ) + + var replacement = "" + val matched = + engine.checkAndExpand("Hello ;Brb") { _, text -> + 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 = + 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) + 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) + 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) + } +}