Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="parley" android:host="auth-callback" />
<!-- Voice-typing hand-off: the keyboard cannot request RECORD_AUDIO
itself (no Activity), so its setup affordance opens this.
See voicetyping/VoiceTypingSetup.kt. -->
<data android:scheme="parley" android:host="voice-typing" />
<!-- Store-screenshot demo mode (debug builds only, see DemoMode):
adb shell am start -a android.intent.action.VIEW \
-d "'parley://demo/library'" -->
Expand All @@ -43,5 +47,29 @@
android:name=".meeting.MeetingService"
android:exported="false"
android:foregroundServiceType="microphone" />

<!--
The voice-typing keyboard. `BIND_INPUT_METHOD` is what tells the system
only the input-method manager may bind this service; the <meta-data>
pointing at res/xml/method.xml is what makes it appear in the on-screen
keyboard list at all.

It needs no foregroundServiceType: an IME with a visible input view is
already a visible window, which is what Android's microphone policy
requires — unlike MeetingService, which has to keep recording with no UI
at all. See android/docs/voice-typing.md.
-->
<service
android:name=".voicetyping.ParleyInputMethodService"
android:exported="true"
android:label="@string/voice_typing_ime_label"
android:permission="android.permission.BIND_INPUT_METHOD">
<intent-filter>
<action android:name="android.view.InputMethod" />
</intent-filter>
<meta-data
android:name="android.view.im"
android:resource="@xml/method" />
</service>
</application>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> =
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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -161,6 +162,10 @@ fun HomeScreen(
AccountSheet(
viewModel = viewModel,
onDismiss = { showAccount = false },
onSetUpVoiceTyping = {
showAccount = false
onSetUpVoiceTyping()
},
)
}
}
Expand Down
32 changes: 30 additions & 2 deletions android/app/src/main/kotlin/com/pathors/parley/ui/ParleyRoot.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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) })
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading