Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions android/app/src/main/java/com/vocahq/vocaphone/core/Snippet.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.vocahq.vocaphone.core

import org.json.JSONArray
import org.json.JSONObject

/** A user-defined trigger phrase and the literal text it expands into. */
data class Snippet(
val id: String,
val trigger: String,
val expansion: String,
) {
private fun toJson(): JSONObject = JSONObject().apply {
put("id", id)
put("trigger", trigger)
put("expansion", expansion)
}

companion object {
fun encode(snippets: List<Snippet>): String =
JSONArray(snippets.map { it.toJson() }).toString()

// Corrupt or unreadable storage becomes no snippets rather than a
// crash on every dictation and every settings screen open.
fun decode(stored: String?): List<Snippet> {
if (stored.isNullOrBlank()) return emptyList()
return runCatching {
val array = JSONArray(stored)
List(array.length()) { index ->
val entry = array.getJSONObject(index)
Snippet(
id = entry.getString("id"),
trigger = entry.getString("trigger"),
expansion = entry.getString("expansion"),
)
}
}.getOrDefault(emptyList())
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.vocahq.vocaphone.core

import java.util.regex.Pattern

/**
* Expands snippet triggers into their literal expansion text.
*
* Runs after [DictatedTranscript.finished], never before: the writing style
* capitalizes sentence starts and would otherwise rewrite a snippet's literal
* expansion (an email trigger must not come out "Me@example.com"). Matching
* itself is case-insensitive, so capitalizing the source text first does not
* break it.
*
* One combined regex over every trigger rather than a snippet-by-snippet
* loop, so an expansion that happens to contain another trigger is never
* re-expanded. Matches are applied in reverse order so an earlier match's
* range stays valid while a later one is replaced.
*/
object SnippetExpander {

fun expand(text: String, snippets: List<Snippet>): String {
val active = snippets
.filter { it.trigger.isNotBlank() }
// Longer, more specific triggers win over a shorter one that could
// be a substring of it ("my email" before "email").
.sortedByDescending { it.trigger.length }
if (active.isEmpty()) return text

val pattern = Pattern.compile(
active.joinToString("|") { "(${boundaryPattern(it.trigger)})" },
Pattern.CASE_INSENSITIVE or Pattern.UNICODE_CASE or Pattern.UNICODE_CHARACTER_CLASS,
)
val matcher = pattern.matcher(text)
val matches = mutableListOf<Triple<Int, Int, Int>>()
while (matcher.find()) {
val groupIndex = (1..active.size).first { matcher.group(it) != null } - 1
matches += Triple(matcher.start(), matcher.end(), groupIndex)
}
if (matches.isEmpty()) return text

val result = StringBuilder(text)
for ((start, end, index) in matches.asReversed()) {
result.replace(start, end, active[index].expansion)
}
return result.toString()
}

/**
* `\b` on a side whose edge character is a word character; otherwise a
* non-whitespace lookaround, so a punctuation-only trigger like "->" still
* matches at the start or end of the string, where `\b` would not fire.
*/
private fun boundaryPattern(trigger: String): String {
val prefix = if (trigger.first().isWordChar()) "\\b" else "(?<!\\S)"
val suffix = if (trigger.last().isWordChar()) "\\b" else "(?!\\S)"
return prefix + Pattern.quote(trigger) + suffix
}

private fun Char.isWordChar(): Boolean = isLetterOrDigit() || this == '_'
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import com.vocahq.vocaphone.core.DictationState
import com.vocahq.vocaphone.core.DictationTone
import com.vocahq.vocaphone.core.MissingPermission
import com.vocahq.vocaphone.core.ModelLanguageSupport
import com.vocahq.vocaphone.core.SnippetExpander
import com.vocahq.vocaphone.data.HistoryRepository
import com.vocahq.vocaphone.data.DiagnosticLog
import com.vocahq.vocaphone.gateway.GatewayClient
Expand Down Expand Up @@ -905,6 +906,10 @@ class DictationController(
configuration: VocaPhoneSettings,
source: DictationSource,
) {
// After formatting, never before: the writing style's capitalization
// must not rewrite a snippet's literal expansion text, and trigger
// matching is case-insensitive so this order does not break it.
val transcript = SnippetExpander.expand(transcript, configuration.snippets)
diagnostics.recordTiming("transcript_ready", source.name)
// Reported here rather than after insertion: the transcript exists and
// is correct at this point, and whether the keyboard managed to commit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ import com.vocahq.vocaphone.core.DictationTone
import com.vocahq.vocaphone.core.MicrophonePreference
import com.vocahq.vocaphone.core.ModelLanguageSupport
import com.vocahq.vocaphone.core.ModelTranslationSupport
import com.vocahq.vocaphone.core.Snippet
import com.vocahq.vocaphone.local.LocalModelCatalog
import com.vocahq.vocaphone.local.LocalModelDescriptor
import com.vocahq.vocaphone.core.TranscriptionLanguage
import com.vocahq.vocaphone.core.TranscriptionQuality
import com.vocahq.vocaphone.core.WritingStyle
import com.vocahq.vocaphone.security.TokenVault
import java.util.UUID
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
Expand Down Expand Up @@ -264,6 +266,8 @@ data class VocaPhoneSettings(
* again on every trip through guided setup.
*/
val telemetryAsked: Boolean = false,
/** Trigger phrases dictated text is expanded against, after formatting. */
val snippets: List<Snippet> = emptyList(),
) {

/**
Expand Down Expand Up @@ -485,6 +489,35 @@ class SettingsRepository(private val context: Context) {
}
}

suspend fun addSnippet(trigger: String, expansion: String) {
val cleaned = trigger.trim()
if (cleaned.isEmpty()) return
context.dataStore.edit { preferences ->
val current = Snippet.decode(preferences[Keys.SNIPPETS])
val added = Snippet(id = UUID.randomUUID().toString(), trigger = cleaned, expansion = expansion)
preferences[Keys.SNIPPETS] = Snippet.encode(current + added)
}
}

suspend fun updateSnippet(id: String, trigger: String, expansion: String) {
val cleaned = trigger.trim()
if (cleaned.isEmpty()) return
context.dataStore.edit { preferences ->
val current = Snippet.decode(preferences[Keys.SNIPPETS])
val next = current.map {
if (it.id == id) it.copy(trigger = cleaned, expansion = expansion) else it
}
preferences[Keys.SNIPPETS] = Snippet.encode(next)
}
}

suspend fun deleteSnippet(id: String) {
context.dataStore.edit { preferences ->
val current = Snippet.decode(preferences[Keys.SNIPPETS])
preferences[Keys.SNIPPETS] = Snippet.encode(current.filter { it.id != id })
}
}

suspend fun setTelemetryEnabled(enabled: Boolean) = put(Keys.TELEMETRY_ENABLED, enabled)

suspend fun setTelemetryAsked(asked: Boolean) = put(Keys.TELEMETRY_ASKED, asked)
Expand Down Expand Up @@ -596,6 +629,7 @@ class SettingsRepository(private val context: Context) {
telemetryEnabled = this[Keys.TELEMETRY_ENABLED]
?: com.vocahq.vocaphone.telemetry.TelemetryConfig.DEFAULT_ENABLED,
telemetryAsked = this[Keys.TELEMETRY_ASKED] ?: false,
snippets = Snippet.decode(this[Keys.SNIPPETS]),
)

private object Keys {
Expand Down Expand Up @@ -640,6 +674,7 @@ class SettingsRepository(private val context: Context) {
val TELEMETRY_ENABLED = booleanPreferencesKey("telemetry_enabled")
val TELEMETRY_ASKED = booleanPreferencesKey("telemetry_asked")
val TELEMETRY_MILESTONES = stringSetPreferencesKey("telemetry_milestones")
val SNIPPETS = stringPreferencesKey("snippets")
val LAST_REPORTED_EXIT_AT = longPreferencesKey("last_reported_process_exit_at")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,9 @@ fun VocaPhoneApp(
onNumberKeyHints = { viewModel.setNumberKeyHintsEnabled(it) },
onLongPressSymbols = { viewModel.setLongPressSymbolsEnabled(it) },
onPersonalDictionary = { viewModel.setPersonalDictionary(it) },
onAddSnippet = { trigger, expansion -> viewModel.addSnippet(trigger, expansion) },
onUpdateSnippet = { id, trigger, expansion -> viewModel.updateSnippet(id, trigger, expansion) },
onDeleteSnippet = { viewModel.deleteSnippet(it) },
onAsciiEmoji = { viewModel.setAsciiEmojiEnabled(it) },
onSwipeTyping = { viewModel.setSwipeTypingEnabled(it) },
onClipboardChip = { viewModel.setClipboardChipEnabled(it) },
Expand Down
Loading
Loading