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.