Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
83 changes: 83 additions & 0 deletions app/src/main/java/org/futo/voiceinput/RecognizerView.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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();
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/java/org/futo/voiceinput/settings/Settings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) }
Expand Down
14 changes: 14 additions & 0 deletions app/src/main/java/org/futo/voiceinput/settings/pages/Advanced.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
127 changes: 127 additions & 0 deletions app/src/main/java/org/futo/voiceinput/settings/pages/History.kt
Original file line number Diff line number Diff line change
@@ -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<HistoryEntry> {
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))
}
}
8 changes: 8 additions & 0 deletions app/src/main/java/org/futo/voiceinput/settings/pages/Home.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@
<string name="check_permissions">Check permissions</string>
<string name="use_beam_search">Use beam search</string>
<string name="recommended">Recommended</string>
<string name="copy_result_to_clipboard">Copy result to clipboard</string>
<string name="copy_result_to_clipboard_subtitle">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.</string>
<string name="new_model_features_tip">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.</string>
<string name="personal_dictionary_placeholder">John Doe, Jane Smith, TensorFlow, bobble, type anything you want voice input to recognize more often here!</string>
<string name="personal_dictionary">Personal Dictionary</string>
Expand All @@ -214,4 +216,12 @@
<string name="commitment_to_privacy_title">Commitment to Privacy</string>
<string name="commitment_to_privacy_body">This app will never serve you ads or sell your data. We are not in the business of doing that.</string>

<string name="enable_transcription_history">Keep transcription history</string>
<string name="enable_transcription_history_subtitle">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.</string>
<string name="transcription_history">Transcription history</string>
<string name="transcription_history_home_subtitle">Recover a dictation that did not get inserted</string>
<string name="transcription_history_hint">Tap a transcription to copy it to the clipboard.</string>
<string name="transcription_history_empty">No transcriptions yet. After you dictate, your most recent transcriptions appear here so a lost one can be copied.</string>
<string name="clear_transcription_history">Clear history</string>

</resources>