diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d9b8137..58141b8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -32,6 +32,10 @@ + + @@ -43,5 +47,29 @@ android:name=".meeting.MeetingService" android:exported="false" android:foregroundServiceType="microphone" /> + + + + + + + + diff --git a/android/app/src/main/kotlin/com/pathors/parley/MainActivity.kt b/android/app/src/main/kotlin/com/pathors/parley/MainActivity.kt index a1d8f1a..a5eb027 100644 --- a/android/app/src/main/kotlin/com/pathors/parley/MainActivity.kt +++ b/android/app/src/main/kotlin/com/pathors/parley/MainActivity.kt @@ -9,6 +9,7 @@ import com.pathors.parley.auth.AuthCallback import com.pathors.parley.screenshot.DemoMode import com.pathors.parley.ui.ParleyRoot import com.pathors.parley.ui.theme.ParleyTheme +import com.pathors.parley.voicetyping.VoiceTypingSetup import kotlinx.coroutines.launch /** @@ -45,6 +46,11 @@ class MainActivity : ComponentActivity() { // Screenshot demo first: it claims only `parley://demo/…` in debug builds // and hands everything else straight on to the sign-in handler. if (DemoMode.handle(uri)) return + // The voice keyboard's hand-off. It cannot request RECORD_AUDIO itself, so + // it sends the user here; the navigation graph picks the request up (see + // VoiceTypingSetup.SetupRequest) once it is mounted, which may be after + // the sign-in wall comes down. + if (VoiceTypingSetup.SetupRequest.handle(uri)) return val container = parleyContainer lifecycleScope.launch { when (val result = container.auth.handleAuthCallback(uri)) { diff --git a/android/app/src/main/kotlin/com/pathors/parley/screenshot/DemoMode.kt b/android/app/src/main/kotlin/com/pathors/parley/screenshot/DemoMode.kt index b47e68b..d821da8 100644 --- a/android/app/src/main/kotlin/com/pathors/parley/screenshot/DemoMode.kt +++ b/android/app/src/main/kotlin/com/pathors/parley/screenshot/DemoMode.kt @@ -532,6 +532,33 @@ object DemoMode { endMs = 112_000, ) + // ── dictation fixture ──────────────────────────────────────────────────── + + /** + * A dictated sentence, in the small pieces a speech recognizer actually + * delivers — the fixture [com.pathors.parley.voicetyping.VoiceTypingSession] + * plays back instead of opening a microphone while demo mode is on. + * + * The Android half of iOS `ScreenshotDemo.dictationScript`, and it exists for + * the same two reasons: the keyboard has to be capturable for the store, and + * an emulator has no microphone input at all — so this is the only way to + * exercise the real commit path (segments → composing/committed text → + * `InputConnection`) on a machine with no mic and no account. Only the + * *source* is faked; every piece downstream of it is production code. + */ + fun dictationScript(locale: Locale = Locale.getDefault()): List = + if (isChinese(locale)) { + listOf( + "跟財務", "確認過了,", "修訂報價", "今天下午", "就能寄出,", + "含鎖價", "條款。", + ) + } else { + listOf( + "Confirmed ", "with finance ", "— the revised ", "quote goes ", + "out this ", "afternoon, ", "price hold ", "included.", + ) + } + private const val SCHEME = "parley" private const val HOST = "demo" private const val ROUTE_OFF = "off" diff --git a/android/app/src/main/kotlin/com/pathors/parley/ui/AccountSheet.kt b/android/app/src/main/kotlin/com/pathors/parley/ui/AccountSheet.kt index 87a3740..04175c7 100644 --- a/android/app/src/main/kotlin/com/pathors/parley/ui/AccountSheet.kt +++ b/android/app/src/main/kotlin/com/pathors/parley/ui/AccountSheet.kt @@ -40,7 +40,11 @@ import com.pathors.parley.cloud.HostedQuota */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun AccountSheet(viewModel: HomeViewModel, onDismiss: () -> Unit) { +fun AccountSheet( + viewModel: HomeViewModel, + onDismiss: () -> Unit, + onSetUpVoiceTyping: () -> Unit, +) { val account by viewModel.account.collectAsState() val sheetState = rememberModalBottomSheetState() var confirmingDelete by remember { mutableStateOf(false) } @@ -80,6 +84,17 @@ fun AccountSheet(viewModel: HomeViewModel, onDismiss: () -> Unit) { HorizontalDivider(Modifier.padding(vertical = 8.dp)) + // Voice typing has no home of its own in the library, and this is the + // app's only settings-shaped surface. The steps themselves live on + // their own screen because one of them — granting the microphone — + // needs an Activity the keyboard does not have; see + // ui/VoiceTypingSetupScreen.kt. + TextButton(onClick = onSetUpVoiceTyping) { + Text(stringResource(R.string.account_voice_typing)) + } + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + TextButton( onClick = { viewModel.signOut() diff --git a/android/app/src/main/kotlin/com/pathors/parley/ui/HomeScreen.kt b/android/app/src/main/kotlin/com/pathors/parley/ui/HomeScreen.kt index 4ee9d4b..49067e5 100644 --- a/android/app/src/main/kotlin/com/pathors/parley/ui/HomeScreen.kt +++ b/android/app/src/main/kotlin/com/pathors/parley/ui/HomeScreen.kt @@ -66,6 +66,7 @@ fun HomeScreen( onRecord: () -> Unit, onImport: () -> Unit, onOpenRecording: (String) -> Unit, + onSetUpVoiceTyping: () -> Unit, ) { val container = rememberContainer() val viewModel: HomeViewModel = viewModel(factory = HomeViewModel.factory(container)) @@ -161,6 +162,10 @@ fun HomeScreen( AccountSheet( viewModel = viewModel, onDismiss = { showAccount = false }, + onSetUpVoiceTyping = { + showAccount = false + onSetUpVoiceTyping() + }, ) } } diff --git a/android/app/src/main/kotlin/com/pathors/parley/ui/ParleyRoot.kt b/android/app/src/main/kotlin/com/pathors/parley/ui/ParleyRoot.kt index 661166f..c7ba285 100644 --- a/android/app/src/main/kotlin/com/pathors/parley/ui/ParleyRoot.kt +++ b/android/app/src/main/kotlin/com/pathors/parley/ui/ParleyRoot.kt @@ -25,19 +25,21 @@ import androidx.navigation.compose.rememberNavController import com.pathors.parley.AppContainer import com.pathors.parley.R import com.pathors.parley.screenshot.DemoMode +import com.pathors.parley.voicetyping.VoiceTypingSetup -/** The four places the app can be. */ +/** The five places the app can be. */ private object Route { const val HOME = "home" const val MEETING = "meeting" const val IMPORT = "import" const val RECORDING = "recording/{id}" + const val VOICE_TYPING = "voice-typing" fun recording(id: String) = "recording/$id" } /** - * The whole UI: a sign-in wall in front of a four-screen navigation graph. + * The whole UI: a sign-in wall in front of a five-screen navigation graph. * * The wall is driven by whether a token is *stored*, not by whether the cloud * answered — being offline must never look like being signed out (see @@ -71,6 +73,7 @@ private fun ParleyNavHost(container: AppContainer) { val context = LocalContext.current DemoNavigation(navController) + VoiceTypingHandOff(navController) // The Storage Access Framework: no storage permission, any provider (Files, // Drive, a recorder app), and the grant lives as long as we need the Uri. @@ -95,8 +98,12 @@ private fun ParleyNavHost(container: AppContainer) { onRecord = { navController.navigate(Route.MEETING) }, onImport = { picker.launch(arrayOf("audio/*")) }, onOpenRecording = { id -> navController.navigate(Route.recording(id)) }, + onSetUpVoiceTyping = { navController.navigate(Route.VOICE_TYPING) }, ) } + composable(Route.VOICE_TYPING) { + VoiceTypingSetupScreen(onBack = { navController.popBackStack() }) + } composable(Route.MEETING) { MeetingScreen(onDone = { navController.popBackStack(Route.HOME, inclusive = false) }) } @@ -134,6 +141,27 @@ private fun DemoNavigation(navController: NavHostController) { } } +/** + * Lands the keyboard's hand-off (`parley://voice-typing`) on the setup screen. + * + * The keyboard cannot request the microphone permission itself, so its setup + * affordance opens the app instead — and it has to arrive *somewhere useful*, not + * on the library. The request is a latch rather than an event precisely because + * the common case is a signed-out user: the graph does not exist while the + * sign-in wall is up, so the navigation happens once this composable finally + * mounts. See `voicetyping/VoiceTypingSetup.kt`. + */ +@Composable +private fun VoiceTypingHandOff(navController: NavHostController) { + val pending by VoiceTypingSetup.SetupRequest.pending.collectAsState() + LaunchedEffect(pending) { + if (!pending) return@LaunchedEffect + VoiceTypingSetup.SetupRequest.consume() + navController.popBackStack(Route.HOME, inclusive = false) + navController.navigate(Route.VOICE_TYPING) + } +} + /** The picked file's own name, which becomes the recording's title. */ private fun displayNameOf(context: Context, uri: Uri): String { val fromProvider = runCatching { diff --git a/android/app/src/main/kotlin/com/pathors/parley/ui/VoiceTypingSetupScreen.kt b/android/app/src/main/kotlin/com/pathors/parley/ui/VoiceTypingSetupScreen.kt new file mode 100644 index 0000000..a2a9c60 --- /dev/null +++ b/android/app/src/main/kotlin/com/pathors/parley/ui/VoiceTypingSetupScreen.kt @@ -0,0 +1,257 @@ +package com.pathors.parley.ui + +import android.Manifest +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.pathors.parley.R +import com.pathors.parley.voicetyping.VoiceTypingSetup + +/** + * Voice-typing onboarding, and the landing pad for the keyboard's hand-off. + * + * It carries the three things only this side of the process can do: + * + * 1. **Enable the keyboard** — a jump to the system's on-screen keyboard list. + * No API can flip that switch for the user; iOS is in the same position, and + * `ios/App/Parley/SettingsView.swift` solves it the same way (one line on what + * the feature is, one button to the place with the toggle, then the steps). + * 2. **Switch to it** — the keyboard picker. + * 3. **Grant the microphone** — the reason this screen has to exist rather than + * being a paragraph in the account sheet. An `InputMethodService` cannot + * request a runtime permission (no Activity to host it), so the keyboard sends + * the user here and *here* is where the system dialog can appear. See + * `voicetyping/VoiceTypingSetup.kt`. + * + * The steps show their own state rather than instructions alone: each one is + * ticked once it is actually done, re-checked every time the screen resumes, + * because all three are changed *outside* the app. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VoiceTypingSetupScreen(onBack: () -> Unit) { + val context = LocalContext.current + + var micGranted by remember { mutableStateOf(VoiceTypingSetup.hasMicPermission(context)) } + var micDenied by remember { mutableStateOf(false) } + var imeEnabled by remember { mutableStateOf(VoiceTypingSetup.isImeEnabled(context)) } + var imeSelected by remember { mutableStateOf(VoiceTypingSetup.isImeSelected(context)) } + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + micGranted = granted + micDenied = !granted + } + + // Every one of these three is toggled in system UI — the keyboard list, the + // picker, the permission dialog — so the only reliable moment to re-read them + // is when this screen comes back to the foreground. + val activity = context as? ComponentActivity + DisposableEffect(activity) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + micGranted = VoiceTypingSetup.hasMicPermission(context) + imeEnabled = VoiceTypingSetup.isImeEnabled(context) + imeSelected = VoiceTypingSetup.isImeSelected(context) + } + } + activity?.lifecycle?.addObserver(observer) + onDispose { activity?.lifecycle?.removeObserver(observer) } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.voice_typing_setup_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + stringResource(R.string.action_back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.voice_typing_setup_headline), + style = MaterialTheme.typography.headlineSmall, + ) + Text( + text = stringResource(R.string.voice_typing_setup_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + SetupStep( + number = 1, + done = imeEnabled, + title = stringResource(R.string.voice_typing_setup_step_enable_title), + body = stringResource(R.string.voice_typing_setup_step_enable_body), + action = stringResource(R.string.voice_typing_setup_step_enable_action), + onAction = { VoiceTypingSetup.openSystemImeSettings(context) }, + ) + SetupStep( + number = 2, + done = imeSelected, + title = stringResource(R.string.voice_typing_setup_step_switch_title), + body = stringResource(R.string.voice_typing_setup_step_switch_body), + action = stringResource(R.string.voice_typing_setup_step_switch_action), + onAction = { VoiceTypingSetup.showImePicker(context) }, + ) + SetupStep( + number = 3, + done = micGranted, + title = stringResource(R.string.voice_typing_setup_step_mic_title), + body = stringResource(R.string.voice_typing_setup_step_mic_body), + action = stringResource(R.string.voice_typing_setup_step_mic_action), + onAction = { permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) }, + ) { + // A denial the user cannot take back from a dialog any more: the + // only remaining route is the app's own settings page. + if (micDenied && !micGranted) { + Text( + text = stringResource(R.string.voice_typing_setup_mic_denied), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + TextButton(onClick = { VoiceTypingSetup.openAppSettings(context) }) { + Text(stringResource(R.string.voice_typing_setup_app_settings)) + } + } + } + + if (micGranted && imeEnabled) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = MaterialTheme.shapes.medium, + ) { + Text( + text = stringResource(R.string.voice_typing_setup_ready), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp), + ) + } + } + } + } +} + +/** One numbered step: a tick once it is done, and the jump that gets it done. */ +@Composable +private fun SetupStep( + number: Int, + done: Boolean, + title: String, + body: String, + action: String, + onAction: () -> Unit, + extra: @Composable () -> Unit = {}, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (done) { + Surface( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Check, + contentDescription = stringResource( + R.string.voice_typing_setup_step_done + ), + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(4.dp), + ) + } + } else { + Surface( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape, + modifier = Modifier.size(24.dp), + ) { + Text( + text = number.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(top = 3.dp), + fontWeight = FontWeight.Bold, + ) + } + } + Text(text = title, style = MaterialTheme.typography.titleSmall) + } + Text( + text = body, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (!done) { + FilledTonalButton(onClick = onAction) { Text(action) } + } + extra() + } + } +} diff --git a/android/app/src/main/kotlin/com/pathors/parley/voicetyping/DictationTextAssembler.kt b/android/app/src/main/kotlin/com/pathors/parley/voicetyping/DictationTextAssembler.kt new file mode 100644 index 0000000..052d638 --- /dev/null +++ b/android/app/src/main/kotlin/com/pathors/parley/voicetyping/DictationTextAssembler.kt @@ -0,0 +1,61 @@ +package com.pathors.parley.voicetyping + +import com.pathors.parley.kit.TranscriptSegment + +/** The live transcript, split the way [TranscriptCommitter] needs it. */ +data class DictationText(val settled: String = "", val tail: String = "") + +/** + * Flattens the relay's diarized segment stream into plain dictation text. + * + * Two rules, both inherited from + * [SegmentBuilder][com.pathors.parley.kit.SegmentBuilder]: + * + * * a segment whose id ends in `-tail` is the **tentative tail** — one value, + * replaced wholesale on every frame; + * * every other segment is a **settled run**, upserted by id (a run is re-emitted + * under the same id as it grows) and joined in arrival order. + * + * Dictation does not care who spoke, so speaker labels are dropped — the same + * flattening iOS `DictationCoordinator` does with its `runs` array, extracted here + * so it can be tested against real protocol frames without a socket or a + * microphone. + * + * Not thread-safe: feed it from one thread (the relay's single reader). + */ +class DictationTextAssembler { + private val runs = LinkedHashMap() + private var tail = "" + + /** Current text. */ + var text = DictationText() + private set + + /** Fold one segment in and return the updated text. */ + fun accept(segment: TranscriptSegment): DictationText { + if (segment.id.endsWith(TAIL_SUFFIX)) { + tail = segment.text + } else { + runs[segment.id] = segment.text + } + text = DictationText(settled = runs.values.joinToString(""), tail = tail) + return text + } + + /** + * End of session: the tentative tail becomes settled text, so nothing said + * just before the stop is lost. `DictationCoordinator.finishUp`'s contract. + */ + fun foldTail(): DictationText { + if (tail.isNotEmpty()) { + text = DictationText(settled = text.settled + tail, tail = "") + tail = "" + } + return text + } + + private companion object { + /** `SegmentBuilder` names the tentative tail `{source}-tail`. */ + const val TAIL_SUFFIX = "-tail" + } +} diff --git a/android/app/src/main/kotlin/com/pathors/parley/voicetyping/KeyboardPalette.kt b/android/app/src/main/kotlin/com/pathors/parley/voicetyping/KeyboardPalette.kt new file mode 100644 index 0000000..6cb08d9 --- /dev/null +++ b/android/app/src/main/kotlin/com/pathors/parley/voicetyping/KeyboardPalette.kt @@ -0,0 +1,90 @@ +package com.pathors.parley.voicetyping + +import android.content.Context +import android.content.res.ColorStateList +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.RippleDrawable +import android.os.Build +import androidx.core.content.ContextCompat +import com.pathors.parley.R + +/** + * The keyboard's colors, and the two backgrounds it draws with. + * + * Why not `ui/theme/Theme.kt`? Because the keyboard is classic Views (see + * [ParleyInputMethodService]'s header for that decision), so a Compose + * `ColorScheme` is not in scope. This is the same palette by other means: the + * static values are Theme.kt's `primary`/`secondary` seeds with stock Material 3 + * neutrals (`values/colors_keyboard.xml` + `values-night/`), and on API 31+ the + * accent is swapped for the platform's dynamic color — the same thing + * `dynamicLightColorScheme()`/`dynamicDarkColorScheme()` does for the app, so a + * wallpaper-tinted app gets a wallpaper-tinted keyboard. + */ +class KeyboardPalette private constructor( + val background: Int, + val onBackground: Int, + val muted: Int, + val accent: Int, + val onAccent: Int, + val key: Int, + val onKey: Int, + val error: Int, +) { + /** A rounded key face with a ripple, for the bottom row. */ + fun keyBackground(cornerRadiusPx: Float): Drawable = ripple(key, cornerRadiusPx) + + /** The accent-filled call to action shown when setup is incomplete. */ + fun accentBackground(cornerRadiusPx: Float): Drawable = ripple(accent, cornerRadiusPx) + + private fun ripple(fill: Int, cornerRadiusPx: Float): Drawable { + val face = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = cornerRadiusPx + setColor(fill) + } + val mask = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = cornerRadiusPx + setColor(Color.WHITE) + } + // A translucent neutral reads correctly on both a light and a dark face, + // which is what lets one ripple color serve the whole palette. + return RippleDrawable(ColorStateList.valueOf(RIPPLE), face, mask) + } + + companion object { + private const val RIPPLE = 0x33808080 + + fun of(context: Context): KeyboardPalette { + val dark = (context.resources.configuration.uiMode and + Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + val color = { id: Int -> ContextCompat.getColor(context, id) } + var accent = color(R.color.keyboard_accent) + var onAccent = color(R.color.keyboard_on_accent) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // The same tonal slots Material 3 maps primary/onPrimary to. + accent = color( + if (dark) android.R.color.system_accent1_200 + else android.R.color.system_accent1_600 + ) + onAccent = color( + if (dark) android.R.color.system_accent1_800 + else android.R.color.system_accent1_0 + ) + } + return KeyboardPalette( + background = color(R.color.keyboard_background), + onBackground = color(R.color.keyboard_on_background), + muted = color(R.color.keyboard_muted), + accent = accent, + onAccent = onAccent, + key = color(R.color.keyboard_key), + onKey = color(R.color.keyboard_on_key), + error = color(R.color.keyboard_error), + ) + } + } +} diff --git a/android/app/src/main/kotlin/com/pathors/parley/voicetyping/MicButton.kt b/android/app/src/main/kotlin/com/pathors/parley/voicetyping/MicButton.kt new file mode 100644 index 0000000..0411130 --- /dev/null +++ b/android/app/src/main/kotlin/com/pathors/parley/voicetyping/MicButton.kt @@ -0,0 +1,125 @@ +package com.pathors.parley.voicetyping + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View +import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils +import com.pathors.parley.R +import kotlin.math.min + +/** + * The keyboard's mic toggle: a filled circle, a mic/stop glyph, and — while + * listening — a ring that breathes with the input level. + * + * A custom [View] rather than an `ImageButton` because of that ring. The level is + * the only honest answer to "is it hearing me": Android hands a muted app + * *silence* rather than an error when another app has taken the microphone + * (see [com.pathors.parley.audio.MicCapture]'s error policy), so a keyboard with + * no level indicator cannot tell "quiet room" from "your mic was stolen". + * Redrawing one circle per audio chunk is also cheaper than animating a view + * hierarchy inside a process that is hosting somebody else's text field. + */ +class MicButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, +) : View(context, attrs) { + + private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val ringPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + private val micIcon: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_kb_mic) + private val stopIcon: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_kb_stop) + + var palette: KeyboardPalette = KeyboardPalette.of(context) + set(value) { + field = value + invalidate() + } + + /** Listening — the button offers "stop" and the level ring is live. */ + var listening: Boolean = false + set(value) { + if (field == value) return + field = value + invalidate() + } + + /** + * Greyed out: the mic is not available to us (setup incomplete, or the + * session is still finishing). Still tappable — the tap is what starts the + * hand-off — so this is deliberately not [setEnabled], which would also + * make the view unclickable and inaudible to TalkBack. + */ + var muted: Boolean = false + set(value) { + if (field == value) return + field = value + invalidate() + } + + /** Input level, 0..1. Cheap to set on every audio chunk. */ + var level: Float = 0f + set(value) { + val clamped = value.coerceIn(0f, 1f) + // Ignore changes too small to see; the mic publishes ~10 per second. + if (kotlin.math.abs(clamped - field) < LEVEL_EPSILON) return + field = clamped + if (listening) invalidate() + } + + init { + isClickable = true + isFocusable = true + } + + override fun onDraw(canvas: Canvas) { + val cx = width / 2f + val cy = height / 2f + val outer = min(width, height) / 2f + val face = outer * FACE_FRACTION + + if (listening) { + // Speech RMS lives near the bottom of the range, so scale it up + // before mapping to the ring — otherwise normal speaking barely + // moves it. + val reach = (level * LEVEL_GAIN).coerceIn(0f, 1f) + ringPaint.color = ColorUtils.setAlphaComponent(palette.accent, RING_ALPHA) + ringPaint.strokeWidth = outer * RING_STROKE_FRACTION + val radius = face + (outer - face) * reach + canvas.drawCircle(cx, cy, radius - ringPaint.strokeWidth / 2f, ringPaint) + } + + val base = if (muted) palette.key else palette.accent + fillPaint.color = if (isPressed) { + ColorUtils.blendARGB(base, palette.onBackground, PRESS_BLEND) + } else { + base + } + canvas.drawCircle(cx, cy, face, fillPaint) + + val icon = (if (listening) stopIcon else micIcon) ?: return + val half = (face * ICON_FRACTION).toInt() + icon.setTint(if (muted) palette.onKey else palette.onAccent) + icon.setBounds(cx.toInt() - half, cy.toInt() - half, cx.toInt() + half, cy.toInt() + half) + icon.draw(canvas) + } + + override fun setPressed(pressed: Boolean) { + super.setPressed(pressed) + invalidate() + } + + private companion object { + /** Circle radius as a fraction of the view's half-size. */ + const val FACE_FRACTION = 0.66f + const val ICON_FRACTION = 0.58f + const val RING_STROKE_FRACTION = 0.10f + const val RING_ALPHA = 110 + const val LEVEL_GAIN = 4f + const val LEVEL_EPSILON = 0.01f + const val PRESS_BLEND = 0.16f + } +} diff --git a/android/app/src/main/kotlin/com/pathors/parley/voicetyping/ParleyInputMethodService.kt b/android/app/src/main/kotlin/com/pathors/parley/voicetyping/ParleyInputMethodService.kt new file mode 100644 index 0000000..4cb220e --- /dev/null +++ b/android/app/src/main/kotlin/com/pathors/parley/voicetyping/ParleyInputMethodService.kt @@ -0,0 +1,509 @@ +package com.pathors.parley.voicetyping + +import android.content.res.Configuration +import android.inputmethodservice.InputMethodService +import android.os.Handler +import android.os.Looper +import android.util.TypedValue +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.ImageButton +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.pathors.parley.BuildConfig +import com.pathors.parley.R +import com.pathors.parley.auth.AuthManager +import com.pathors.parley.parleyContainer +import com.pathors.parley.screenshot.DemoMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * Parley's dictation keyboard: an [InputMethodService] that streams the + * microphone to the hosted STT relay and types the result into whatever app the + * user is in. + * + * ## How this differs from iOS, and why + * + * On iOS a keyboard extension is *forbidden* from opening the microphone, so + * `ios/Keyboard/` hands every dictation off to the container app, which records + * and passes text back through an App Group. Android has no such rule: an IME + * runs in its own app's process and may hold `RECORD_AUDIO`, so this keyboard + * records **itself** — mic, relay and `InputConnection` all in one process, with + * no channel, no session ids and no app switch per dictation. + * + * The one thing an IME cannot do is **request a runtime permission**: there is no + * Activity to host the request. So the hand-off survives in a much smaller form — + * a one-time trip to the app to grant the mic (and to sign in, if there is no + * cloud session for the relay to authenticate with). Everything about that trip + * lives in [VoiceTypingSetup], including why getting it wrong is the classic way + * dictation keyboards die silently. + * + * ## Why classic Views and not Compose + * + * The app is otherwise entirely Compose, and this file is the deliberate + * exception: + * + * * An `InputMethodService` is not a `LifecycleOwner`, `ViewModelStoreOwner` or + * `SavedStateRegistryOwner`, so `ComposeView` here needs hand-rolled owner + * plumbing attached to the input view before it will compose at all — extra + * moving parts in a surface the user cannot escape from if it breaks, since a + * keyboard that fails to draw leaves them unable to type. + * * The surface is five controls. Compose buys nothing at this size, and this + * layout has no state that outlives a keystroke. + * * An IME is loaded into the input pipeline of *every* app on the device. A + * plain `LinearLayout` inflates in microseconds and adds no runtime to a + * process we are a guest in. + * + * Material 3 still applies — the palette is the app's own scheme, including + * dynamic color on API 31+ (see [KeyboardPalette]). + * + * ## The commit rule + * + * Provisional text goes in as **composing text** and settled text is + * **committed**; the delta bookkeeping that keeps the user from seeing a word + * twice is [TranscriptCommitter], which is pure and unit-tested. This service + * only supplies it with an [DictationEditor] backed by `currentInputConnection`. + */ +class ParleyInputMethodService : InputMethodService() { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val handler = Handler(Looper.getMainLooper()) + + private lateinit var auth: AuthManager + + /** + * Whether a cloud session token exists. Null until the first DataStore read + * lands — treated as "not yet known", which only ever makes the keyboard + * offer the setup route a moment early. + */ + private var signedIn: Boolean? = null + + private var session: VoiceTypingSession? = null + private var sessionJobs: Job? = null + private var lastState: VoiceTypingState = VoiceTypingState.Idle + private var remainingSeconds: Long = VoiceTypingSession.MAX_SESSION_SECONDS + + /** The layout's own bottom padding, before the navigation-bar inset. */ + private var basePaddingBottom = 0 + + private var palette: KeyboardPalette? = null + private var stateLine: TextView? = null + private var setupAction: TextView? = null + private var micButton: MicButton? = null + + /** + * The editor seam. It resolves `currentInputConnection` per call on purpose: + * the connection is replaced whenever the user moves between fields, and a + * cached one would type into a text box that is no longer there. + */ + private val editor = object : DictationEditor { + override fun commitText(text: String) { + currentInputConnection?.commitText(text, 1) + } + + override fun setComposingText(text: String) { + currentInputConnection?.setComposingText(text, 1) + } + + override fun finishComposing() { + currentInputConnection?.finishComposingText() + } + } + + private val committer = TranscriptCommitter(editor) + + override fun onCreate() { + super.onCreate() + auth = parleyContainer.auth + // The relay needs a bearer token, so "signed out" is a first-class state + // of this keyboard rather than a failure to discover on the first tap. + scope.launch { + auth.isSignedIn.collect { value -> + signedIn = value + render() + } + } + } + + // ---------------------------------------------------------------- the view + + override fun onCreateInputView(): View { + val root = LayoutInflater.from(this).inflate(R.layout.keyboard_voice, null) + stateLine = root.findViewById(R.id.keyboard_state) + setupAction = root.findViewById(R.id.keyboard_setup_action) + micButton = root.findViewById(R.id.keyboard_mic).apply { + contentDescription = getString(R.string.voice_typing_mic_start) + setOnClickListener { onMicTap() } + } + + setupAction?.setOnClickListener { VoiceTypingSetup.openSetupInApp(this) } + + bindKey( + root.findViewById(R.id.keyboard_switch_ime), + R.drawable.ic_kb_language, + R.string.voice_typing_switch_keyboard, + ) { VoiceTypingSetup.showImePicker(this) } + bindKey( + root.findViewById(R.id.keyboard_backspace), + R.drawable.ic_kb_backspace, + R.string.voice_typing_backspace, + ) { backspace() } + bindKey( + root.findViewById(R.id.keyboard_enter), + R.drawable.ic_kb_return, + R.string.voice_typing_enter, + ) { enter() } + armBackspaceRepeat(root.findViewById(R.id.keyboard_backspace)) + + insetForNavigationBar(root) + applyPalette(root) + render() + return root + } + + /** + * Keep the bottom row clear of the system navigation bar. + * + * From API 35 an IME window is drawn edge-to-edge, so it extends behind the + * navigation bar — and the framework draws its *own* controls down there, the + * hide-keyboard chevron and the input-method switcher. Without this padding + * our globe and return keys are laid out underneath those: clipped, and two + * targets on the same pixels. + * + * Applied from a listener *and* on every [onStartInputView], because a + * listener registered on a detached view is not guaranteed a dispatch before + * the keyboard is first shown — which is exactly what left the row clipped + * after a configuration change recreated the input view. + */ + private fun insetForNavigationBar(root: View) { + basePaddingBottom = root.paddingBottom + ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets -> + applyBottomInset(view, insets) + insets + } + } + + private fun applyBottomInset(root: View, insets: WindowInsetsCompat?) { + val resolved = insets ?: ViewCompat.getRootWindowInsets(root) ?: return + val bottom = resolved.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom + val target = basePaddingBottom + bottom + if (root.paddingBottom == target) return + root.setPadding(root.paddingLeft, root.paddingTop, root.paddingRight, target) + } + + private fun bindKey(button: ImageButton, icon: Int, description: Int, action: () -> Unit) { + button.setImageDrawable(ContextCompat.getDrawable(this, icon)) + button.contentDescription = getString(description) + button.setOnClickListener { action() } + } + + /** + * Hold-to-repeat on backspace. It is the only way to delete in this keyboard, + * so one character per tap would make fixing a mis-heard sentence painful. + * + * The touch listener returns false so the ordinary click and long-click paths + * keep working; it exists only to notice the finger lifting. + */ + private fun armBackspaceRepeat(button: ImageButton) { + val repeat = object : Runnable { + override fun run() { + backspace() + handler.postDelayed(this, BACKSPACE_REPEAT_MS) + } + } + button.setOnLongClickListener { + handler.postDelayed(repeat, BACKSPACE_REPEAT_MS) + true + } + button.setOnTouchListener { _, event -> + if (event.actionMasked == MotionEvent.ACTION_UP || + event.actionMasked == MotionEvent.ACTION_CANCEL + ) { + handler.removeCallbacks(repeat) + } + false + } + } + + private fun applyPalette(root: View) { + val colors = KeyboardPalette.of(this) + palette = colors + root.setBackgroundColor(colors.background) + val radius = dp(14f) + stateLine?.setTextColor(colors.muted) + setupAction?.apply { + setTextColor(colors.onAccent) + background = colors.accentBackground(radius) + } + micButton?.palette = colors + for (id in intArrayOf( + R.id.keyboard_switch_ime, + R.id.keyboard_backspace, + R.id.keyboard_enter, + )) { + root.findViewById(id).apply { + background = colors.keyBackground(radius) + setColorFilter(colors.onKey) + } + } + } + + private fun dp(value: Float): Float = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value, resources.displayMetrics) + + /** + * Never take over the whole screen. Extract mode would hide the very field + * the user is dictating into, and a dictation keyboard has nothing to gain + * from the extra room. + */ + override fun onEvaluateFullscreenMode(): Boolean = false + + // ----------------------------------------------------------- input plumbing + + override fun onStartInput(info: EditorInfo?, restarting: Boolean) { + super.onStartInput(info, restarting) + // A new field: any composing text belonged to the old one. + if (!restarting) committer.reset() + } + + override fun onStartInputView(info: EditorInfo?, restarting: Boolean) { + super.onStartInputView(info, restarting) + // Re-read the palette on every appearance so a night-mode flip lands even + // if the framework reused the input view. + micButton?.rootView?.let { root -> + applyPalette(root) + applyBottomInset(root, null) + } + render() + } + + /** + * The keyboard is going away. Stop the microphone — a dictation must never + * outlive the surface that shows it is running, and the mic is the one + * resource here the user would not forgive us for holding. + */ + override fun onFinishInputView(finishingInput: Boolean) { + super.onFinishInputView(finishingInput) + endSession() + } + + override fun onFinishInput() { + super.onFinishInput() + // The connection is gone; forget the high-water mark without touching it. + committer.reset() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + micButton?.let { applyPalette(it.rootView) } + } + + override fun onDestroy() { + session?.dispose() + session = null + handler.removeCallbacksAndMessages(null) + scope.cancel() + super.onDestroy() + } + + // ------------------------------------------------------------------- keys + + private fun backspace() { + // Settle the composing region first: deleting *into* provisional text + // would leave the committer's bookkeeping describing something the field + // no longer contains. + committer.finish() + sendDownUpKeyEvents(KeyEvent.KEYCODE_DEL) + } + + /** + * Return. Unlike iOS — where a keyboard extension has no way to fire the + * host's return action and `ios/Keyboard/` therefore always types a line + * break — Android exposes [android.view.inputmethod.InputConnection.performEditorAction], + * so a field asking for Send/Go/Search gets exactly that. Anything else types + * a newline. + */ + private fun enter() { + committer.finish() + val info = currentInputEditorInfo + val action = info?.imeOptions?.and(EditorInfo.IME_MASK_ACTION) ?: EditorInfo.IME_ACTION_NONE + val suppressed = (info?.imeOptions?.and(EditorInfo.IME_FLAG_NO_ENTER_ACTION) ?: 0) != 0 + if (!suppressed && + action != EditorInfo.IME_ACTION_NONE && + action != EditorInfo.IME_ACTION_UNSPECIFIED + ) { + currentInputConnection?.performEditorAction(action) + } else { + currentInputConnection?.commitText("\n", 1) + } + } + + // --------------------------------------------------------------- dictation + + /** + * Whether this keyboard can dictate on its own right now. + * + * ScreenshotDemo stands both conditions down in debug builds, the same way + * it stands down the sign-in wall in `ParleyRoot` — it serves a scripted + * transcript and never touches the microphone, an account or the network. + */ + private val ready: Boolean + get() = (BuildConfig.DEBUG && DemoMode.isActive) || + (signedIn == true && VoiceTypingSetup.hasMicPermission(this)) + + private fun onMicTap() { + val current = session + if (current != null && current.state.value is VoiceTypingState.Listening) { + scope.launch { current.stop() } + return + } + if (!ready) { + // The whole point of the hand-off: an IME cannot request the + // permission, and it cannot sign in either. + VoiceTypingSetup.openSetupInApp(this) + return + } + startSession() + } + + private fun startSession() { + endSession() + committer.reset() + val fresh = VoiceTypingSession(this, auth) + session = fresh + sessionJobs = scope.launch { + launch { + // The only place text reaches the editor. Conflation is safe: + // `settled` is cumulative and `tail` is absolute, so skipping an + // intermediate value can never lose or duplicate a word. + fresh.text.collect { committer.update(it.settled, it.tail) } + } + launch { fresh.level.collect { micButton?.level = it } } + launch { + fresh.elapsedMs.collect { elapsed -> + val left = VoiceTypingSession.MAX_SESSION_SECONDS - elapsed / 1_000L + if (left != remainingSeconds) { + remainingSeconds = left + if (left <= COUNTDOWN_FROM_SECONDS) render() + } + } + } + launch { + fresh.state.collect { state -> + lastState = state + if (state is VoiceTypingState.Done || state is VoiceTypingState.Failed) { + // Whatever was still provisional is the last thing the + // user said; keep it. Safe in either order relative to + // the final text update — see TranscriptCommitter. + committer.finish() + } + render() + } + } + } + fresh.start() + } + + /** Stop the mic and drop the session, without waiting for a flush. */ + private fun endSession() { + sessionJobs?.cancel() + sessionJobs = null + session?.dispose() + session = null + committer.finish() + micButton?.level = 0f + remainingSeconds = VoiceTypingSession.MAX_SESSION_SECONDS + lastState = VoiceTypingState.Idle + } + + // ------------------------------------------------------------------ render + + /** + * One place that decides what the keyboard looks like, from `lastState`, + * `signedIn` and the mic grant. Called from every input that can change any + * of those, so there is no partial-update path to get wrong. + */ + private fun render() { + val mic = micButton ?: return + val state = lastState + val listening = state is VoiceTypingState.Listening + val finishing = state is VoiceTypingState.Finishing || + state is VoiceTypingState.Connecting + + mic.listening = listening + mic.muted = finishing || !ready + mic.contentDescription = getString( + if (listening) R.string.voice_typing_mic_stop else R.string.voice_typing_mic_start + ) + + val needsSetup = !ready + setupAction?.visibility = if (needsSetup) View.VISIBLE else View.GONE + setupAction?.text = getString( + if (signedIn == false) R.string.voice_typing_open_sign_in + else R.string.voice_typing_open_app + ) + + stateLine?.text = stateText(state, needsSetup) + stateLine?.setTextColor( + if (state is VoiceTypingState.Failed) { + palette?.error ?: 0 + } else { + palette?.muted ?: 0 + } + ) + } + + private fun stateText(state: VoiceTypingState, needsSetup: Boolean): String = when { + // Setup copy wins over everything: it is the only message with an action + // attached, and a stale error above it would just be noise. + needsSetup && signedIn == false -> getString(R.string.voice_typing_needs_sign_in) + needsSetup -> getString(R.string.voice_typing_needs_mic) + state is VoiceTypingState.Listening -> + if (remainingSeconds <= COUNTDOWN_FROM_SECONDS) { + getString( + R.string.voice_typing_listening_countdown, + remainingSeconds.coerceAtLeast(0), + ) + } else { + getString(R.string.voice_typing_listening) + } + + state is VoiceTypingState.Connecting -> getString(R.string.voice_typing_connecting) + state is VoiceTypingState.Finishing -> getString(R.string.voice_typing_transcribing) + state is VoiceTypingState.Done -> + if (state.reachedLimit) { + getString(R.string.voice_typing_limit_reached) + } else { + getString(R.string.voice_typing_idle) + } + + state is VoiceTypingState.Failed -> getString(failureMessage(state.reason)) + else -> getString(R.string.voice_typing_idle) + } + + private fun failureMessage(reason: VoiceTypingFailure): Int = when (reason) { + VoiceTypingFailure.NOT_SIGNED_IN -> R.string.voice_typing_needs_sign_in + VoiceTypingFailure.MIC_PERMISSION -> R.string.voice_typing_needs_mic + VoiceTypingFailure.MIC_UNAVAILABLE -> R.string.voice_typing_error_mic + VoiceTypingFailure.CONNECTION -> R.string.voice_typing_error_connection + VoiceTypingFailure.QUOTA_EXCEEDED -> R.string.voice_typing_error_quota + VoiceTypingFailure.RELAY_ERROR -> R.string.voice_typing_error_relay + } + + private companion object { + const val BACKSPACE_REPEAT_MS = 55L + + /** Only show the cap as a countdown once it is close enough to matter. */ + const val COUNTDOWN_FROM_SECONDS = 30L + } +} diff --git a/android/app/src/main/kotlin/com/pathors/parley/voicetyping/TranscriptCommitter.kt b/android/app/src/main/kotlin/com/pathors/parley/voicetyping/TranscriptCommitter.kt new file mode 100644 index 0000000..4e1522e --- /dev/null +++ b/android/app/src/main/kotlin/com/pathors/parley/voicetyping/TranscriptCommitter.kt @@ -0,0 +1,133 @@ +package com.pathors.parley.voicetyping + +/** + * The three editor operations dictation needs, abstracted away from + * `InputConnection` so the commit rule below can be unit-tested on the JVM. + * + * The names and the semantics are `InputConnection`'s: + * * [commitText] replaces the composing region (if any) with [text] and ends + * composition — this is how a settled word displaces the tentative guess that + * preceded it without ever showing both. + * * [setComposingText] replaces the composing region with [text], marking it as + * provisional (the host editor underlines it). + * * [finishComposing] leaves the composing text in place as ordinary text. + */ +interface DictationEditor { + fun commitText(text: String) + + fun setComposingText(text: String) + + fun finishComposing() +} + +/** + * Turns the relay's (settled, tail) transcript into editor operations, so + * dictated words never appear twice. + * + * ## The rule + * + * [SegmentBuilder][com.pathors.parley.kit.SegmentBuilder] emits two kinds of + * segment, and the difference is the whole problem: + * + * * **Committed runs** (`mix-0`, `mix-1`, …) — settled text. A run keeps growing + * under the *same* id until an endpoint or a speaker change closes it, so the + * concatenation of all runs only ever grows **by appending at the end**. + * * **The tail** (`mix-tail`) — the provider's tentative guess at what is being + * said right now. It is rewritten wholesale on nearly every frame, and its + * words re-appear a moment later inside a committed run. + * + * So the tail must never be *committed*: it goes in as **composing text**, which + * the next operation replaces. Settled growth is committed as a delta measured + * against a high-water mark ([committed]). Because `commitText` implicitly + * replaces the composing region, committing the delta also erases the stale + * tail in the same call — which is exactly why the user never sees + * "hello hello". + * + * This mirrors the desktop (`src-tauri/src/voice_typing.rs` + the overlay, which + * renders committed runs solid and the tail faint, then pastes the settled + * result) and iOS (`DictationCoordinator`, which keeps `committed`/`partial` + * apart and only ever inserts the committed delta). + * + * ## End of session + * + * [finish] mirrors `DictationCoordinator.finishUp`: whatever is still composing + * is the last thing the user said, so it is kept as real text rather than + * discarded. The session normally folds the tail into the settled text itself, + * in which case there is nothing composing left and [finish] is a no-op — both + * orders are safe. + * + * Not thread-safe: drive it from the main thread (which is where an + * `InputConnection` must be touched anyway). + */ +class TranscriptCommitter(private val editor: DictationEditor) { + + /** Everything already committed to the editor by this session. */ + private var committed = "" + + /** What the editor's composing region currently holds. */ + private var composing = "" + + /** + * Apply one transcript update. + * + * @param settled all committed runs, concatenated — expected to extend what + * was passed last time. + * @param tail the tentative tail; empty clears the composing region. + */ + fun update(settled: String, tail: String) { + if (settled.startsWith(committed)) { + val delta = settled.substring(committed.length) + if (delta.isNotEmpty()) { + // Replaces the composing region *and* ends composition, so the + // stale tail cannot survive into the document. + editor.commitText(delta) + committed = settled + composing = "" + } + } else { + // Defensive, and unreachable through `SegmentBuilder`: settled runs + // only ever grow at the end, so settled text is append-only. If a + // provider ever did rewrite it, there is nothing honest to do — + // already-typed characters cannot be retracted through this + // interface — so resync the high-water mark and keep the *later* + // growth correct rather than typing a garbled splice. + committed = settled + } + if (tail != composing) { + editor.setComposingText(tail) + composing = tail + } + } + + /** + * The session ended: keep the tentative tail as real text. Idempotent. + */ + fun finish() { + if (composing.isNotEmpty()) { + editor.finishComposing() + committed += composing + composing = "" + } + } + + /** + * Forget this session's high-water mark and drop any composing text still on + * screen — called when a *new* dictation starts, so the delta is measured + * against the new session rather than the old one. + * + * Dropping rather than keeping is deliberate: an abandoned tail was never + * settled, and the cursor may have moved (or be in a different field + * entirely) since it was shown. + */ + fun reset() { + if (composing.isNotEmpty()) { + editor.setComposingText("") + } + committed = "" + composing = "" + } + + /** Whether a composing region is currently on screen. */ + val hasComposingText: Boolean + get() = composing.isNotEmpty() +} diff --git a/android/app/src/main/kotlin/com/pathors/parley/voicetyping/VoiceTypingSession.kt b/android/app/src/main/kotlin/com/pathors/parley/voicetyping/VoiceTypingSession.kt new file mode 100644 index 0000000..4c57680 --- /dev/null +++ b/android/app/src/main/kotlin/com/pathors/parley/voicetyping/VoiceTypingSession.kt @@ -0,0 +1,411 @@ +package com.pathors.parley.voicetyping + +import android.content.Context +import android.os.SystemClock +import com.pathors.parley.BuildConfig +import com.pathors.parley.audio.MicCapture +import com.pathors.parley.audio.MicCaptureException +import com.pathors.parley.auth.AuthManager +import com.pathors.parley.kit.SttRelayClient +import com.pathors.parley.kit.SttRelayEvent +import com.pathors.parley.kit.TranscriptSegment +import com.pathors.parley.screenshot.DemoMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +/** Where one dictation is in its lifecycle. The keyboard's state line reads this. */ +sealed interface VoiceTypingState { + /** Nothing running. */ + data object Idle : VoiceTypingState + + /** Opening the relay socket. */ + data object Connecting : VoiceTypingState + + /** Microphone is live and audio is streaming. */ + data object Listening : VoiceTypingState + + /** Input drained; waiting for the relay to flush the last utterance. */ + data object Finishing : VoiceTypingState + + /** + * Ended cleanly. [reachedLimit] when [VoiceTypingSession.MAX_SESSION_SECONDS] + * ended it rather than the user — the keyboard says so, because a session + * that stopped on its own otherwise looks like a bug. + */ + data class Done(val reachedLimit: Boolean) : VoiceTypingState + + /** The dictation could not run (or died mid-stream). */ + data class Failed(val reason: VoiceTypingFailure) : VoiceTypingState +} + +/** + * Why a dictation failed. The keyboard owns the (bilingual) copy for each case, + * and the first two are the ones it can actually route out of — see + * [VoiceTypingSetup]. + */ +enum class VoiceTypingFailure { + /** No cloud session token: the relay has nothing to authenticate with. */ + NOT_SIGNED_IN, + + /** `RECORD_AUDIO` is not granted, and an IME cannot ask for it itself. */ + MIC_PERMISSION, + + /** The mic exists but would not open — a call, or another app holding it. */ + MIC_UNAVAILABLE, + + /** The relay handshake failed (offline, expired session, rejected). */ + CONNECTION, + + /** The account's hosted transcription quota is used up. */ + QUOTA_EXCEEDED, + + /** The stream died for some other reason. */ + RELAY_ERROR, +} + +/** + * One dictation: microphone → hosted STT relay → growing text. No recording, no + * upload, no diarization UI — the meeting stack stripped down to what typing + * needs. + * + * This is the Android sibling of desktop `src-tauri/src/voice_typing.rs` and iOS + * `DictationCoordinator`, and it is deliberately the same shape as + * [com.pathors.parley.meeting.MeetingSession] minus the encoder and the + * uploader: + * + * ``` + * MicCapture ──ByteArray(3200)──▶ SttRelayClient.sendPcm ──▶ segments ──▶ text + * ``` + * + * The one thing it does *not* do is touch an editor. It publishes [text] and + * [state]; [ParleyInputMethodService] is what turns those into + * `InputConnection` calls through [TranscriptCommitter]. That split is what makes + * the commit rule testable without an IME. + * + * One session per instance — build a new one per dictation, like + * [SttRelayClient] itself. + */ +class VoiceTypingSession( + context: Context, + private val auth: AuthManager, + /** + * Where the pipeline runs. Owned by the caller (the IME service's scope) so + * a session cannot outlive the keyboard that started it. + */ + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + private val relayFactory: (String) -> SttRelayClient = { token -> + SttRelayClient( + SttRelayClient.Options( + bearerToken = token, + // The cloud whitelists exactly `meeting | voice_typing | realtime`; + // anything else is billed unattributed. This is the one flow that + // uses VOICE_TYPING on Android. + feature = SttRelayClient.Feature.VOICE_TYPING, + ) + ) + }, +) { + private val appContext = context.applicationContext + private val mic = MicCapture(appContext) + + private val _state = MutableStateFlow(VoiceTypingState.Idle) + val state: StateFlow = _state.asStateFlow() + + private val _text = MutableStateFlow(DictationText()) + val text: StateFlow = _text.asStateFlow() + + private val _level = MutableStateFlow(0f) + + /** RMS of the last microphone chunk, 0..1 — drives the mic button's ring. */ + val level: StateFlow = _level.asStateFlow() + + private val _elapsedMs = MutableStateFlow(0L) + + /** How long the mic has been open, for the countdown near the cap. */ + val elapsedMs: StateFlow = _elapsedMs.asStateFlow() + + private var relay: SttRelayClient? = null + private var captureJob: Job? = null + private var eventsJob: Job? = null + private var capJob: Job? = null + private var tickerJob: Job? = null + + @Volatile private var finishRequested = false + + @Volatile private var hitLimit = false + + /** Segments → plain text. Pure, and unit-tested against real relay frames. */ + private val assembler = DictationTextAssembler() + + /** Begin. Safe to call twice; the second call is a no-op. */ + fun start() { + if (_state.value !is VoiceTypingState.Idle) return + _state.value = VoiceTypingState.Connecting + captureJob = scope.launch { run() } + } + + private suspend fun run() { + // ScreenshotDemo: play a scripted transcript instead of opening a + // microphone, so the keyboard can be exercised (and captured) with no + // account, no network and — the reason this exists — no microphone at + // all, which is every emulator. Debug builds only, and only while a + // `parley://demo/…` link has turned demo mode on. Faking stops at the + // source: the assembler, the committer and the InputConnection writes + // below it are the production path. Mirrors iOS + // `DictationCoordinator.streamDemoTranscript`. + if (BuildConfig.DEBUG && DemoMode.isActive) { + runDemo() + return + } + + val token = auth.currentToken() + if (token == null) { + _state.value = VoiceTypingState.Failed(VoiceTypingFailure.NOT_SIGNED_IN) + return + } + // Checked here as well as by the keyboard before it offers the mic: the + // grant can be revoked between the two, and MicCapture's own + // PermissionDenied would otherwise be reported as a generic mic failure. + if (!VoiceTypingSetup.hasMicPermission(appContext)) { + _state.value = VoiceTypingState.Failed(VoiceTypingFailure.MIC_PERMISSION) + return + } + + val client = relayFactory(token) + relay = client + // Collect before connecting: connect() does not wait for the stream, and + // a rejected handshake arrives as an event rather than an exception. + eventsJob = scope.launch { client.events.collect(::onRelayEvent) } + try { + client.connect() + } catch (_: IllegalArgumentException) { + abandon() + _state.value = VoiceTypingState.Failed(VoiceTypingFailure.CONNECTION) + return + } + if (_state.value is VoiceTypingState.Failed) return + + _state.value = VoiceTypingState.Listening + startTicker() + armCap() + + try { + mic.start().collect { chunk -> + client.sendPcm(chunk) + _level.value = mic.level.value + } + } catch (e: MicCaptureException) { + abandon() + _state.value = VoiceTypingState.Failed(micFailure(e)) + } + } + + /** + * The scripted stand-in for mic + relay. It builds the same segments the + * relay would — one growing `mix-0` run plus a `mix-tail` that is rewritten + * and then absorbed — and pushes them through the real [assembler], so the + * partial/final behaviour on screen is the real behaviour. + */ + private suspend fun runDemo() { + _state.value = VoiceTypingState.Listening + startTicker() + armCap() + var settled = "" + var tail = "" + for (piece in DemoMode.dictationScript()) { + delay(DEMO_PIECE_MS) + if (_state.value !is VoiceTypingState.Listening) return + tail += piece + // A tail long enough to be a phrase settles into the committed run, + // which is roughly the cadence a real endpoint detector produces. + if (tail.length >= DEMO_SETTLE_CHARS) { + settled += tail + tail = "" + } + emitDemo(settled, tail) + _level.value = DEMO_LEVELS[(settled.length + tail.length) % DEMO_LEVELS.size] + } + } + + private fun emitDemo(settled: String, tail: String) { + if (settled.isNotEmpty()) { + _text.value = assembler.accept( + TranscriptSegment( + id = "${SttRelayClient.SOURCE}-0", + source = SttRelayClient.SOURCE, + speaker = 1, + text = settled, + isFinal = true, + startMs = 0, + endMs = settled.length * 60L, + ) + ) + } + _text.value = assembler.accept( + TranscriptSegment( + id = "${SttRelayClient.SOURCE}-tail", + source = SttRelayClient.SOURCE, + speaker = 1, + text = tail, + isFinal = false, + startMs = settled.length * 60L, + endMs = settled.length * 60L, + ) + ) + } + + /** + * The hosted single-dictation cap, mirroring the desktop's + * `HOSTED_VOICE_TYPING_MAX_SECONDS` (`src/lib/limits.ts`) exactly. A session + * the user forgets to stop must not quietly burn the account's whole + * transcription quota — and on Android that risk is real in a way it is not + * on the desktop, because the keyboard can be dismissed while the mic is + * still open. + * + * iOS uses a tighter 120 s ([DictationCoordinator]) because its keyboard + * extension records through the host app under a hard jetsam limit; an + * Android IME runs in the app's own process, so it can afford the desktop + * number. + */ + private fun armCap() { + capJob = scope.launch { + delay(MAX_SESSION_SECONDS * 1_000L) + if (_state.value is VoiceTypingState.Listening) { + hitLimit = true + stop() + } + } + } + + private fun startTicker() { + val startedAt = SystemClock.elapsedRealtime() + tickerJob = scope.launch { + while (true) { + _elapsedMs.value = SystemClock.elapsedRealtime() - startedAt + delay(TICK_MS) + } + } + } + + private fun onRelayEvent(event: SttRelayEvent) { + when (event) { + is SttRelayEvent.Segment -> _text.value = assembler.accept(event.segment) + // A close after finalize is the normal end of the stream; a close + // before it means the relay hung up on us mid-dictation. + is SttRelayEvent.Closed -> + if (finishRequested) foldTail() else fail(VoiceTypingFailure.RELAY_ERROR) + + is SttRelayEvent.QuotaExceeded -> fail(VoiceTypingFailure.QUOTA_EXCEEDED) + is SttRelayEvent.Error -> + fail( + // A rejected handshake is the same user-visible problem as + // being offline: nothing was transcribed and retrying is the + // advice. Anything mid-stream is a relay error. + if (_state.value is VoiceTypingState.Connecting) { + VoiceTypingFailure.CONNECTION + } else { + VoiceTypingFailure.RELAY_ERROR + } + ) + } + } + + /** + * Stop the mic, let the relay flush the last utterance, then fold the tail + * into the settled text so nothing said just before the stop is dropped — + * `DictationCoordinator.finishUp`'s contract, and what + * [TranscriptCommitter.finish] leans on. + * + * Suspends until the transcript is final. Idempotent. + */ + suspend fun stop() { + val current = _state.value + if (current !is VoiceTypingState.Listening && current !is VoiceTypingState.Connecting) { + return + } + finishRequested = true + _state.value = VoiceTypingState.Finishing + capJob?.cancel() + tickerJob?.cancel() + + mic.stop() + withTimeoutOrNull(CAPTURE_JOIN_TIMEOUT_MS) { captureJob?.join() } + + relay?.let { client -> + runCatching { client.finish() } + // The relay holds the socket open to flush the tail; the events flow + // completes when it closes. Don't wait forever for a server that + // never does. + withTimeoutOrNull(TAIL_TIMEOUT_MS) { eventsJob?.join() } + client.cancel() + } + eventsJob?.cancel() + foldTail() + } + + /** Tentative tail becomes settled text; the session is over. */ + private fun foldTail() { + _text.value = assembler.foldTail() + if (_state.value !is VoiceTypingState.Failed) { + _state.value = VoiceTypingState.Done(reachedLimit = hitLimit) + } + } + + private fun fail(reason: VoiceTypingFailure) { + if (_state.value is VoiceTypingState.Done || _state.value is VoiceTypingState.Failed) return + abandon() + _state.value = VoiceTypingState.Failed(reason) + } + + /** Tear everything down without waiting for a flush. Idempotent. */ + fun abandon() { + capJob?.cancel() + tickerJob?.cancel() + runCatching { mic.stop() } + runCatching { relay?.cancel() } + eventsJob?.cancel() + } + + /** + * Release everything this session holds, including its coroutine scope. The + * session is spent afterwards. Mirrors `MeetingSession.dispose`. + */ + fun dispose() { + abandon() + scope.coroutineContext[Job]?.cancel() + } + + private fun micFailure(e: MicCaptureException): VoiceTypingFailure = when (e) { + is MicCaptureException.PermissionDenied -> VoiceTypingFailure.MIC_PERMISSION + is MicCaptureException.DeviceUnavailable, + is MicCaptureException.UnsupportedConfiguration, + is MicCaptureException.ReadFailed, + -> VoiceTypingFailure.MIC_UNAVAILABLE + } + + companion object { + /** + * Cap on one dictation, in seconds. The desktop's + * `HOSTED_VOICE_TYPING_MAX_SECONDS` (`src/lib/limits.ts`) — keep the two + * in step. + */ + const val MAX_SESSION_SECONDS = 600L + + private const val TICK_MS = 250L + private const val TAIL_TIMEOUT_MS = 8_000L + private const val CAPTURE_JOIN_TIMEOUT_MS = 5_000L + + /** ScreenshotDemo playback: roughly speaking pace. */ + private const val DEMO_PIECE_MS = 420L + private const val DEMO_SETTLE_CHARS = 12 + private val DEMO_LEVELS = floatArrayOf(0.10f, 0.28f, 0.17f, 0.36f, 0.22f) + } +} diff --git a/android/app/src/main/kotlin/com/pathors/parley/voicetyping/VoiceTypingSetup.kt b/android/app/src/main/kotlin/com/pathors/parley/voicetyping/VoiceTypingSetup.kt new file mode 100644 index 0000000..0394d05 --- /dev/null +++ b/android/app/src/main/kotlin/com/pathors/parley/voicetyping/VoiceTypingSetup.kt @@ -0,0 +1,166 @@ +package com.pathors.parley.voicetyping + +import android.Manifest +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.Settings +import android.util.Log +import android.view.inputmethod.InputMethodManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * The two doors between the keyboard and the app, plus the questions the keyboard + * has to ask before it dares open a microphone. + * + * ## Why this file exists at all + * + * An `InputMethodService` **cannot request a runtime permission**. There is no + * Activity to host the request, and `ActivityCompat.requestPermissions` needs + * one — so `RECORD_AUDIO` can only ever be granted from the app. This is the + * Android analogue of the iOS constraint that a keyboard extension cannot open + * the microphone at all (`ios/Keyboard/KeyboardViewController.swift`): both + * platforms force a hand-off, they just draw the line in different places. + * + * | | iOS | Android | + * |---|---|---| + * | Records | the app | the keyboard's own process | + * | Hand-off is for | recording itself | *granting* the mic, once | + * | Trigger | every dictation (unless the app is awake) | only while the grant or the session is missing | + * + * So the Android hand-off is a one-time setup trip, not a per-dictation round + * trip — and getting it wrong is the single most common way third-party + * dictation keyboards break: the mic button appears to do nothing, forever, + * because nothing in the IME can ever ask for the permission it is missing. + * + * ## The doors + * + * * [openSetupInApp] — from the keyboard into the app, at the screen that can + * actually fix things (request the mic, or sign in). + * * [openSystemImeSettings] / [showImePicker] — from the app out to the system, + * for the two steps only the user can take: *enable* the keyboard, then + * *switch* to it. + */ +object VoiceTypingSetup { + + private const val TAG = "VoiceTypingSetup" + + /** The IME's component, as the framework and `adb shell ime` spell it. */ + const val IME_ID = "com.pathors.parley/.voicetyping.ParleyInputMethodService" + + /** + * Deep link the keyboard uses to reach the setup screen. Declared on + * `MainActivity` next to the sign-in callback, and consumed by + * [SetupRequest] once the navigation graph is up. + */ + const val SETUP_URI = "parley://voice-typing" + + /** Whether `RECORD_AUDIO` is granted to this process right now. */ + fun hasMicPermission(context: Context): Boolean = + context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + + /** Whether the user has enabled the Parley keyboard in system settings. */ + fun isImeEnabled(context: Context): Boolean { + val imm = context.getSystemService(InputMethodManager::class.java) ?: return false + return imm.enabledInputMethodList.any { it.packageName == context.packageName } + } + + /** Whether the Parley keyboard is the one currently selected. */ + fun isImeSelected(context: Context): Boolean { + val current = Settings.Secure.getString( + context.contentResolver, + Settings.Secure.DEFAULT_INPUT_METHOD, + ) + return current?.startsWith("${context.packageName}/") == true + } + + /** + * Open the app's voice-typing setup screen from the keyboard. + * + * `FLAG_ACTIVITY_NEW_TASK` is required (a Service has no task of its own), + * and the start is allowed despite Android 10+ background-activity-start + * restrictions because an IME showing its input view *has a visible window*, + * which is one of the documented exemptions. It is also unambiguously + * user-initiated: this only ever runs from a tap on the keyboard. + */ + fun openSetupInApp(context: Context) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(SETUP_URI)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Log.w(TAG, "could not open the Parley app for setup", e) + } + } + + /** + * The system's "Manage on-screen keyboards" list, where the Parley keyboard + * is switched on. There is no API to enable an IME for the user — this jump + * is as far as any keyboard app can go. + */ + fun openSystemImeSettings(context: Context) { + val intent = Intent(Settings.ACTION_INPUT_METHOD_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Log.w(TAG, "no input-method settings activity on this device", e) + } + } + + /** + * The "Change keyboard" picker — step two, switching to Parley. Also what the + * keyboard's own globe key uses, so the user is never trapped in an + * input-only keyboard. + */ + fun showImePicker(context: Context) { + context.getSystemService(InputMethodManager::class.java)?.showInputMethodPicker() + } + + /** This app's entry in system Settings, for a permanently-denied mic grant. */ + fun openAppSettings(context: Context) { + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Log.w(TAG, "no application details settings activity", e) + } + } + + /** + * A pending "show me the voice-typing setup" request, carried from the deep + * link that `MainActivity` receives to the navigation graph that can act on + * it. + * + * A latch rather than an event: when the keyboard hands off while the user is + * signed *out*, the graph does not exist yet (the sign-in wall is up), so the + * request has to survive until after sign-in — which is exactly the trip the + * user was sent on. Mirrors how `screenshot/DemoMode` drives navigation. + */ + object SetupRequest { + private val _pending = MutableStateFlow(false) + + /** True while a hand-off is waiting to be shown. */ + val pending: StateFlow = _pending.asStateFlow() + + /** @return true if [uri] was the setup deep link (and is now pending). */ + fun handle(uri: Uri): Boolean { + if (uri.toString().trimEnd('/') != SETUP_URI) return false + _pending.value = true + return true + } + + /** The graph has navigated; stop asking. */ + fun consume() { + _pending.value = false + } + } +} diff --git a/android/app/src/main/res/drawable/ic_kb_backspace.xml b/android/app/src/main/res/drawable/ic_kb_backspace.xml new file mode 100644 index 0000000..7ab124f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_kb_backspace.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_kb_language.xml b/android/app/src/main/res/drawable/ic_kb_language.xml new file mode 100644 index 0000000..12ff4fe --- /dev/null +++ b/android/app/src/main/res/drawable/ic_kb_language.xml @@ -0,0 +1,14 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_kb_mic.xml b/android/app/src/main/res/drawable/ic_kb_mic.xml new file mode 100644 index 0000000..0a4eca0 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_kb_mic.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_kb_return.xml b/android/app/src/main/res/drawable/ic_kb_return.xml new file mode 100644 index 0000000..67182bc --- /dev/null +++ b/android/app/src/main/res/drawable/ic_kb_return.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_kb_stop.xml b/android/app/src/main/res/drawable/ic_kb_stop.xml new file mode 100644 index 0000000..b5445cb --- /dev/null +++ b/android/app/src/main/res/drawable/ic_kb_stop.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/layout/keyboard_voice.xml b/android/app/src/main/res/layout/keyboard_voice.xml new file mode 100644 index 0000000..108ad4c --- /dev/null +++ b/android/app/src/main/res/layout/keyboard_voice.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values-night/colors_keyboard.xml b/android/app/src/main/res/values-night/colors_keyboard.xml new file mode 100644 index 0000000..c0a394b --- /dev/null +++ b/android/app/src/main/res/values-night/colors_keyboard.xml @@ -0,0 +1,18 @@ + + + + #FF191C1D + #FFE1E3E3 + #FFBFC8C9 + #FF4FD8DF + #FF003739 + #FF2B3133 + #FFE1E3E3 + #FFFFB4AB + diff --git a/android/app/src/main/res/values-zh-rTW/strings.xml b/android/app/src/main/res/values-zh-rTW/strings.xml index 3b5cb06..a0f41bf 100644 --- a/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/android/app/src/main/res/values-zh-rTW/strings.xml @@ -113,6 +113,48 @@ %1$d 位說話者 長度 %1$s + + Parley 語音 + Parley 語音 + 開始聽寫 + 停止聽寫 + 切換鍵盤 + 刪除 + 換行 + 點一下麥克風就可以開始說。 + 正在連線… + 正在聆聽… + 正在聆聽…剩下 %1$d 秒 + 正在補上最後幾個字… + 單次聽寫已到上限。點一下麥克風可以繼續。 + 聽寫需要麥克風權限,而鍵盤本身無法要求。請到 Parley 裡設定。 + 請先登入 Parley 才能聽寫。 + 到 Parley 設定 + 開啟 Parley 登入 + 目前無法使用麥克風 —— 可能正在通話,或被其他 App 佔用。 + 無法連上轉錄服務。請確認網路後再試一次。 + 這個帳號的雲端轉錄額度已用完。 + 轉錄意外中斷,請再試一次。 + + + 語音輸入 + 在任何 App 裡用說話打字 + 在 Parley 鍵盤上點一下麥克風,說出來的話就會出現在游標的位置。 + 都設定好了。切回原本正在打字的地方,點一下麥克風即可。 + 啟用 Parley 鍵盤 + 在系統的螢幕小鍵盤清單裡,把「Parley 語音」打開。 + 開啟鍵盤設定 + 切換到它 + 點進任何輸入框,再從鍵盤選單裡選「Parley 語音」。 + 選擇鍵盤 + 允許使用麥克風 + 鍵盤本身沒辦法要求這個權限,所以在這裡授權一次就好。 + 允許麥克風 + 麥克風權限被拒絕了。請到系統設定裡開啟才能聽寫。 + 開啟 App 設定 + 已完成 + 設定語音輸入 + 會議錄音 Parley 正在錄製會議時顯示。 diff --git a/android/app/src/main/res/values/colors_keyboard.xml b/android/app/src/main/res/values/colors_keyboard.xml new file mode 100644 index 0000000..394629b --- /dev/null +++ b/android/app/src/main/res/values/colors_keyboard.xml @@ -0,0 +1,24 @@ + + + + #FFF4FBFB + #FF191C1D + #FF3F484A + #FF00696E + #FFFFFFFF + #FFDBE4E5 + #FF191C1D + #FFBA1A1A + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index b4eb4fe..b768d38 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -114,6 +114,48 @@ %1$d speakers Length %1$s + + Parley Voice + Parley Voice + Start dictation + Stop dictation + Switch keyboard + Backspace + Enter + Tap the mic and start talking. + Connecting… + Listening… + Listening… %1$d s left + Finishing the last words… + That\'s the limit for one dictation. Tap the mic to carry on. + Dictation needs microphone access, and a keyboard can\'t ask for it. Set it up in Parley. + Sign in to Parley to dictate. + Set up in Parley + Open Parley to sign in + The microphone isn\'t available — a call or another app may be using it. + Couldn\'t reach transcription. Check your connection and try again. + Your hosted transcription quota is used up. + Transcription stopped unexpectedly. Try again. + + + Voice typing + Type by voice in any app + Tap the mic on the Parley keyboard and your words land at the cursor. + Everything is ready. Switch back to what you were typing and tap the mic. + Enable the Parley keyboard + In the system\'s on-screen keyboard list, turn Parley Voice on. + Open keyboard settings + Switch to it + Tap into any text field, then pick Parley Voice from the keyboard picker. + Choose keyboard + Allow the microphone + A keyboard can\'t ask for this itself, so it is granted here — once. + Allow microphone + Microphone access was denied. Turn it on in system settings to dictate. + Open app settings + Done + Set up voice typing + Meeting recording Shows while Parley is recording a meeting. diff --git a/android/app/src/main/res/xml/method.xml b/android/app/src/main/res/xml/method.xml new file mode 100644 index 0000000..629d53b --- /dev/null +++ b/android/app/src/main/res/xml/method.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/android/app/src/test/kotlin/com/pathors/parley/voicetyping/DictationPipelineTest.kt b/android/app/src/test/kotlin/com/pathors/parley/voicetyping/DictationPipelineTest.kt new file mode 100644 index 0000000..16da8e2 --- /dev/null +++ b/android/app/src/test/kotlin/com/pathors/parley/voicetyping/DictationPipelineTest.kt @@ -0,0 +1,173 @@ +package com.pathors.parley.voicetyping + +import com.pathors.parley.kit.SonioxStreamParser +import com.pathors.parley.kit.TranscriptSegment +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The whole text path, end to end, with no microphone and no socket: real relay + * frames → the real [SonioxStreamParser] and `SegmentBuilder` → the real + * [DictationTextAssembler] → the real [TranscriptCommitter] → a fake editor with + * `InputConnection`'s composing-region semantics. + * + * This is the test that would catch the bug the feature is most likely to have: + * a word that is spoken once and typed twice, because the provider first sent it + * as a non-final token and then again inside a final one. [VoiceTypingSession] + * itself is the only piece left out, and the only thing it adds is the coroutine + * wiring between these parts. + */ +class DictationPipelineTest { + + /** Same faithful stand-in as in [TranscriptCommitterTest]. */ + private class FakeEditor : DictationEditor { + private val builder = StringBuilder() + private var composing = "" + + val text: String get() = builder.toString() + composing + val committed: String get() = builder.toString() + + override fun commitText(text: String) { + composing = "" + builder.append(text) + } + + override fun setComposingText(text: String) { + composing = text + } + + override fun finishComposing() { + builder.append(composing) + composing = "" + } + } + + /** Drives the production pipeline, one socket frame at a time. */ + private class Pipeline { + val editor = FakeEditor() + private val committer = TranscriptCommitter(editor) + private val assembler = DictationTextAssembler() + private val parser = SonioxStreamParser(SOURCE, ::onSegment) + + private fun onSegment(segment: TranscriptSegment) { + val text = assembler.accept(segment) + committer.update(text.settled, text.tail) + } + + fun frame(json: String) = parser.process(json) + + /** What the session and the service do when the stream ends. */ + fun finish() { + val text = assembler.foldTail() + committer.update(text.settled, text.tail) + committer.finish() + } + } + + private fun tokens(vararg tokens: String) = + """{"tokens":[${tokens.joinToString(",")}]}""" + + private fun token(text: String, isFinal: Boolean, start: Long = 0, end: Long = 0) = + """{"text":"$text","is_final":$isFinal,"start_ms":$start,"end_ms":$end,"speaker":"1"}""" + + private fun endpoint() = """{"tokens":[{"text":"","is_final":true}]}""" + + @Test + fun `a word guessed then finalized is typed once`() { + val pipeline = Pipeline() + + // Frame 1: the provider is guessing. + pipeline.frame(tokens(token("Hello", isFinal = false))) + assertEquals("Hello", pipeline.editor.text) + assertEquals("", pipeline.editor.committed) + + // Frame 2: the same word comes back settled, with a new guess behind it. + pipeline.frame( + tokens( + token("Hello", isFinal = true, end = 400), + token(" wor", isFinal = false, start = 400), + ) + ) + assertEquals("Hello wor", pipeline.editor.text) + assertEquals("Hello", pipeline.editor.committed) + + // Frame 3: the rest settles. + pipeline.frame(tokens(token(" world", isFinal = true, start = 400, end = 900))) + assertEquals("Hello world", pipeline.editor.text) + assertEquals("Hello world", pipeline.editor.committed) + } + + @Test + fun `an endpoint closes the run and the next utterance appends`() { + val pipeline = Pipeline() + + pipeline.frame(tokens(token("First sentence.", isFinal = true, end = 900))) + pipeline.frame(endpoint()) + // The endpoint advances SegmentBuilder's segment id, so the next + // utterance arrives as a *different* run — the assembler must join them + // rather than replace one with the other. + pipeline.frame(tokens(token(" Second sentence.", isFinal = true, start = 1000, end = 1800))) + + assertEquals("First sentence. Second sentence.", pipeline.editor.committed) + } + + @Test + fun `the last guess survives the end of the stream`() { + val pipeline = Pipeline() + + pipeline.frame(tokens(token("Ship it", isFinal = true, end = 500))) + pipeline.frame(tokens(token(" today", isFinal = false, start = 500))) + // The user stops before the provider settles the tail. + pipeline.finish() + + assertEquals("Ship it today", pipeline.editor.committed) + } + + @Test + fun `a speaker change does not duplicate the earlier run`() { + val pipeline = Pipeline() + + pipeline.frame( + tokens("""{"text":"one ","is_final":true,"start_ms":0,"end_ms":100,"speaker":"1"}""") + ) + // SegmentBuilder closes the open run on a speaker change and starts a new + // one; both stay in the transcript, in order. + pipeline.frame( + tokens("""{"text":"two","is_final":true,"start_ms":100,"end_ms":200,"speaker":"2"}""") + ) + pipeline.finish() + + assertEquals("one two", pipeline.editor.committed) + } + + @Test + fun `a guess that the provider withdraws leaves nothing behind`() { + val pipeline = Pipeline() + + pipeline.frame(tokens(token("umm", isFinal = false))) + assertEquals("umm", pipeline.editor.text) + // Next frame has no non-final tokens at all: the tail is cleared. + pipeline.frame(tokens(token("Right", isFinal = true, end = 300))) + pipeline.finish() + + assertEquals("Right", pipeline.editor.committed) + } + + @Test + fun `many rewrites of the same guess commit nothing`() { + val pipeline = Pipeline() + + pipeline.frame(tokens(token("re", isFinal = false))) + pipeline.frame(tokens(token("reco", isFinal = false))) + pipeline.frame(tokens(token("recogni", isFinal = false))) + pipeline.frame(tokens(token("recognise", isFinal = false))) + + assertEquals("recognise", pipeline.editor.text) + assertEquals("", pipeline.editor.committed) + } + + private companion object { + /** A phone has one mic, so the relay's source is always `mix`. */ + const val SOURCE = "mix" + } +} diff --git a/android/app/src/test/kotlin/com/pathors/parley/voicetyping/TranscriptCommitterTest.kt b/android/app/src/test/kotlin/com/pathors/parley/voicetyping/TranscriptCommitterTest.kt new file mode 100644 index 0000000..4b2c807 --- /dev/null +++ b/android/app/src/test/kotlin/com/pathors/parley/voicetyping/TranscriptCommitterTest.kt @@ -0,0 +1,224 @@ +package com.pathors.parley.voicetyping + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The partial/final commit rule — the one place a voice keyboard duplicates or + * drops words, so every way it can go wrong gets a test. + * + * [FakeEditor] models `InputConnection`'s actual semantics rather than just + * recording calls: `commitText` **replaces the composing region**, which is the + * behaviour the whole design leans on. [FakeEditor.text] is therefore what the + * user would really see in the field. + */ +class TranscriptCommitterTest { + + /** + * A miniature editor with a composing region, faithful to + * `InputConnection`: committed text is permanent, composing text is replaced + * wholesale by the next `setComposingText` or `commitText`, and + * `finishComposingText` turns it into committed text. + */ + private class FakeEditor : DictationEditor { + private val builder = StringBuilder() + private var composing = "" + + /** What the field shows: committed text plus the composing region. */ + val text: String get() = builder.toString() + composing + + /** Only the settled part — what survives a `finishComposingText`. */ + val committed: String get() = builder.toString() + + var commitCalls = 0 + private set + + override fun commitText(text: String) { + composing = "" + builder.append(text) + commitCalls += 1 + } + + override fun setComposingText(text: String) { + composing = text + } + + override fun finishComposing() { + builder.append(composing) + composing = "" + } + } + + @Test + fun `a tail that later settles is not typed twice`() { + val editor = FakeEditor() + val committer = TranscriptCommitter(editor) + + // The provider guesses, then settles the same words as a committed run. + committer.update(settled = "", tail = "hello") + assertEquals("hello", editor.text) + committer.update(settled = "hello ", tail = "") + assertEquals("hello ", editor.text) + + committer.update(settled = "hello ", tail = "wor") + assertEquals("hello wor", editor.text) + committer.update(settled = "hello ", tail = "world") + assertEquals("hello world", editor.text) + committer.update(settled = "hello world", tail = "") + + assertEquals("hello world", editor.text) + assertEquals("hello world", editor.committed) + } + + @Test + fun `a growing run only commits its delta`() { + val editor = FakeEditor() + val committer = TranscriptCommitter(editor) + + // SegmentBuilder re-emits the open run under the same id as it grows, so + // the committer sees the whole run every time and must add only the new + // part. + committer.update("The quick", "") + committer.update("The quick brown", "") + committer.update("The quick brown fox", "") + + assertEquals("The quick brown fox", editor.text) + assertEquals(3, editor.commitCalls) + } + + @Test + fun `a rewritten tail replaces itself in place`() { + val editor = FakeEditor() + val committer = TranscriptCommitter(editor) + + committer.update("", "recognise") + committer.update("", "recognize") + committer.update("", "recognizing") + + assertEquals("recognizing", editor.text) + // Nothing settled yet, so nothing may be committed. + assertEquals("", editor.committed) + assertEquals(0, editor.commitCalls) + } + + @Test + fun `finish keeps the last tail as real text`() { + val editor = FakeEditor() + val committer = TranscriptCommitter(editor) + + committer.update("Ship it", "") + committer.update("Ship it", " today") + committer.finish() + + assertEquals("Ship it today", editor.committed) + assertEquals("Ship it today", editor.text) + } + + @Test + fun `finish is idempotent`() { + val editor = FakeEditor() + val committer = TranscriptCommitter(editor) + + committer.update("", "one") + committer.finish() + committer.finish() + + assertEquals("one", editor.committed) + } + + /** + * The session folds its tail into the settled text on the way out + * ([VoiceTypingSession]'s `foldTail`) while the service also calls + * [TranscriptCommitter.finish] on the terminal state. The two arrive over + * separate flows, so both orders happen in practice and neither may duplicate. + */ + @Test + fun `fold-then-finish and finish-then-fold agree`() { + val foldFirst = FakeEditor() + TranscriptCommitter(foldFirst).apply { + update("hello ", "world") + update("hello world", "") // session folded the tail + finish() // service reacted to the terminal state + } + + val finishFirst = FakeEditor() + TranscriptCommitter(finishFirst).apply { + update("hello ", "world") + finish() // terminal state observed before the final text update + update("hello world", "") + } + + assertEquals("hello world", foldFirst.committed) + assertEquals("hello world", finishFirst.committed) + } + + @Test + fun `an empty tail clears the composing region`() { + val editor = FakeEditor() + val committer = TranscriptCommitter(editor) + + committer.update("", "maybe") + committer.update("", "") + + assertEquals("", editor.text) + } + + @Test + fun `reset drops an unsettled tail and starts a new session clean`() { + val editor = FakeEditor() + val committer = TranscriptCommitter(editor) + + committer.update("done. ", "abandoned") + committer.reset() + assertEquals("done. ", editor.text) + + // The new session's own settled text starts from scratch and must not be + // measured against the previous session's high-water mark. + committer.update("done. ", "") + assertEquals("done. done. ", editor.text) + } + + @Test + fun `hasComposingText tracks the composing region`() { + val editor = FakeEditor() + val committer = TranscriptCommitter(editor) + + assertEquals(false, committer.hasComposingText) + committer.update("", "tentative") + assertEquals(true, committer.hasComposingText) + committer.update("tentative", "") + assertEquals(false, committer.hasComposingText) + } + + /** + * Defensive: settled text is only ever appended to in practice (see + * `SegmentBuilder`), but a provider that rewrote history must not make the + * keyboard re-type the transcript or splice a garbled suffix into it. It + * resyncs instead, and later growth still lands correctly. + */ + @Test + fun `a rewritten prefix resyncs instead of splicing`() { + val editor = FakeEditor() + val committer = TranscriptCommitter(editor) + + committer.update("colour ", "") + committer.update("color theory ", "") + assertEquals("colour ", editor.text) + assertEquals(1, editor.commitCalls) + + committer.update("color theory works", "") + assertEquals("colour works", editor.text) + } + + @Test + fun `a shrinking settled string commits nothing`() { + val editor = FakeEditor() + val committer = TranscriptCommitter(editor) + + committer.update("a long sentence", "") + committer.update("a long", "") + + assertEquals("a long sentence", editor.text) + assertEquals(1, editor.commitCalls) + } +} diff --git a/android/docs/app-structure.md b/android/docs/app-structure.md index ee28522..36a06e2 100644 --- a/android/docs/app-structure.md +++ b/android/docs/app-structure.md @@ -4,6 +4,10 @@ What sits on top of the four documented layers (`api-parleykit.md`, `api-cloud.md`, `api-audio.md`): the Compose UI, the two capture *sessions*, and the foreground service that keeps a live meeting alive. +The voice-typing keyboard is a third capture surface with its own document — +`voice-typing.md` — because an `InputMethodService` answers to a different set of +rules than an activity does. + ``` com.pathors.parley ParleyApplication.kt AppContainer — the whole dependency graph, one per process @@ -12,6 +16,10 @@ com.pathors.parley MeetingService.kt foreground service (type=microphone) + its notification MeetingSession.kt live capture: mic → encoder + relay → segments → upload ImportSession.kt imported file: decoder → encoder + relay → … → upload + voicetyping/ the dictation keyboard — see voice-typing.md + ParleyInputMethodService.kt the IME (classic Views, not Compose) + VoiceTypingSession.kt mic → relay → text, minus the encoder/upload + TranscriptCommitter.kt composing vs committed text (pure, tested) ui/ ParleyRoot.kt sign-in wall, NavHost, the SAF picker SignInScreen.kt Custom Tab hand-off @@ -21,6 +29,7 @@ com.pathors.parley MeetingScreen.kt permission gate, live transcript, level meter, stop ImportScreen.kt progress + phase label + cancel RecordingDetail*.kt read-only transcript, findings, action items + VoiceTypingSetupScreen.kt keyboard onboarding + the mic-permission hand-off Format.kt duration/clock/date/speaker-label formatting theme/Theme.kt Material 3, dynamic color on API 31+ ``` diff --git a/android/docs/voice-typing.md b/android/docs/voice-typing.md new file mode 100644 index 0000000..187c92c --- /dev/null +++ b/android/docs/voice-typing.md @@ -0,0 +1,273 @@ +# Voice typing — the Parley keyboard + +Dictation into *any* app: an `InputMethodService` that streams the microphone to +the hosted STT relay and types the result at the cursor. The Android counterpart +of the iOS keyboard extension (`ios/Keyboard/`) and the desktop's push-to-talk +(`src-tauri/src/voice_typing.rs`). + +``` +com.pathors.parley + voicetyping/ + ParleyInputMethodService.kt the IME: input view, keys, session lifecycle + VoiceTypingSession.kt mic → relay → text; the max-duration cap + DictationTextAssembler.kt segments → (settled, tail) [pure] + TranscriptCommitter.kt (settled, tail) → InputConnection [pure] + VoiceTypingSetup.kt the permission hand-off, both directions + MicButton.kt mic toggle with a level ring + KeyboardPalette.kt the keyboard's Material 3 colors + ui/ + VoiceTypingSetupScreen.kt in-app onboarding + the hand-off landing pad + res/ + xml/method.xml input-method metadata (required) + layout/keyboard_voice.xml the input view + values{,-night}/colors_keyboard.xml +``` + +## Architecture + +``` + ┌─────────────── the app's own process ───────────────┐ + │ │ + user speaks ──▶ MicCapture ──ByteArray(3200)──▶ SttRelayClient │ + │ 16 kHz mono s16le (feature=voice_typing)│ + │ │ │ + │ SttRelayEvent.Segment │ + │ ▼ │ + │ DictationTextAssembler │ + │ (settled, tail) │ + │ ▼ │ + │ TranscriptCommitter │ + │ ▼ │ + └────────────────────────── InputConnection ──────────┘ + │ + ▼ + whatever app the user is in +``` + +Everything reused, nothing forked: `MicCapture`, `SttRelayClient`, +`SonioxStreamParser` and `SegmentBuilder` are the same classes a meeting uses. +Voice typing is the meeting pipeline with the encoder and the uploader removed — +compare `meeting/MeetingSession.kt`, which is deliberately the same shape. + +Cloud usage is attributed with `SttRelayClient.Feature.VOICE_TYPING` +(`?feature=voice_typing`). The relay whitelists exactly +`meeting | voice_typing | realtime`; anything else bills as unattributed. + +### Why the keyboard records, when iOS's cannot + +On iOS a keyboard extension is *forbidden* from opening the microphone, so +`ios/Keyboard/KeyboardViewController.swift` hands every dictation to the container +app over an App Group and only inserts the text that comes back. Android has no +such rule: an IME runs inside its own app's process and may hold `RECORD_AUDIO`. +So there is no channel, no session ids, no per-dictation app switch — mic, relay +and `InputConnection` all live in one place. + +### Why classic Views, not Compose + +The app is otherwise entirely Compose. This one surface is not: + +- `InputMethodService` is not a `LifecycleOwner`, `ViewModelStoreOwner` or + `SavedStateRegistryOwner`, so a `ComposeView` needs hand-rolled owner plumbing + attached to the input view before it composes at all. That is extra machinery in + the one surface the user cannot escape if it breaks — a keyboard that fails to + draw leaves them unable to type at all. +- The surface is five controls with no state that outlives a keystroke. +- An IME is loaded into the input pipeline of every app on the device. A + `LinearLayout` inflates in microseconds and adds nothing to a process we are a + guest in. + +Material 3 still applies: `KeyboardPalette` is the app's own scheme (the seeds +from `ui/theme/Theme.kt` plus M3 neutrals) including dynamic color on API 31+, +which is what `ParleyTheme` does through `dynamicLightColorScheme()`. + +### No foreground service + +`MeetingService` needs `foregroundServiceType="microphone"` because a meeting +keeps recording with no UI at all. The keyboard does not: an IME showing its input +view **has a visible window**, which is what Android's microphone policy requires, +and the session is torn down in `onFinishInputView` — the mic never outlives the +surface that shows it running. That also means no notification for something the +user is looking at. + +## The permission hand-off + +**An `InputMethodService` cannot request a runtime permission.** There is no +Activity to host the request. This is the single most common way third-party +dictation keyboards die: the mic button appears to do nothing, forever, because +nothing in the IME can ever ask for the `RECORD_AUDIO` it is missing. + +So the keyboard never tries. `ParleyInputMethodService.ready` is +`signed in && mic granted`; while it is false the keyboard: + +1. **says which one is missing** in the state line, in the user's language, and +2. **shows a call to action** whose tap opens `parley://voice-typing`. + +``` +keyboard (mic button tapped, not ready) + │ startActivity(parley://voice-typing, FLAG_ACTIVITY_NEW_TASK) + ▼ +MainActivity.handleDeepLink + │ VoiceTypingSetup.SetupRequest.handle(uri) ← latches, does not navigate + ▼ +ParleyRoot → (sign-in wall, if signed out) → ParleyNavHost + │ VoiceTypingHandOff consumes the latch + ▼ +VoiceTypingSetupScreen + │ step 3 → ActivityResultContracts.RequestPermission(RECORD_AUDIO) + ▼ +user switches back to their app, taps the mic again — now it works +``` + +Notes on the pieces that are easy to get wrong: + +- **The request is a latch, not an event.** The commonest hand-off reason after a + fresh install is *no signed-in session*, and while the sign-in wall is up the + navigation graph does not exist yet. A one-shot event would be dropped; the + latch survives the sign-in trip that the user was sent on. +- **Starting an Activity from a Service** needs `FLAG_ACTIVITY_NEW_TASK`, and it + is permitted despite Android 10+ background-activity-start restrictions because + an IME with a visible input view is an app with a visible window — a documented + exemption. It is also only ever reached from a tap. +- **Signed-out is a first-class state**, not a failure discovered on the first + tap: the service collects `AuthManager.isSignedIn` for its whole life, so the + keyboard knows before the user speaks a word. +- **The setup screen re-reads all three conditions on `ON_RESUME`**, because all + three (keyboard enabled, keyboard selected, mic granted) are toggled in system + UI where the app gets no callback. +- The keyboard's globe key (`showInputMethodPicker`) is always live, in every + state. A user who cannot dictate must still be able to leave. + +The app also reaches *out* to the system for the two steps no API can do for the +user — enabling the keyboard (`ACTION_INPUT_METHOD_SETTINGS`) and switching to it +(the picker). `ios/App/Parley/SettingsView.swift` presents its equivalent the same +way, for the same reason. + +## The partial/final commit rule + +This is where a voice keyboard types a word twice, so it is the part with the most +tests (`app/src/test/kotlin/com/pathors/parley/voicetyping/`). + +`SegmentBuilder` emits two kinds of segment and the difference is the whole +problem: + +| Segment | Id | Behaviour | +|---|---|---| +| **Committed run** | `mix-0`, `mix-1`, … | Settled. Keeps growing under the *same* id until an endpoint or a speaker change closes it, so the concatenation of all runs only ever grows **by appending at the end**. | +| **Tentative tail** | `mix-tail` | The provider's current guess. Rewritten wholesale on nearly every frame, and its words re-appear a moment later inside a committed run. | + +The rule: + +> **The tail is composing text. Settled growth is committed text.** + +`DictationTextAssembler` flattens segments to `(settled, tail)`; +`TranscriptCommitter` turns that into `InputConnection` calls: + +```kotlin +if (settled.startsWith(committed)) { // always true via SegmentBuilder + val delta = settled.substring(committed.length) + if (delta.isNotEmpty()) { + editor.commitText(delta) // ← also replaces the composing region + committed = settled + } +} +if (tail != composing) editor.setComposingText(tail) +``` + +Two things make it work: + +1. **A high-water mark.** Only the delta beyond what was already committed is + typed, so a run that is re-emitted as it grows does not re-type its prefix. +2. **`commitText` implicitly replaces the composing region.** Committing the + settled delta erases the stale guess *in the same call*. That is precisely why + the user never sees `hello hello`. + +At the end of a session the tail is the last thing the user said, so it is kept: +`VoiceTypingSession.foldTail()` moves it into `settled`, and the service also +calls `TranscriptCommitter.finish()` on the terminal state. Those arrive over two +separate flows, so **both orders happen** — and both converge, because `finish()` +adds the composing text to the same high-water mark that the final update is +measured against. There is a test for exactly that +(`fold-then-finish and finish-then-fold agree`). + +`reset()` is called when a *new* dictation starts and when the editor changes: an +abandoned tail was never settled, and the cursor may be in a different field +entirely. + +Mirrors iOS `DictationCoordinator` (which keeps `committed`/`partial` apart and +inserts only the committed delta) and the desktop overlay (which renders committed +runs solid and the tail faint, then pastes the settled result). + +## Max session duration + +`VoiceTypingSession.MAX_SESSION_SECONDS = 600` — the desktop's +`HOSTED_VOICE_TYPING_MAX_SECONDS` in `src/lib/limits.ts`. **Keep the two in +step.** A dictation the user forgets to stop must not quietly burn the account's +whole transcription quota. + +At the cap the session stops itself the same way the user's tap would — the mic +closes, the relay flushes its tail, the text is committed — and the state resolves +to `Done(reachedLimit = true)`, which the keyboard reports rather than leaving the +stop looking like a bug. The last 30 seconds show as a countdown in the state +line. + +iOS uses a tighter 120 s: its keyboard extension records through the host app +under a hard jetsam limit. An Android IME runs in the app's own process, so it can +afford the desktop number. + +## Manual test checklist + +`assembleDebug`, `:parleykit:test` and `:app:testDebugUnitTest` cover the text +path. Everything below needs a real device, because an emulator has no microphone +input. + +**Setup and hand-off** + +- [ ] Fresh install, signed out: enable the keyboard, switch to it, tap the mic → + the state line says to sign in and the button opens Parley at the sign-in + wall. Sign in → the setup screen appears by itself. +- [ ] Signed in, mic not granted: tap the mic → the state line says the keyboard + cannot ask, the button opens the setup screen, step 3 requests the + permission. Grant, switch back, tap the mic → it dictates. +- [ ] Deny the permission twice (permanent denial) → the setup screen shows the + "open app settings" route, and it works. +- [ ] Revoke `RECORD_AUDIO` in system settings while the keyboard is on screen → + next tap hands off again rather than failing silently. +- [ ] The globe key opens the picker in *every* state, including signed out. + +**Dictation** + +- [ ] Dictate a sentence into a plain `EditText` (Notes, Messages): words settle + in place, nothing appears twice, no leftover underlined text at the end. +- [ ] Speak, then stop mid-word: the last guess is kept, not dropped. +- [ ] Speak two sentences with a pause between them → both land, in order, + joined with the spacing the provider gave (the pause closes a run). +- [ ] zh-TW: dictate Mandarin and check the text is Traditional (the relay's + S→T pass) and that no character is duplicated at a run boundary. +- [ ] Watch the mic ring: it must move while you speak. A frozen ring during + speech means another app took the mic and Android is feeding us silence. +- [ ] Tap the mic twice quickly (start, immediate stop) → no crash, no orphaned + session, and the next tap starts a fresh one. + +**Keys and hosts** + +- [ ] Backspace deletes one character; hold it and it repeats. +- [ ] Backspace immediately after dictating, while the tail is still underlined → + deletes exactly one character (the tail is settled first). +- [ ] Return types a newline in a multi-line field, and fires the action in a + field that asks for one (a search box submits, a chat box sends). +- [ ] Move the cursor / switch to another field mid-dictation → no text lands in + the wrong place. + +**Lifecycle** + +- [ ] Dismiss the keyboard while listening → the mic stops (check the status-bar + mic indicator disappears). +- [ ] Home / recents / lock the screen while listening → the mic stops. +- [ ] Take a phone call while listening → the state line reports the mic being + unavailable rather than hanging. +- [ ] Turn on airplane mode and tap the mic → a connection error, retryable. +- [ ] With an exhausted hosted quota → the quota message, not a generic failure. +- [ ] Flip the system into dark mode with the keyboard open → the palette follows + on the next appearance. +- [ ] Leave a dictation running for 10 minutes → it ends itself at the cap, the + text is committed, and the state line says the limit was reached.