diff --git a/app/src/main/java/org/futo/voiceinput/RecognizerView.kt b/app/src/main/java/org/futo/voiceinput/RecognizerView.kt index d9509edc..60e5378d 100644 --- a/app/src/main/java/org/futo/voiceinput/RecognizerView.kt +++ b/app/src/main/java/org/futo/voiceinput/RecognizerView.kt @@ -1,5 +1,7 @@ package org.futo.voiceinput +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.media.AudioAttributes import android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION @@ -56,15 +58,25 @@ import androidx.compose.ui.unit.dp import androidx.core.math.MathUtils.clamp import androidx.lifecycle.LifecycleCoroutineScope import com.google.android.material.math.MathUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.futo.voiceinput.ml.RunState +import org.futo.voiceinput.settings.COPY_RESULT_TO_CLIPBOARD import org.futo.voiceinput.settings.ENABLE_ANIMATIONS import org.futo.voiceinput.settings.ENABLE_SOUND +import org.futo.voiceinput.settings.ENABLE_TRANSCRIPTION_HISTORY import org.futo.voiceinput.settings.LANGUAGE_TOGGLES import org.futo.voiceinput.settings.MANUALLY_SELECT_LANGUAGE +import org.futo.voiceinput.settings.TRANSCRIPTION_HISTORY +import org.futo.voiceinput.settings.TRANSCRIPTION_HISTORY_MAX_ENTRIES import org.futo.voiceinput.settings.VERBOSE_PROGRESS import org.futo.voiceinput.settings.getSetting +import org.futo.voiceinput.settings.setSetting import org.futo.voiceinput.settings.useDataStoreValueNullable +import org.json.JSONArray +import org.json.JSONObject import org.futo.voiceinput.theme.Typography fun Modifier.recognizerSurfaceClickable(disabled: Boolean, onPauseVAD: (Boolean) -> Unit, onFinish: () -> Unit): Modifier = composed { @@ -260,12 +272,72 @@ abstract class RecognizerView { private var shouldBeVerbose = VERBOSE_PROGRESS.default private var shouldRequestLanguage = MANUALLY_SELECT_LANGUAGE.default private var languages = LANGUAGE_TOGGLES.default + private var shouldCopyToClipboard = COPY_RESULT_TO_CLIPBOARD.default + private var shouldSaveToHistory = ENABLE_TRANSCRIPTION_HISTORY.default suspend fun loadSettings() { shouldPlaySounds = context.getSetting(ENABLE_SOUND) shouldBeVerbose = context.getSetting(VERBOSE_PROGRESS) shouldRequestLanguage = context.getSetting(MANUALLY_SELECT_LANGUAGE) languages = context.getSetting(LANGUAGE_TOGGLES) + shouldCopyToClipboard = context.getSetting(COPY_RESULT_TO_CLIPBOARD) + shouldSaveToHistory = context.getSetting(ENABLE_TRANSCRIPTION_HISTORY) + } + + /** + * Put the recognized text on the clipboard as a fallback, so that a transcription is never + * lost outright when the commit into the editor does not land. + */ + private fun copyToClipboard(text: String) { + if (text.isBlank()) return + + try { + val clipboard = + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip( + ClipData.newPlainText(context.getString(R.string.app_name), text) + ) + } catch (e: Exception) { + // Never let a clipboard failure take down the far more important sendResult path. + e.printStackTrace() + } + } + + /** + * Keep the last few transcriptions so a dictation that never reaches the text field can be + * copied from the settings app later. The delivery contract has no way to report that a + * keyboard dropped the result, so this is the only loss-proof record of what was said. + */ + private fun saveToHistory(text: String) { + if (text.isBlank()) return + + // NonCancellable: sendResult() may finish the activity right after this is scheduled, + // which cancels lifecycleScope - the write must survive that or the entry is lost. + lifecycleScope.launch { + withContext(NonCancellable + Dispatchers.IO) { + try { + val entries = JSONArray() + entries.put( + JSONObject() + .put("time", System.currentTimeMillis()) + .put("text", text) + ) + val previous = context.getSetting(TRANSCRIPTION_HISTORY) + if (previous.isNotEmpty()) { + val previousEntries = JSONArray(previous) + for (i in 0 until minOf( + previousEntries.length(), + TRANSCRIPTION_HISTORY_MAX_ENTRIES - 1 + )) { + entries.put(previousEntries.getJSONObject(i)) + } + } + context.setSetting(TRANSCRIPTION_HISTORY, entries.toString()) + } catch (e: Exception) { + e.printStackTrace() + } + } + } } private val soundPool = SoundPool.Builder().setMaxStreams(2).setAudioAttributes( @@ -320,6 +392,17 @@ abstract class RecognizerView { } override fun finished(result: String) { + // Do this before sendResult: sendResult may switch the input method or finish the + // activity, and once that happens we are no longer a foreground/active app and + // setPrimaryClip is silently ignored on Android 10+. + if (shouldCopyToClipboard) { + copyToClipboard(result) + } + + if (shouldSaveToHistory) { + saveToHistory(result) + } + val manager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager if(manager.isEnabled) { val event = AccessibilityEvent.obtain(); diff --git a/app/src/main/java/org/futo/voiceinput/settings/Settings.kt b/app/src/main/java/org/futo/voiceinput/settings/Settings.kt index 8a42afcd..07c0cd21 100644 --- a/app/src/main/java/org/futo/voiceinput/settings/Settings.kt +++ b/app/src/main/java/org/futo/voiceinput/settings/Settings.kt @@ -142,6 +142,21 @@ val MANUALLY_SELECT_LANGUAGE = SettingsKey(booleanPreferencesKey("manually_selec val PERSONAL_DICTIONARY = SettingsKey(stringPreferencesKey("personal_dict"), "") +// Safety net for the case where the recognized text never reaches the editor - the keyboard's +// commit can be silently dropped if the editor's InputConnection is no longer active by the time +// the result arrives. With this on, the text is always also on the clipboard so it can be pasted +// by hand instead of being lost. Off by default because Android 13+ shows an unsuppressable +// system "copied" overlay on every clipboard write, which is intrusive on every dictation. +val COPY_RESULT_TO_CLIPBOARD = SettingsKey(booleanPreferencesKey("copy_result_to_clipboard"), false) + +// Silent counterpart to the clipboard fallback: the last few transcriptions are kept on-device +// (newest first, JSON array of {time, text}) and can be copied from the settings app, so a +// dropped dictation is recoverable without a clipboard write - and its system popup - on every +// single result. +val ENABLE_TRANSCRIPTION_HISTORY = SettingsKey(booleanPreferencesKey("enable_transcription_history"), true) +val TRANSCRIPTION_HISTORY = SettingsKey(stringPreferencesKey("transcription_history"), "") +const val TRANSCRIPTION_HISTORY_MAX_ENTRIES = 20 + val THEME_KEY = SettingsKey( key = stringPreferencesKey("activeThemeOption"), default = if(BuildConfig.FLAVOR == "dev" || BuildConfig.FLAVOR == "devSameId") { DevThemeYellow.key } else { VoiceInputTheme.key } diff --git a/app/src/main/java/org/futo/voiceinput/settings/SettingsUtils.kt b/app/src/main/java/org/futo/voiceinput/settings/SettingsUtils.kt index 5428e4b9..828506dc 100644 --- a/app/src/main/java/org/futo/voiceinput/settings/SettingsUtils.kt +++ b/app/src/main/java/org/futo/voiceinput/settings/SettingsUtils.kt @@ -41,6 +41,7 @@ import org.futo.voiceinput.settings.pages.AdvancedScreen import org.futo.voiceinput.settings.pages.CreditsScreen import org.futo.voiceinput.settings.pages.DependenciesScreen import org.futo.voiceinput.settings.pages.HelpScreen +import org.futo.voiceinput.settings.pages.HistoryScreen import org.futo.voiceinput.settings.pages.HomeScreen import org.futo.voiceinput.settings.pages.InputScreen import org.futo.voiceinput.settings.pages.LanguagesScreen @@ -143,6 +144,7 @@ fun SettingsMain( composable("home") { HomeScreen(settingsViewModel, navController) } composable("advanced") { AdvancedScreen(settingsViewModel, navController) } composable("help") { HelpScreen(navController) } + composable("history") { HistoryScreen(settingsViewModel, navController) } composable("languages") { LanguagesScreen(settingsViewModel, navController) } composable("testing") { TestScreen(settingsUiState.intentResultText, navController) } composable("models") { ModelsScreen(settingsViewModel, navController) } diff --git a/app/src/main/java/org/futo/voiceinput/settings/pages/Advanced.kt b/app/src/main/java/org/futo/voiceinput/settings/pages/Advanced.kt index c02dc5d6..b0e21c46 100644 --- a/app/src/main/java/org/futo/voiceinput/settings/pages/Advanced.kt +++ b/app/src/main/java/org/futo/voiceinput/settings/pages/Advanced.kt @@ -11,7 +11,9 @@ import org.futo.voiceinput.MULTILINGUAL_MODELS import org.futo.voiceinput.R import org.futo.voiceinput.settings.ALLOW_UNDERTRAINED_LANGUAGES import org.futo.voiceinput.settings.BEAM_SEARCH +import org.futo.voiceinput.settings.COPY_RESULT_TO_CLIPBOARD import org.futo.voiceinput.settings.DISALLOW_SYMBOLS +import org.futo.voiceinput.settings.ENABLE_TRANSCRIPTION_HISTORY import org.futo.voiceinput.settings.DevOnlySettings import org.futo.voiceinput.settings.ENABLE_30S_LIMIT import org.futo.voiceinput.settings.MULTILINGUAL_MODEL_INDEX @@ -53,6 +55,18 @@ fun AdvancedScreen( SettingToggleDataStore(stringResource(R.string.use_beam_search), BEAM_SEARCH, subtitle = stringResource(R.string.recommended)) + SettingToggleDataStore( + stringResource(R.string.copy_result_to_clipboard), + COPY_RESULT_TO_CLIPBOARD, + subtitle = stringResource(R.string.copy_result_to_clipboard_subtitle) + ) + + SettingToggleDataStore( + stringResource(R.string.enable_transcription_history), + ENABLE_TRANSCRIPTION_HISTORY, + subtitle = stringResource(R.string.enable_transcription_history_subtitle) + ) + SettingToggleDataStore( stringResource(R.string.allow_undertrained_languages), ALLOW_UNDERTRAINED_LANGUAGES, diff --git a/app/src/main/java/org/futo/voiceinput/settings/pages/History.kt b/app/src/main/java/org/futo/voiceinput/settings/pages/History.kt new file mode 100644 index 00000000..092347ad --- /dev/null +++ b/app/src/main/java/org/futo/voiceinput/settings/pages/History.kt @@ -0,0 +1,127 @@ +package org.futo.voiceinput.settings.pages + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.text.format.DateUtils +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import org.futo.voiceinput.R +import org.futo.voiceinput.settings.NavigationItem +import org.futo.voiceinput.settings.NavigationItemStyle +import org.futo.voiceinput.settings.ScreenTitle +import org.futo.voiceinput.settings.ScrollableList +import org.futo.voiceinput.settings.SettingsViewModel +import org.futo.voiceinput.settings.TRANSCRIPTION_HISTORY +import org.futo.voiceinput.settings.useDataStore +import org.futo.voiceinput.theme.Typography +import org.json.JSONArray + +private data class HistoryEntry(val time: Long, val text: String) + +private fun parseHistory(json: String): List { + if (json.isEmpty()) return emptyList() + + return try { + val array = JSONArray(json) + (0 until array.length()).mapNotNull { i -> + val entry = array.optJSONObject(i) ?: return@mapNotNull null + val text = entry.optString("text") + if (text.isEmpty()) null else HistoryEntry(entry.optLong("time"), text) + } + } catch (e: Exception) { + emptyList() + } +} + +@Composable +private fun HistoryRow(entry: HistoryEntry, onCopy: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onCopy) + .padding(16.dp, 10.dp) + ) { + Text(entry.text, style = Typography.bodyMedium) + if (entry.time > 0) { + Text( + DateUtils.getRelativeTimeSpanString(entry.time).toString(), + style = Typography.labelSmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + } + } +} + +@Composable +@Preview +fun HistoryScreen( + settingsViewModel: SettingsViewModel = viewModel(), + navController: NavHostController = rememberNavController() +) { + val context = LocalContext.current + val (historyJson, setHistoryJson) = useDataStore(TRANSCRIPTION_HISTORY) + val entries = remember(historyJson) { parseHistory(historyJson) } + + ScrollableList { + ScreenTitle( + title = stringResource(R.string.transcription_history), + showBack = true, + navController = navController + ) + + if (entries.isEmpty()) { + Text( + stringResource(R.string.transcription_history_empty), + style = Typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) + } else { + Text( + stringResource(R.string.transcription_history_hint), + style = Typography.labelSmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp, 4.dp) + ) + + entries.forEach { entry -> + HistoryRow(entry) { + val clipboard = + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip( + ClipData.newPlainText(context.getString(R.string.app_name), entry.text) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + NavigationItem( + title = stringResource(R.string.clear_transcription_history), + style = NavigationItemStyle.Misc, + navigate = { setHistoryJson("") } + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + } +} diff --git a/app/src/main/java/org/futo/voiceinput/settings/pages/Home.kt b/app/src/main/java/org/futo/voiceinput/settings/pages/Home.kt index 4f98c4e9..16cc5203 100644 --- a/app/src/main/java/org/futo/voiceinput/settings/pages/Home.kt +++ b/app/src/main/java/org/futo/voiceinput/settings/pages/Home.kt @@ -157,6 +157,14 @@ fun HomeScreen( icon = painterResource(R.drawable.edit) ) + NavigationItem( + title = stringResource(R.string.transcription_history), + subtitle = stringResource(R.string.transcription_history_home_subtitle), + style = NavigationItemStyle.Misc, + navigate = { navController.navigate("history") }, + icon = painterResource(R.drawable.edit) + ) + UnpaidNoticeCondition(showOnlyIfReminder = true) { NavigationItem( title = stringResource(R.string.payment), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 01671ca8..8cd30596 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -199,6 +199,8 @@ Check permissions Use beam search Recommended + Copy result to clipboard + Every transcription is also placed on the clipboard, so it can be pasted by hand if it does not reach the text field. Note that this overwrites whatever you last copied. Models have been updated for better accuracy and faster performance. You can now also add words/phrases in the personal dictionary. If you have any issues or feedback after this change please contact us. John Doe, Jane Smith, TensorFlow, bobble, type anything you want voice input to recognize more often here! Personal Dictionary @@ -214,4 +216,12 @@ Commitment to Privacy This app will never serve you ads or sell your data. We are not in the business of doing that. + Keep transcription history + Saves your last 20 transcriptions on this device, so a dictation that never reaches the text field can be copied from the Transcription history screen. No popups. + Transcription history + Recover a dictation that did not get inserted + Tap a transcription to copy it to the clipboard. + No transcriptions yet. After you dictate, your most recent transcriptions appear here so a lost one can be copied. + Clear history + \ No newline at end of file