diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt index b17282f..ad40f18 100644 --- a/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt +++ b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt @@ -466,7 +466,7 @@ class MainActivityInstrumentedTest { } awaitActivity(scenario) { activity -> displayItems(activity).filterIsInstance() - .singleOrNull()?.presentation?.action?.kind == TaskActionKind.CREATE_OUTPUT + .singleOrNull()?.presentation?.action?.kind == TaskActionKind.SELECT_OUTPUT } scenario.onActivity { activity -> stateHost(activity).replace( @@ -520,6 +520,8 @@ class MainActivityInstrumentedTest { setField(activity, "modelPresentationKnown", true) setField(activity, "outputPresentationKnown", true) setField(activity, "folderPresentationKnown", true) + setField(activity, "keyboardStatus", AndroidVoiceKeyboardStatus.ENABLED) + setField(activity, "keyboardStatusKnown", true) invoke(activity, "publishTaskState") } awaitActivity(scenario) { activity -> @@ -544,6 +546,57 @@ class MainActivityInstrumentedTest { } } + @Test + fun legacyDiscoveryCardCanBeDismissedAndDoesNotDuplicateOnboarding() { + clearOnboardingActivityState() + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + setField( + activity, + "keyboardDiscoveryLifecycle", + AndroidVoiceKeyboardDiscoveryLifecycle.ELIGIBLE, + ) + stateHost(activity).replace( + AndroidMainScreenInput( + model = me.maxistar.voiceinbox.core.ModelSetupSnapshot(ModelSetupSnapshotState.READY), + output = me.maxistar.voiceinbox.core.OutputSetupSnapshot( + me.maxistar.voiceinbox.core.OutputSetupSnapshotState.READY, + ), + folder = me.maxistar.voiceinbox.core.FolderSetupSnapshot( + me.maxistar.voiceinbox.core.FolderSetupSnapshotState.READY, + ), + hydration = AndroidMainScreenHydration(true, true, true, true), + onboardingLifecycle = AndroidOnboardingHintLifecycle.COMPLETED, + keyboardStatus = AndroidVoiceKeyboardStatus.DISABLED, + keyboardKnown = true, + keyboardDiscoveryLifecycle = AndroidVoiceKeyboardDiscoveryLifecycle.ELIGIBLE, + ), + ) + } + awaitActivity(scenario) { activity -> + val items = displayItems(activity) + items.count { it is TaskListDisplayItem.KeyboardDiscovery } == 1 && + items.none { it is TaskListDisplayItem.OnboardingHint } + } + scenario.onActivity { activity -> + activity.findViewById(R.id.keyboardDiscoveryClose).performClick() + } + awaitActivity(scenario) { activity -> + displayItems(activity).none { it is TaskListDisplayItem.KeyboardDiscovery } + } + val context = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals( + AndroidVoiceKeyboardDiscoveryLifecycle.DISMISSED, + AndroidVoiceKeyboardDiscoveryStore( + context.getSharedPreferences( + AndroidVoiceKeyboardDiscoveryStore.PREFERENCES_NAME, + Context.MODE_PRIVATE, + ), + ).loadOrInitialize(AndroidOnboardingHintLifecycle.COMPLETED), + ) + } + } + private fun clearActivityState() { val context = InstrumentationRegistry.getInstrumentation().targetContext WorkManager.getInstance(context).cancelUniqueWork(TranscriptionWorker.UNIQUE_WORK_NAME).result.get(30, TimeUnit.SECONDS) @@ -553,6 +606,7 @@ class MainActivityInstrumentedTest { context.getSharedPreferences(DocumentSelectionStore.PREFERENCES_NAME, Context.MODE_PRIVATE).edit().clear().commit() context.getSharedPreferences(StartupProcessingPolicyStore.PREFERENCES_NAME, Context.MODE_PRIVATE).edit().clear().commit() context.getSharedPreferences(AndroidOnboardingHintStore.PREFERENCES_NAME, Context.MODE_PRIVATE).edit().clear().commit() + context.getSharedPreferences(AndroidVoiceKeyboardDiscoveryStore.PREFERENCES_NAME, Context.MODE_PRIVATE).edit().clear().commit() context.deleteDatabase(AndroidSqlDelightAudioCatalogFactory.DATABASE_NAME) java.io.File(context.filesDir, AndroidAudioImportConstants.DIRECTORY_NAME).deleteRecursively() } @@ -563,6 +617,7 @@ class MainActivityInstrumentedTest { context.getSharedPreferences(DocumentSelectionStore.PREFERENCES_NAME, Context.MODE_PRIVATE).edit().clear().commit() context.getSharedPreferences(StartupProcessingPolicyStore.PREFERENCES_NAME, Context.MODE_PRIVATE).edit().clear().commit() context.getSharedPreferences(AndroidOnboardingHintStore.PREFERENCES_NAME, Context.MODE_PRIVATE).edit().clear().commit() + context.getSharedPreferences(AndroidVoiceKeyboardDiscoveryStore.PREFERENCES_NAME, Context.MODE_PRIVATE).edit().clear().commit() context.deleteDatabase(AndroidSqlDelightAudioCatalogFactory.DATABASE_NAME) java.io.File(context.filesDir, AndroidAudioImportConstants.DIRECTORY_NAME).deleteRecursively() } @@ -583,6 +638,8 @@ class MainActivityInstrumentedTest { ), hydration = AndroidMainScreenHydration(true, true, true, true), onboardingLifecycle = AndroidOnboardingHintLifecycle.ACTIVE, + keyboardStatus = AndroidVoiceKeyboardStatus.DISABLED, + keyboardKnown = true, ) private fun seedCatalogEntry( diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/SettingsActivityInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/SettingsActivityInstrumentedTest.kt index e9bd39b..5db8c50 100644 --- a/app/src/androidTest/java/me/maxistar/voiceinbox/SettingsActivityInstrumentedTest.kt +++ b/app/src/androidTest/java/me/maxistar/voiceinbox/SettingsActivityInstrumentedTest.kt @@ -66,6 +66,21 @@ class SettingsActivityInstrumentedTest { assertEquals(30, scheduledStore.load().minute) } + @Test + fun voiceKeyboardSectionIsPermanentAndActionable() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + clearSettings(context) + + ActivityScenario.launch(SettingsActivity::class.java).use { + onView(withText(R.string.settings_voice_keyboard_title)) + .perform(scrollTo()) + .check(matches(isDisplayed())) + onView(withId(R.id.settingsVoiceKeyboardStatus)).check(matches(isDisplayed())) + onView(withId(R.id.settingsVoiceKeyboardAction)).check(matches(isDisplayed())) + onView(withId(R.id.settingsVoiceKeyboardDocumentation)).check(matches(isDisplayed())) + } + } + private fun clearSettings(context: Context) { context.getSharedPreferences(StartupProcessingPolicyStore.PREFERENCES_NAME, Context.MODE_PRIVATE) .edit() diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/VoiceKeyboardInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/VoiceKeyboardInstrumentedTest.kt index bb0f6c4..5da9149 100644 --- a/app/src/androidTest/java/me/maxistar/voiceinbox/VoiceKeyboardInstrumentedTest.kt +++ b/app/src/androidTest/java/me/maxistar/voiceinbox/VoiceKeyboardInstrumentedTest.kt @@ -3,6 +3,7 @@ package me.maxistar.voiceinbox import android.content.ComponentName import android.content.pm.PackageManager import android.view.LayoutInflater +import android.view.ViewConfiguration import android.widget.EditText import android.widget.ImageButton import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -24,10 +25,11 @@ class VoiceKeyboardInstrumentedTest { assertEquals("android.permission.BIND_INPUT_METHOD", service.permission) assertTrue(service.metaData?.containsKey("android.view.im") == true) - assertEquals( - PackageManager.PERMISSION_GRANTED, - context.packageManager.checkPermission(android.Manifest.permission.RECORD_AUDIO, context.packageName), + val packageInfo = context.packageManager.getPackageInfo( + context.packageName, + PackageManager.GET_PERMISSIONS, ) + assertTrue(packageInfo.requestedPermissions?.contains(android.Manifest.permission.RECORD_AUDIO) == true) } @Test @@ -67,6 +69,14 @@ class VoiceKeyboardInstrumentedTest { InstrumentationRegistry.getInstrumentation().runOnMainSync { val view = LayoutInflater.from(context).inflate(R.layout.input_view_voice_keyboard, null) + assertEquals( + context.getString(R.string.voice_keyboard_record), + view.findViewById(R.id.voiceKeyboardRecord).contentDescription, + ) + val recordButton = view.findViewById(R.id.voiceKeyboardRecord) + val minimumTouchTarget = (48 * context.resources.displayMetrics.density).toInt() + assertTrue(recordButton.layoutParams.width >= minimumTouchTarget) + assertTrue(recordButton.layoutParams.height >= minimumTouchTarget) assertEquals( context.getString(R.string.voice_keyboard_return_to_previous), view.findViewById(R.id.voiceKeyboardNextKeyboard).contentDescription, @@ -85,4 +95,35 @@ class VoiceKeyboardInstrumentedTest { ) } } + + @Test + fun androidLongPressThresholdClassifiesLatchedAndHeldRecording() { + val threshold = ViewConfiguration.getLongPressTimeout().toLong() + val coordinator = HybridRecordGestureCoordinator(HybridRecordGesturePolicy(threshold)) + + assertTrue(coordinator.begin(pointerId = 0, generation = 1, eventTimeMillis = 1_000)) + assertEquals( + HybridRecordRelease.LATCH, + coordinator.release(0, 1, 1_000 + threshold - 1)?.release, + ) + coordinator.finish(1) + + assertTrue(coordinator.begin(pointerId = 0, generation = 2, eventTimeMillis = 2_000)) + assertEquals( + HybridRecordRelease.STOP_AND_TRANSCRIBE, + coordinator.release(0, 2, 2_000 + threshold)?.release, + ) + } + + @Test + fun cancelledHeldGestureCannotStartAfterModelPreparation() { + val coordinator = HybridRecordGestureCoordinator( + HybridRecordGesturePolicy(ViewConfiguration.getLongPressTimeout().toLong()), + ) + assertTrue(coordinator.begin(pointerId = 4, generation = 9, eventTimeMillis = 1_000)) + + assertTrue(coordinator.cancel(pointerId = 4, generation = 9)) + + assertEquals(HybridRecordPreparationAction.CANCEL, coordinator.preparationAction(9)) + } } diff --git a/app/src/main/java/me/maxistar/voiceinbox/AndroidInlineOnboarding.kt b/app/src/main/java/me/maxistar/voiceinbox/AndroidInlineOnboarding.kt index 58ac6cf..b6deaec 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/AndroidInlineOnboarding.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/AndroidInlineOnboarding.kt @@ -70,6 +70,7 @@ enum class AndroidOnboardingStepKind { MODEL, OUTPUT, FOLDER, + KEYBOARD, } data class AndroidOnboardingChecklistStep( @@ -106,12 +107,14 @@ object AndroidOnboardingHintPresenter { model: ModelSetupSnapshot, output: OutputSetupSnapshot, folder: FolderSetupSnapshot, + keyboardStatus: AndroidVoiceKeyboardStatus, + keyboardKnown: Boolean, ): AndroidOnboardingHintPresentation { if ( lifecycle != AndroidOnboardingHintLifecycle.ACTIVE || filter != TaskListFilter.NEW || - !setupKnown(hydration) || - allStepsComplete(model, output, folder) + !setupKnown(hydration, keyboardKnown) || + allStepsComplete(model, output, folder, keyboardStatus) ) { return AndroidOnboardingHintPresentation.HIDDEN } @@ -134,8 +137,14 @@ object AndroidOnboardingHintPresenter { complete = folder.state == FolderSetupSnapshotState.READY, optional = true, ), + AndroidOnboardingChecklistStep( + kind = AndroidOnboardingStepKind.KEYBOARD, + label = "Enable voice keyboard · Optional", + complete = keyboardStatus != AndroidVoiceKeyboardStatus.DISABLED, + optional = true, + ), ) - val action = nextAction(model, output, folder) + val action = nextAction(model, output, folder, keyboardStatus) return AndroidOnboardingHintPresentation( visible = true, steps = steps, @@ -156,26 +165,33 @@ object AndroidOnboardingHintPresenter { model: ModelSetupSnapshot, output: OutputSetupSnapshot, folder: FolderSetupSnapshot, + keyboardStatus: AndroidVoiceKeyboardStatus, + keyboardKnown: Boolean, ): Boolean = lifecycle == AndroidOnboardingHintLifecycle.ACTIVE && - setupKnown(hydration) && - allStepsComplete(model, output, folder) + setupKnown(hydration, keyboardKnown) && + allStepsComplete(model, output, folder, keyboardStatus) - private fun setupKnown(hydration: AndroidMainScreenHydration): Boolean = - hydration.modelKnown && hydration.outputKnown && hydration.folderKnown + private fun setupKnown( + hydration: AndroidMainScreenHydration, + keyboardKnown: Boolean, + ): Boolean = hydration.modelKnown && hydration.outputKnown && hydration.folderKnown && keyboardKnown private fun allStepsComplete( model: ModelSetupSnapshot, output: OutputSetupSnapshot, folder: FolderSetupSnapshot, + keyboardStatus: AndroidVoiceKeyboardStatus, ): Boolean = model.state == ModelSetupSnapshotState.READY && output.state == OutputSetupSnapshotState.READY && - folder.state == FolderSetupSnapshotState.READY + folder.state == FolderSetupSnapshotState.READY && + keyboardStatus != AndroidVoiceKeyboardStatus.DISABLED private fun nextAction( model: ModelSetupSnapshot, output: OutputSetupSnapshot, folder: FolderSetupSnapshot, + keyboardStatus: AndroidVoiceKeyboardStatus, ): AndroidOnboardingHintAction = when { model.state == ModelSetupSnapshotState.INSTALLING -> AndroidOnboardingHintAction( label = "Installing speech model…", @@ -206,6 +222,11 @@ object AndroidOnboardingHintPresenter { enabled = true, kind = TaskActionKind.SELECT_FOLDER, ) + keyboardStatus == AndroidVoiceKeyboardStatus.DISABLED -> AndroidOnboardingHintAction( + label = "Enable voice keyboard", + enabled = true, + kind = TaskActionKind.ENABLE_VOICE_KEYBOARD, + ) else -> AndroidOnboardingHintAction( label = "Ready to transcribe", enabled = false, diff --git a/app/src/main/java/me/maxistar/voiceinbox/AndroidMainScreenStateHost.kt b/app/src/main/java/me/maxistar/voiceinbox/AndroidMainScreenStateHost.kt index 11fa5b3..0ce3060 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/AndroidMainScreenStateHost.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/AndroidMainScreenStateHost.kt @@ -36,6 +36,9 @@ data class AndroidMainScreenInput( val hydration: AndroidMainScreenHydration = AndroidMainScreenHydration(), val folderSync: AndroidFolderSyncPresentation = AndroidFolderSyncPresentation(), val onboardingLifecycle: AndroidOnboardingHintLifecycle = AndroidOnboardingHintLifecycle.DISMISSED, + val keyboardStatus: AndroidVoiceKeyboardStatus = AndroidVoiceKeyboardStatus.DISABLED, + val keyboardKnown: Boolean = false, + val keyboardDiscoveryLifecycle: AndroidVoiceKeyboardDiscoveryLifecycle = AndroidVoiceKeyboardDiscoveryLifecycle.SUPPRESSED, ) data class AndroidMainScreenHydration( @@ -70,6 +73,7 @@ data class AndroidMainScreenState( val importEnabled: Boolean, val folderSync: AndroidFolderSyncPresentation, val onboardingHint: AndroidOnboardingHintPresentation, + val keyboardDiscovery: AndroidVoiceKeyboardDiscoveryPresentation, val transcriptionActive: Boolean, ) { val refreshFolderVisible: Boolean get() = folderSync.visible @@ -131,6 +135,16 @@ object AndroidTaskListSnapshotMapper { model = input.model, output = input.output, folder = input.folder, + keyboardStatus = input.keyboardStatus, + keyboardKnown = input.keyboardKnown, + ), + keyboardDiscovery = AndroidVoiceKeyboardDiscoveryPresenter.present( + lifecycle = input.keyboardDiscoveryLifecycle, + filter = input.filter, + model = input.model, + modelKnown = input.hydration.modelKnown, + keyboardStatus = input.keyboardStatus, + keyboardKnown = input.keyboardKnown, ), ) } diff --git a/app/src/main/java/me/maxistar/voiceinbox/AndroidTaskActionRouter.kt b/app/src/main/java/me/maxistar/voiceinbox/AndroidTaskActionRouter.kt index 5d0992b..0ffa05d 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/AndroidTaskActionRouter.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/AndroidTaskActionRouter.kt @@ -26,6 +26,11 @@ class AndroidTaskActionRouter( state.onboardingHint.action?.let { action -> action.kind == request.kind && action.enabled } == true + request.stableId == TaskListDisplayItem.KeyboardDiscovery.STABLE_KEY -> + state.keyboardDiscovery.visible && request.kind in setOf( + state.keyboardDiscovery.setupAction, + TaskActionKind.OPEN_VOICE_KEYBOARD_DOCUMENTATION, + ) request.kind == TaskActionKind.TRANSCRIBE_ALL -> request.stableId == TaskListDisplayItem.BatchAction.STABLE_KEY && state.taskList.batchAction.visible && diff --git a/app/src/main/java/me/maxistar/voiceinbox/AndroidVoiceKeyboardDiscovery.kt b/app/src/main/java/me/maxistar/voiceinbox/AndroidVoiceKeyboardDiscovery.kt new file mode 100644 index 0000000..cccf3bb --- /dev/null +++ b/app/src/main/java/me/maxistar/voiceinbox/AndroidVoiceKeyboardDiscovery.kt @@ -0,0 +1,193 @@ +package me.maxistar.voiceinbox + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.provider.Settings +import android.view.inputmethod.InputMethodManager +import me.maxistar.voiceinbox.core.ModelSetupSnapshot +import me.maxistar.voiceinbox.core.ModelSetupSnapshotState +import me.maxistar.voiceinbox.core.TaskActionKind +import me.maxistar.voiceinbox.core.TaskListFilter + +enum class AndroidVoiceKeyboardStatus { + DISABLED, + ENABLED, + SELECTED, +} + +internal object AndroidVoiceKeyboardStatusResolver { + fun resolve( + serviceComponent: String, + enabledComponents: Collection, + selectedComponent: String?, + ): AndroidVoiceKeyboardStatus { + val target = normalize(serviceComponent) + val enabled = enabledComponents.any { normalize(it) == target } + if (!enabled) return AndroidVoiceKeyboardStatus.DISABLED + return if (normalize(selectedComponent) == target) { + AndroidVoiceKeyboardStatus.SELECTED + } else { + AndroidVoiceKeyboardStatus.ENABLED + } + } + + private fun normalize(value: String?): String? { + val (packageName, className) = value?.trim()?.split('/', limit = 2) + ?.takeIf { it.size == 2 } + ?: return null + val expandedClass = if (className.startsWith('.')) "$packageName$className" else className + return "$packageName/$expandedClass" + } +} + +class AndroidVoiceKeyboardStatusProvider(private val context: Context) { + private val serviceComponent = ComponentName(context, VoiceKeyboardInputMethodService::class.java) + + fun current(): AndroidVoiceKeyboardStatus { + val manager = context.getSystemService(InputMethodManager::class.java) + val enabled = manager?.enabledInputMethodList.orEmpty().map { it.id } + val selected = Settings.Secure.getString( + context.contentResolver, + Settings.Secure.DEFAULT_INPUT_METHOD, + ) + return AndroidVoiceKeyboardStatusResolver.resolve( + serviceComponent.flattenToString(), + enabled, + selected, + ) + } +} + +enum class AndroidVoiceKeyboardSystemAction { + ENABLE, + CHOOSE, +} + +internal object AndroidVoiceKeyboardActionPresenter { + fun systemAction(status: AndroidVoiceKeyboardStatus): AndroidVoiceKeyboardSystemAction = + if (status == AndroidVoiceKeyboardStatus.DISABLED) { + AndroidVoiceKeyboardSystemAction.ENABLE + } else { + AndroidVoiceKeyboardSystemAction.CHOOSE + } + + fun taskAction(status: AndroidVoiceKeyboardStatus): TaskActionKind = + if (status == AndroidVoiceKeyboardStatus.DISABLED) { + TaskActionKind.ENABLE_VOICE_KEYBOARD + } else { + TaskActionKind.CHOOSE_VOICE_KEYBOARD + } +} + +class AndroidVoiceKeyboardSystemGateway(private val context: Context) { + fun perform(action: AndroidVoiceKeyboardSystemAction): Boolean = runCatching { + when (action) { + AndroidVoiceKeyboardSystemAction.ENABLE -> context.startActivity( + Intent(Settings.ACTION_INPUT_METHOD_SETTINGS), + ) + AndroidVoiceKeyboardSystemAction.CHOOSE -> + context.getSystemService(InputMethodManager::class.java)?.showInputMethodPicker() + ?: error("Input method picker is unavailable") + } + }.isSuccess +} + +enum class AndroidVoiceKeyboardDiscoveryLifecycle { + ELIGIBLE, + SUPPRESSED, + DISMISSED, + COMPLETED, +} + +interface AndroidVoiceKeyboardDiscoveryStorage { + fun loadRaw(): String? + fun saveRaw(value: String) +} + +class AndroidVoiceKeyboardDiscoveryStore( + private val storage: AndroidVoiceKeyboardDiscoveryStorage, +) { + constructor(preferences: SharedPreferences) : this( + object : AndroidVoiceKeyboardDiscoveryStorage { + override fun loadRaw(): String? = preferences.getString(KEY_LIFECYCLE, null) + override fun saveRaw(value: String) { + preferences.edit().putString(KEY_LIFECYCLE, value).apply() + } + }, + ) + + fun loadOrInitialize( + onboardingLifecycle: AndroidOnboardingHintLifecycle, + ): AndroidVoiceKeyboardDiscoveryLifecycle { + val stored = storage.loadRaw()?.let(::decode) + if (stored != null) return stored + return if (onboardingLifecycle == AndroidOnboardingHintLifecycle.ACTIVE) { + AndroidVoiceKeyboardDiscoveryLifecycle.SUPPRESSED + } else { + AndroidVoiceKeyboardDiscoveryLifecycle.ELIGIBLE + }.also(::save) + } + + fun save(lifecycle: AndroidVoiceKeyboardDiscoveryLifecycle) { + storage.saveRaw(lifecycle.name.lowercase()) + } + + private fun decode(value: String): AndroidVoiceKeyboardDiscoveryLifecycle? = + AndroidVoiceKeyboardDiscoveryLifecycle.entries.firstOrNull { + it.name.equals(value, ignoreCase = true) + } + + companion object { + const val PREFERENCES_NAME = "android_voice_keyboard_discovery" + private const val KEY_LIFECYCLE = "lifecycle" + } +} + +data class AndroidVoiceKeyboardDiscoveryPresentation( + val visible: Boolean = false, + val title: String = "Dictate in any app", + val explanation: String = "Use Voice Inbox as a compact voice keyboard. Recognition runs locally after model setup.", + val setupLabel: String = "Set up keyboard", + val setupAction: TaskActionKind = TaskActionKind.ENABLE_VOICE_KEYBOARD, + val documentationLabel: String = "Learn more", +) { + companion object { + val HIDDEN = AndroidVoiceKeyboardDiscoveryPresentation() + } +} + +object AndroidVoiceKeyboardDiscoveryPresenter { + fun present( + lifecycle: AndroidVoiceKeyboardDiscoveryLifecycle, + filter: TaskListFilter, + model: ModelSetupSnapshot, + modelKnown: Boolean, + keyboardStatus: AndroidVoiceKeyboardStatus, + keyboardKnown: Boolean, + ): AndroidVoiceKeyboardDiscoveryPresentation { + if ( + lifecycle != AndroidVoiceKeyboardDiscoveryLifecycle.ELIGIBLE || + filter != TaskListFilter.NEW || + !modelKnown || + model.state != ModelSetupSnapshotState.READY || + !keyboardKnown || + keyboardStatus != AndroidVoiceKeyboardStatus.DISABLED + ) { + return AndroidVoiceKeyboardDiscoveryPresentation.HIDDEN + } + return AndroidVoiceKeyboardDiscoveryPresentation( + visible = true, + setupAction = AndroidVoiceKeyboardActionPresenter.taskAction(keyboardStatus), + ) + } + + fun shouldComplete( + lifecycle: AndroidVoiceKeyboardDiscoveryLifecycle, + keyboardStatus: AndroidVoiceKeyboardStatus, + keyboardKnown: Boolean, + ): Boolean = lifecycle == AndroidVoiceKeyboardDiscoveryLifecycle.ELIGIBLE && + keyboardKnown && + keyboardStatus != AndroidVoiceKeyboardStatus.DISABLED +} diff --git a/app/src/main/java/me/maxistar/voiceinbox/HybridRecordGestureCoordinator.kt b/app/src/main/java/me/maxistar/voiceinbox/HybridRecordGestureCoordinator.kt new file mode 100644 index 0000000..d83b7a9 --- /dev/null +++ b/app/src/main/java/me/maxistar/voiceinbox/HybridRecordGestureCoordinator.kt @@ -0,0 +1,97 @@ +package me.maxistar.voiceinbox + +enum class HybridRecordRelease { + LATCH, + STOP_AND_TRANSCRIBE, +} + +class HybridRecordGesturePolicy( + private val holdThresholdMillis: Long, +) { + init { + require(holdThresholdMillis >= 0) { "Hold threshold must not be negative" } + } + + fun classify(pressStartedAtMillis: Long, releasedAtMillis: Long): HybridRecordRelease = + if (releasedAtMillis - pressStartedAtMillis >= holdThresholdMillis) { + HybridRecordRelease.STOP_AND_TRANSCRIBE + } else { + HybridRecordRelease.LATCH + } +} + +enum class HybridRecordPreparationAction { + START_HELD, + START_LATCHED, + CANCEL, + IGNORE, +} + +data class HybridRecordReleaseEvent( + val generation: Long, + val release: HybridRecordRelease, +) + +/** Owns one touch pointer and keeps gesture decisions independent from Android callbacks. */ +class HybridRecordGestureCoordinator( + private val policy: HybridRecordGesturePolicy, +) { + private data class Session( + val pointerId: Int, + val generation: Long, + val pressedAtMillis: Long, + var pointerDown: Boolean = true, + var release: HybridRecordRelease? = null, + var cancelled: Boolean = false, + ) + + private var session: Session? = null + + fun begin(pointerId: Int, generation: Long, eventTimeMillis: Long): Boolean { + if (session != null) return false + session = Session(pointerId, generation, eventTimeMillis) + return true + } + + fun release(pointerId: Int, generation: Long, eventTimeMillis: Long): HybridRecordReleaseEvent? { + val active = session ?: return null + if (active.pointerId != pointerId || active.generation != generation || !active.pointerDown) return null + val release = policy.classify(active.pressedAtMillis, eventTimeMillis) + active.pointerDown = false + active.release = release + return HybridRecordReleaseEvent(generation, release) + } + + fun cancel(pointerId: Int, generation: Long): Boolean { + val active = session ?: return false + if (active.pointerId != pointerId || active.generation != generation) return false + active.pointerDown = false + active.cancelled = true + return true + } + + fun preparationAction(generation: Long): HybridRecordPreparationAction { + val active = session ?: return HybridRecordPreparationAction.IGNORE + if (active.generation != generation) return HybridRecordPreparationAction.IGNORE + return when { + active.cancelled -> HybridRecordPreparationAction.CANCEL + active.pointerDown -> HybridRecordPreparationAction.START_HELD + active.release == HybridRecordRelease.LATCH -> HybridRecordPreparationAction.START_LATCHED + else -> HybridRecordPreparationAction.CANCEL + } + } + + fun isPointerDown(generation: Long): Boolean = session?.let { + it.generation == generation && it.pointerDown && !it.cancelled + } == true + + fun isCurrent(generation: Long): Boolean = session?.generation == generation + + fun finish(generation: Long) { + if (session?.generation == generation) session = null + } + + fun clear() { + session = null + } +} diff --git a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt index 05564f3..dbf75ed 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt @@ -61,6 +61,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen private lateinit var startupPolicyStore: StartupProcessingPolicyStore private lateinit var startupCoordinator: StartupProcessingCoordinator private lateinit var onboardingHintStore: AndroidOnboardingHintStore + private lateinit var keyboardDiscoveryStore: AndroidVoiceKeyboardDiscoveryStore + private lateinit var keyboardStatusProvider: AndroidVoiceKeyboardStatusProvider + private lateinit var keyboardSystemGateway: AndroidVoiceKeyboardSystemGateway private val folderExecutor = Executors.newSingleThreadExecutor() private val importExecutor = Executors.newSingleThreadExecutor() @@ -129,6 +132,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen private val stopRefreshIndicator = Runnable(::stopRefreshIndicatorAnimation) private val queuedImportUris = mutableListOf() private var onboardingHintLifecycle = AndroidOnboardingHintLifecycle.DISMISSED + private var keyboardDiscoveryLifecycle = AndroidVoiceKeyboardDiscoveryLifecycle.SUPPRESSED + private var keyboardStatus = AndroidVoiceKeyboardStatus.DISABLED + private var keyboardStatusKnown = false private var pendingModelPackageUri: Uri? = null private var pendingModelPackageCatalogId: String? = null @@ -201,6 +207,13 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen getSharedPreferences(AndroidOnboardingHintStore.PREFERENCES_NAME, MODE_PRIVATE), ) onboardingHintLifecycle = onboardingHintStore.load() + keyboardDiscoveryStore = AndroidVoiceKeyboardDiscoveryStore( + getSharedPreferences(AndroidVoiceKeyboardDiscoveryStore.PREFERENCES_NAME, MODE_PRIVATE), + ) + keyboardDiscoveryLifecycle = keyboardDiscoveryStore.loadOrInitialize(onboardingHintLifecycle) + keyboardStatusProvider = AndroidVoiceKeyboardStatusProvider(this) + keyboardSystemGateway = AndroidVoiceKeyboardSystemGateway(this) + refreshKeyboardStatus(publish = false) startupCoordinator = StartupProcessingCoordinator.restore( savedInstanceState?.getString(STATE_STARTUP_PROCESSING_STAGE), ) @@ -262,6 +275,11 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen } } + override fun onResume() { + super.onResume() + if (::keyboardStatusProvider.isInitialized) refreshKeyboardStatus(publish = true) + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) @@ -337,7 +355,11 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen allTab = findViewById(R.id.allTab) taskFilters = findViewById(R.id.taskFilters) taskList = findViewById(R.id.taskList) - taskAdapter = TaskListAdapter(::handleTaskAction, ::dismissOnboardingHint) + taskAdapter = TaskListAdapter( + ::handleTaskAction, + ::dismissOnboardingHint, + ::dismissKeyboardDiscovery, + ) taskList.layoutManager = LinearLayoutManager(this) taskList.adapter = taskAdapter (taskList.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false @@ -1411,11 +1433,23 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen modelSnapshot, outputSnapshot, folderSnapshot, + keyboardStatus, + keyboardStatusKnown, ) ) { onboardingHintLifecycle = AndroidOnboardingHintLifecycle.COMPLETED onboardingHintStore.save(onboardingHintLifecycle) } + if ( + AndroidVoiceKeyboardDiscoveryPresenter.shouldComplete( + keyboardDiscoveryLifecycle, + keyboardStatus, + keyboardStatusKnown, + ) + ) { + keyboardDiscoveryLifecycle = AndroidVoiceKeyboardDiscoveryLifecycle.COMPLETED + keyboardDiscoveryStore.save(keyboardDiscoveryLifecycle) + } taskStateHost.update { current -> current.copy( model = modelSnapshot, @@ -1443,6 +1477,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen }, ), onboardingLifecycle = onboardingHintLifecycle, + keyboardStatus = keyboardStatus, + keyboardKnown = keyboardStatusKnown, + keyboardDiscoveryLifecycle = keyboardDiscoveryLifecycle, ) } } @@ -1459,7 +1496,13 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen renderingFilter = false importAudio.isEnabled = state.importEnabled updateTaskListAnimation(state.transcriptionActive) - taskAdapter.submitList(TaskListDisplayItems.from(state.taskList, state.onboardingHint)) + taskAdapter.submitList( + TaskListDisplayItems.from( + state.taskList, + state.onboardingHint, + state.keyboardDiscovery, + ), + ) invalidateOptionsMenu() } @@ -1498,6 +1541,38 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen publishTaskState() } + private fun dismissKeyboardDiscovery() { + if (keyboardDiscoveryLifecycle != AndroidVoiceKeyboardDiscoveryLifecycle.ELIGIBLE) return + keyboardDiscoveryLifecycle = AndroidVoiceKeyboardDiscoveryLifecycle.DISMISSED + keyboardDiscoveryStore.save(keyboardDiscoveryLifecycle) + publishTaskState() + } + + private fun refreshKeyboardStatus(publish: Boolean) { + keyboardStatus = keyboardStatusProvider.current() + keyboardStatusKnown = true + if (publish) publishTaskState() + } + + private fun performKeyboardSystemAction() { + val action = AndroidVoiceKeyboardActionPresenter.systemAction(keyboardStatusProvider.current()) + if (!keyboardSystemGateway.perform(action)) { + Toast.makeText(this, R.string.voice_keyboard_system_action_error, Toast.LENGTH_LONG).show() + } + } + + private fun openVoiceKeyboardDocumentation() { + runCatching { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(VoiceInboxPublicLinks.VOICE_KEYBOARD_DOCUMENTATION))) + }.onFailure { error -> + if (error is android.content.ActivityNotFoundException) { + Toast.makeText(this, R.string.settings_about_link_error, Toast.LENGTH_LONG).show() + } else { + throw error + } + } + } + private fun performTaskAction(kind: TaskActionKind, entry: AudioCatalogEntry?) { when (kind) { TaskActionKind.DOWNLOAD_MODEL, @@ -1523,6 +1598,10 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen TaskActionKind.PLAY -> entry?.let(::startPreviewPlayback) TaskActionKind.STOP -> stopPreviewPlayback(render = true) TaskActionKind.SHOW_TEXT -> entry?.let(::showTranscriptText) + TaskActionKind.ENABLE_VOICE_KEYBOARD, + TaskActionKind.CHOOSE_VOICE_KEYBOARD, + -> performKeyboardSystemAction() + TaskActionKind.OPEN_VOICE_KEYBOARD_DOCUMENTATION -> openVoiceKeyboardDocumentation() } } diff --git a/app/src/main/java/me/maxistar/voiceinbox/SettingsActivity.kt b/app/src/main/java/me/maxistar/voiceinbox/SettingsActivity.kt index 8ad3818..26089d8 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/SettingsActivity.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/SettingsActivity.kt @@ -24,6 +24,7 @@ import java.util.concurrent.Executors internal object VoiceInboxPublicLinks { const val WEBSITE = "https://voiceinbox.simpleditor.org/" const val DOCUMENTATION = "https://voiceinbox.simpleditor.org/docs/" + const val VOICE_KEYBOARD_DOCUMENTATION = "https://voiceinbox.simpleditor.org/docs/voice-keyboard/" const val LEGAL = "https://voiceinbox.simpleditor.org/legal/" } @@ -39,6 +40,10 @@ class SettingsActivity : AppCompatActivity() { private lateinit var scheduledSwitch: SwitchCompat private lateinit var scheduledTime: TextView private lateinit var scheduledTimeDetail: TextView + private lateinit var keyboardStatusView: TextView + private lateinit var keyboardActionView: TextView + private lateinit var keyboardStatusProvider: AndroidVoiceKeyboardStatusProvider + private lateinit var keyboardSystemGateway: AndroidVoiceKeyboardSystemGateway private val selectionExecutor = Executors.newSingleThreadExecutor() private var settings = ScheduledTranscriptionSettings() private var activityDestroyed = false @@ -87,6 +92,8 @@ class SettingsActivity : AppCompatActivity() { ) documentAccess = DocumentAccess(contentResolver) folderScanner = AudioFolderScanner(contentResolver) + keyboardStatusProvider = AndroidVoiceKeyboardStatusProvider(this) + keyboardSystemGateway = AndroidVoiceKeyboardSystemGateway(this) settings = settingsStore.load() folderDetail = findViewById(R.id.settingsFolderDetail) outputDetail = findViewById(R.id.settingsOutputDetail) @@ -94,6 +101,8 @@ class SettingsActivity : AppCompatActivity() { scheduledTime = findViewById(R.id.scheduledTime) scheduledTimeDetail = findViewById(R.id.scheduledTimeDetail) scheduledSwitch = findViewById(R.id.scheduledSwitch) + keyboardStatusView = findViewById(R.id.settingsVoiceKeyboardStatus) + keyboardActionView = findViewById(R.id.settingsVoiceKeyboardAction) findViewById(R.id.settingsAboutVersion).text = getString( R.string.settings_about_version, appVersionName(), @@ -110,6 +119,15 @@ class SettingsActivity : AppCompatActivity() { .putExtra("open-model-folder-picker", true), ) } + keyboardActionView.setOnClickListener { + val action = AndroidVoiceKeyboardActionPresenter.systemAction(keyboardStatusProvider.current()) + if (!keyboardSystemGateway.perform(action)) { + Toast.makeText(this, R.string.voice_keyboard_system_action_error, Toast.LENGTH_LONG).show() + } + } + findViewById(R.id.settingsVoiceKeyboardDocumentation).setOnClickListener { + openExternalUrl(VoiceInboxPublicLinks.VOICE_KEYBOARD_DOCUMENTATION) + } findViewById(R.id.settingsWebsiteRow).setOnClickListener { openExternalUrl(VoiceInboxPublicLinks.WEBSITE) } @@ -143,11 +161,13 @@ class SettingsActivity : AppCompatActivity() { } renderStorage() renderSchedule() + renderVoiceKeyboard() } override fun onResume() { super.onResume() renderStorage() + renderVoiceKeyboard() } override fun onOptionsItemSelected(item: MenuItem): Boolean = @@ -254,6 +274,24 @@ class SettingsActivity : AppCompatActivity() { renderModel() } + private fun renderVoiceKeyboard() { + val status = keyboardStatusProvider.current() + keyboardStatusView.setText( + when (status) { + AndroidVoiceKeyboardStatus.DISABLED -> R.string.settings_voice_keyboard_disabled + AndroidVoiceKeyboardStatus.ENABLED -> R.string.settings_voice_keyboard_enabled + AndroidVoiceKeyboardStatus.SELECTED -> R.string.settings_voice_keyboard_selected + }, + ) + keyboardActionView.setText( + when (status) { + AndroidVoiceKeyboardStatus.DISABLED -> R.string.settings_voice_keyboard_enable + AndroidVoiceKeyboardStatus.ENABLED -> R.string.settings_voice_keyboard_choose + AndroidVoiceKeyboardStatus.SELECTED -> R.string.settings_voice_keyboard_change + }, + ) + } + private fun renderModel() { modelDetail.text = when (val state = SpeechModelRepository.forActive( noBackupFilesDir.resolve("models"), diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelWarmup.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelWarmup.kt index efe60f1..6916763 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelWarmup.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelWarmup.kt @@ -29,6 +29,18 @@ internal class SpeechModelWarmupCoordinator( fun state(): SpeechModelWarmupState = synchronized(lock) { currentState } + fun state(repository: SpeechModelRepository): SpeechModelWarmupState { + val installation = installationIdentity(repository) + return synchronized(lock) { + when (val state = currentState) { + is SpeechModelWarmupState.Preparing -> state.takeIf { it.installation == installation } + is SpeechModelWarmupState.Ready -> state.takeIf { it.installation == installation } + is SpeechModelWarmupState.Failed -> state.takeIf { it.installation == installation } + SpeechModelWarmupState.Idle -> state + } ?: SpeechModelWarmupState.Idle + } + } + fun warmUp(repository: SpeechModelRepository) { prepare(repository, retryFailed = false) } @@ -96,6 +108,8 @@ internal object SpeechModelWarmup { retryFailed: Boolean, ): Future> = coordinator.prepare(repository, retryFailed) + fun state(repository: SpeechModelRepository): SpeechModelWarmupState = coordinator.state(repository) + fun invalidate() { SpeechModelPreparation.invalidate(NativeTranscriptionBridge::reset) coordinator.invalidate() diff --git a/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt b/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt index a86abb6..d4f734a 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt @@ -56,6 +56,16 @@ sealed class TaskListDisplayItem { } } + data class KeyboardDiscovery( + val presentation: AndroidVoiceKeyboardDiscoveryPresentation, + ) : TaskListDisplayItem() { + override val stableKey: String = STABLE_KEY + + companion object { + const val STABLE_KEY = "hint:android-voice-keyboard" + } + } + data class Empty( val message: String, val actions: List, @@ -72,6 +82,7 @@ object TaskListDisplayItems { fun from( state: TaskListState, onboardingHint: AndroidOnboardingHintPresentation = AndroidOnboardingHintPresentation.HIDDEN, + keyboardDiscovery: AndroidVoiceKeyboardDiscoveryPresentation = AndroidVoiceKeyboardDiscoveryPresentation.HIDDEN, ): List { return buildList { state.tasks.filterIsInstance().forEach { @@ -80,6 +91,9 @@ object TaskListDisplayItems { if (onboardingHint.visible) { add(TaskListDisplayItem.OnboardingHint(onboardingHint)) } + if (keyboardDiscovery.visible) { + add(TaskListDisplayItem.KeyboardDiscovery(keyboardDiscovery)) + } if (state.batchAction.visible) { add( TaskListDisplayItem.BatchAction( @@ -131,6 +145,7 @@ sealed class TaskListChangePayload { class TaskListAdapter( private val onAction: (AndroidTaskActionRequest) -> Unit, private val onDismissOnboarding: () -> Unit, + private val onDismissKeyboardDiscovery: () -> Unit, ) : ListAdapter(TaskListDisplayItemDiff) { init { setHasStableIds(true) @@ -143,6 +158,7 @@ class TaskListAdapter( is TaskListDisplayItem.Audio -> VIEW_AUDIO is TaskListDisplayItem.BatchAction -> VIEW_BATCH is TaskListDisplayItem.OnboardingHint -> VIEW_ONBOARDING + is TaskListDisplayItem.KeyboardDiscovery -> VIEW_KEYBOARD_DISCOVERY is TaskListDisplayItem.Empty -> VIEW_EMPTY } @@ -160,6 +176,11 @@ class TaskListAdapter( onAction, onDismissOnboarding, ) + VIEW_KEYBOARD_DISCOVERY -> KeyboardDiscoveryViewHolder( + inflater.inflate(R.layout.row_voice_keyboard_discovery, parent, false), + onAction, + onDismissKeyboardDiscovery, + ) VIEW_EMPTY -> EmptyTaskViewHolder( inflater.inflate(R.layout.row_empty_task, parent, false), onAction, @@ -174,6 +195,7 @@ class TaskListAdapter( is TaskListDisplayItem.Audio -> (holder as AudioTaskViewHolder).bind(item.task) is TaskListDisplayItem.BatchAction -> (holder as BatchActionViewHolder).bind(item) is TaskListDisplayItem.OnboardingHint -> (holder as OnboardingHintViewHolder).bind(item) + is TaskListDisplayItem.KeyboardDiscovery -> (holder as KeyboardDiscoveryViewHolder).bind(item) is TaskListDisplayItem.Empty -> (holder as EmptyTaskViewHolder).bind(item) } } @@ -322,6 +344,7 @@ class TaskListAdapter( private val modelStep: TextView = itemView.findViewById(R.id.onboardingModelStep) private val outputStep: TextView = itemView.findViewById(R.id.onboardingOutputStep) private val folderStep: TextView = itemView.findViewById(R.id.onboardingFolderStep) + private val keyboardStep: TextView = itemView.findViewById(R.id.onboardingKeyboardStep) private val action: MaterialButton = itemView.findViewById(R.id.onboardingAction) private val close: ImageButton = itemView.findViewById(R.id.onboardingClose) @@ -338,6 +361,7 @@ class TaskListAdapter( bindStep(modelStep, presentation.steps.firstOrNull { it.kind == AndroidOnboardingStepKind.MODEL }) bindStep(outputStep, presentation.steps.firstOrNull { it.kind == AndroidOnboardingStepKind.OUTPUT }) bindStep(folderStep, presentation.steps.firstOrNull { it.kind == AndroidOnboardingStepKind.FOLDER }) + bindStep(keyboardStep, presentation.steps.firstOrNull { it.kind == AndroidOnboardingStepKind.KEYBOARD }) action.text = presentation.action?.label.orEmpty() action.isEnabled = presentation.action?.enabled == true action.isVisible = presentation.action != null @@ -362,6 +386,41 @@ class TaskListAdapter( } } + private class KeyboardDiscoveryViewHolder( + itemView: View, + private val onAction: (AndroidTaskActionRequest) -> Unit, + onDismiss: () -> Unit, + ) : RecyclerView.ViewHolder(itemView) { + private val title: TextView = itemView.findViewById(R.id.keyboardDiscoveryTitle) + private val explanation: TextView = itemView.findViewById(R.id.keyboardDiscoveryExplanation) + private val setup: MaterialButton = itemView.findViewById(R.id.keyboardDiscoverySetup) + private val documentation: MaterialButton = itemView.findViewById(R.id.keyboardDiscoveryDocumentation) + + init { + itemView.findViewById(R.id.keyboardDiscoveryClose).setOnClickListener { onDismiss() } + } + + fun bind(item: TaskListDisplayItem.KeyboardDiscovery) { + val presentation = item.presentation + title.text = presentation.title + explanation.text = presentation.explanation + setup.text = presentation.setupLabel + documentation.text = presentation.documentationLabel + setup.setOnClickListener { + onAction(AndroidTaskActionRequest(item.stableKey, null, presentation.setupAction)) + } + documentation.setOnClickListener { + onAction( + AndroidTaskActionRequest( + item.stableKey, + null, + TaskActionKind.OPEN_VOICE_KEYBOARD_DOCUMENTATION, + ), + ) + } + } + } + private class EmptyTaskViewHolder( itemView: View, private val onAction: (AndroidTaskActionRequest) -> Unit, @@ -399,6 +458,7 @@ class TaskListAdapter( const val VIEW_BATCH = 3 const val VIEW_EMPTY = 4 const val VIEW_ONBOARDING = 5 + const val VIEW_KEYBOARD_DISCOVERY = 6 fun stableLongId(value: String): Long { var hash = -0x340d631b8c46753bL diff --git a/app/src/main/java/me/maxistar/voiceinbox/VoiceKeyboardController.kt b/app/src/main/java/me/maxistar/voiceinbox/VoiceKeyboardController.kt index 5909f18..bfb9040 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/VoiceKeyboardController.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/VoiceKeyboardController.kt @@ -2,8 +2,8 @@ package me.maxistar.voiceinbox enum class VoiceKeyboardPhase { IDLE, - PREPARING, RECORDING, + WAITING_FOR_MODEL, TRANSCRIBING, RESULT_PENDING, ERROR, @@ -17,20 +17,20 @@ class VoiceKeyboardController { var pendingText: String? = null private set - fun beginPreparation(): Boolean { + fun beginRecording(): Boolean { if (phase !in setOf(VoiceKeyboardPhase.IDLE, VoiceKeyboardPhase.ERROR)) return false - phase = VoiceKeyboardPhase.PREPARING - return true - } - - fun recordingStarted(): Boolean { - if (phase != VoiceKeyboardPhase.PREPARING) return false phase = VoiceKeyboardPhase.RECORDING return true } fun recordingStopped(): Boolean { if (phase != VoiceKeyboardPhase.RECORDING) return false + phase = VoiceKeyboardPhase.WAITING_FOR_MODEL + return true + } + + fun modelReady(): Boolean { + if (phase != VoiceKeyboardPhase.WAITING_FOR_MODEL) return false phase = VoiceKeyboardPhase.TRANSCRIBING return true } diff --git a/app/src/main/java/me/maxistar/voiceinbox/VoiceKeyboardInputMethodService.kt b/app/src/main/java/me/maxistar/voiceinbox/VoiceKeyboardInputMethodService.kt index 213ef7a..f4610e4 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/VoiceKeyboardInputMethodService.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/VoiceKeyboardInputMethodService.kt @@ -11,6 +11,7 @@ import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent import android.view.View +import android.view.ViewConfiguration import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection import android.view.inputmethod.InputMethodManager @@ -18,13 +19,32 @@ import android.widget.Button import android.widget.ImageButton import android.widget.ProgressBar import android.widget.TextView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import java.io.File import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import java.util.concurrent.Future class VoiceKeyboardInputMethodService : InputMethodService() { private val controller = VoiceKeyboardController() private val mainHandler = Handler(Looper.getMainLooper()) private val workExecutor: ExecutorService = Executors.newSingleThreadExecutor() + private val passiveWarmupScheduler = VoiceKeyboardWarmupScheduler( + scheduler = object : VoiceKeyboardDelayScheduler { + override fun postDelayed(runnable: Runnable, delayMillis: Long) { + mainHandler.postDelayed(runnable, delayMillis) + } + + override fun removeCallbacks(runnable: Runnable) { + mainHandler.removeCallbacks(runnable) + } + }, + delayMillis = PASSIVE_WARMUP_DELAY_MS, + ) + private val recordGestureCoordinator = HybridRecordGestureCoordinator( + HybridRecordGesturePolicy(ViewConfiguration.getLongPressTimeout().toLong()), + ) private val backspaceRepeater = VoiceKeyboardBackspaceRepeater( scheduler = object : VoiceKeyboardRepeatScheduler { override fun postDelayed(runnable: Runnable, delayMillis: Long) { @@ -39,9 +59,14 @@ class VoiceKeyboardInputMethodService : InputMethodService() { ) private lateinit var recorder: VoiceKeyboardAudioRecorder private var inputActive = false + private var inputViewActive = false private var requestGeneration = 0L - private var warmUpGeneration = 0L - private var warmUpActive = false + private var requestPreparation: Future>? = null + private var durationLimitCallback: Runnable? = null + private var activeTouchPointerId: Int? = null + private var activeTouchGeneration: Long? = null + private var handledRecordingStopTouch = false + private var suppressNextRecordClick = false private var statusView: TextView? = null private var progressView: ProgressBar? = null @@ -60,6 +85,7 @@ class VoiceKeyboardInputMethodService : InputMethodService() { override fun onCreateInputView(): View { val view = LayoutInflater.from(this).inflate(R.layout.input_view_voice_keyboard, null) + applyNavigationBarInsets(view) statusView = view.findViewById(R.id.voiceKeyboardStatus) progressView = view.findViewById(R.id.voiceKeyboardProgress) recordButton = view.findViewById(R.id.voiceKeyboardRecord) @@ -70,7 +96,14 @@ class VoiceKeyboardInputMethodService : InputMethodService() { enterButton = view.findViewById(R.id.voiceKeyboardEnter) nextKeyboardButton = view.findViewById(R.id.voiceKeyboardNextKeyboard) - recordButton?.setOnClickListener { handleRecordButton() } + recordButton?.setOnClickListener { + if (suppressNextRecordClick) { + suppressNextRecordClick = false + } else { + handleRecordButtonActivation() + } + } + recordButton?.setOnTouchListener(::handleRecordButtonTouch) dismissButton?.setOnClickListener { controller.dismissPendingResult() render(R.string.voice_keyboard_ready) @@ -98,6 +131,26 @@ class VoiceKeyboardInputMethodService : InputMethodService() { return view } + private fun applyNavigationBarInsets(view: View) { + val initialPaddingLeft = view.paddingLeft + val initialPaddingTop = view.paddingTop + val initialPaddingRight = view.paddingRight + val initialPaddingBottom = view.paddingBottom + ViewCompat.setOnApplyWindowInsetsListener(view) { target, insets -> + val navigationBarBottom = insets + .getInsets(WindowInsetsCompat.Type.navigationBars()) + .bottom + target.setPadding( + initialPaddingLeft, + initialPaddingTop, + initialPaddingRight, + initialPaddingBottom + navigationBarBottom, + ) + insets + } + ViewCompat.requestApplyInsets(view) + } + override fun onStartInput(attribute: EditorInfo, restarting: Boolean) { super.onStartInput(attribute, restarting) inputActive = true @@ -106,38 +159,45 @@ class VoiceKeyboardInputMethodService : InputMethodService() { override fun onStartInputView(info: EditorInfo, restarting: Boolean) { super.onStartInputView(info, restarting) inputActive = true + inputViewActive = true flushPendingResult() - mainHandler.post(::startVisibleWarmUp) + schedulePassiveWarmUp() } override fun onFinishInput() { inputActive = false - warmUpGeneration += 1 - warmUpActive = false + inputViewActive = false + passiveWarmupScheduler.cancel() backspaceRepeater.cancel() + cancelActiveDictationForLifecycle() super.onFinishInput() } override fun onFinishInputView(finishingInput: Boolean) { + inputViewActive = false + passiveWarmupScheduler.cancel() backspaceRepeater.cancel() + cancelActiveDictationForLifecycle() super.onFinishInputView(finishingInput) } override fun onDestroy() { requestGeneration += 1 - warmUpGeneration += 1 + inputViewActive = false + passiveWarmupScheduler.cancel() + cancelDurationLimit() backspaceRepeater.cancel() + recordGestureCoordinator.clear() recorder.close() controller.cancel() workExecutor.shutdownNow() super.onDestroy() } - private fun handleRecordButton() { - if (warmUpActive) return + private fun handleRecordButtonActivation() { when (controller.phase) { VoiceKeyboardPhase.RECORDING -> stopRecording() - VoiceKeyboardPhase.PREPARING, + VoiceKeyboardPhase.WAITING_FOR_MODEL, VoiceKeyboardPhase.TRANSCRIBING, -> cancelCurrentRequest() VoiceKeyboardPhase.RESULT_PENDING -> { @@ -146,79 +206,231 @@ class VoiceKeyboardInputMethodService : InputMethodService() { } VoiceKeyboardPhase.IDLE, VoiceKeyboardPhase.ERROR, - -> prepareAndStartRecording() + -> startRecordingAndPrepare(requestGeneration + 1, touchInitiated = false) + } + } + + private fun handleRecordButtonTouch(view: View, event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + handledRecordingStopTouch = false + if (controller.phase == VoiceKeyboardPhase.RECORDING) { + handledRecordingStopTouch = true + stopRecording() + return true + } + if (controller.phase !in setOf(VoiceKeyboardPhase.IDLE, VoiceKeyboardPhase.ERROR)) return true + val pointerId = event.getPointerId(event.actionIndex) + val generation = requestGeneration + 1 + if (!recordGestureCoordinator.begin(pointerId, generation, event.eventTime)) return true + activeTouchPointerId = pointerId + activeTouchGeneration = generation + startRecordingAndPrepare(generation, touchInitiated = true) + } + + MotionEvent.ACTION_UP -> { + if (handledRecordingStopTouch) { + handledRecordingStopTouch = false + announceHandledTouchClick(view) + return true + } + finishRecordButtonTouch(view, event.getPointerId(event.actionIndex), event.eventTime) + } + + MotionEvent.ACTION_CANCEL -> { + val pointerId = activeTouchPointerId + val generation = activeTouchGeneration + activeTouchPointerId = null + activeTouchGeneration = null + handledRecordingStopTouch = false + if (pointerId != null && generation != null && recordGestureCoordinator.cancel(pointerId, generation)) { + cancelCurrentRequest(generation) + } + } + + MotionEvent.ACTION_POINTER_UP -> { + finishRecordButtonTouch(view, event.getPointerId(event.actionIndex), event.eventTime) + } + + MotionEvent.ACTION_POINTER_DOWN -> Unit } + return true } - private fun prepareAndStartRecording() { + private fun finishRecordButtonTouch(view: View, pointerId: Int, eventTimeMillis: Long) { + val activePointerId = activeTouchPointerId ?: return + val generation = activeTouchGeneration ?: return + if (pointerId != activePointerId) return + val release = recordGestureCoordinator.release(pointerId, generation, eventTimeMillis) + activeTouchPointerId = null + activeTouchGeneration = null + when (release?.release) { + HybridRecordRelease.LATCH -> { + if (generation == requestGeneration) { + when (controller.phase) { + VoiceKeyboardPhase.RECORDING -> render(R.string.voice_keyboard_listening_latched) + else -> Unit + } + } + announceHandledTouchClick(view) + } + HybridRecordRelease.STOP_AND_TRANSCRIBE -> { + when { + generation != requestGeneration -> recordGestureCoordinator.finish(generation) + controller.phase == VoiceKeyboardPhase.RECORDING -> stopRecording(generation) + else -> recordGestureCoordinator.finish(generation) + } + } + null -> Unit + } + } + + private fun announceHandledTouchClick(view: View) { + suppressNextRecordClick = true + if (!view.performClick()) suppressNextRecordClick = false + } + + private fun startRecordingAndPrepare(generation: Long, touchInitiated: Boolean) { if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + recordGestureCoordinator.finish(generation) controller.fail() render(R.string.voice_keyboard_microphone_permission_missing, showSetup = true) return } - if (!controller.beginPreparation()) return - val generation = ++requestGeneration - render(R.string.voice_keyboard_preparing) - workExecutor.execute { - val preparation = prepareModelForDictation() - mainHandler.post { - if (generation != requestGeneration || controller.phase != VoiceKeyboardPhase.PREPARING) return@post - preparation.onSuccess { - recorder.start().onSuccess { - if (controller.recordingStarted()) { - render(R.string.voice_keyboard_listening) - mainHandler.postDelayed({ stopForDurationLimit(generation) }, MAX_PHRASE_DURATION_MS) - } - }.onFailure { - controller.fail() - render(R.string.voice_keyboard_microphone_unavailable, showSetup = false) - } - }.onFailure { - controller.fail() - render(R.string.voice_keyboard_model_unavailable, showSetup = true) - } - } + + val repository = SpeechModelRepository.forActive(noBackupFilesDir.resolve("models")) + if (repository.inspectLightweight() !is InstalledSpeechModelState.Ready) { + recordGestureCoordinator.finish(generation) + controller.fail() + render(R.string.voice_keyboard_model_unavailable, showSetup = true) + return + } + if (!controller.beginRecording()) return + + requestGeneration = generation + passiveWarmupScheduler.cancel() + requestPreparation = SpeechModelWarmup.prepare(repository, retryFailed = true) + val preparationAction = if (touchInitiated) { + recordGestureCoordinator.preparationAction(generation) + } else { + HybridRecordPreparationAction.START_LATCHED + } + if (preparationAction in setOf( + HybridRecordPreparationAction.CANCEL, + HybridRecordPreparationAction.IGNORE, + ) + ) { + cancelCurrentRequest(generation) + return + } + recorder.start().onSuccess { + render( + if (preparationAction == HybridRecordPreparationAction.START_HELD) { + R.string.voice_keyboard_listening_held + } else { + R.string.voice_keyboard_listening_latched + }, + ) + scheduleDurationLimit(generation) + }.onFailure { + recordGestureCoordinator.finish(generation) + requestPreparation = null + controller.fail() + render(R.string.voice_keyboard_microphone_unavailable, showSetup = false) } } - private fun startVisibleWarmUp() { - if (warmUpActive || controller.phase != VoiceKeyboardPhase.IDLE) return - val generation = ++warmUpGeneration - warmUpActive = true - render(R.string.voice_keyboard_preparing) - workExecutor.execute { + private fun schedulePassiveWarmUp() { + passiveWarmupScheduler.schedule { + if (!inputViewActive || controller.phase != VoiceKeyboardPhase.IDLE) return@schedule val repository = SpeechModelRepository.forActive(noBackupFilesDir.resolve("models")) - val preparation = SpeechModelWarmup.prepare(repository, retryFailed = false).get() - mainHandler.post { - if (generation != warmUpGeneration) return@post - warmUpActive = false - render(R.string.voice_keyboard_ready) + if (repository.inspectLightweight() is InstalledSpeechModelState.Ready) { + SpeechModelWarmup.prepare(repository, retryFailed = false) } } } - private fun prepareModelForDictation(): Result = runCatching { - val repository = SpeechModelRepository.forActive(noBackupFilesDir.resolve("models")) - SpeechModelWarmup.prepare(repository, retryFailed = true).get().getOrThrow() + private fun scheduleDurationLimit(generation: Long) { + cancelDurationLimit() + durationLimitCallback = Runnable { stopForDurationLimit(generation) }.also { + mainHandler.postDelayed(it, MAX_PHRASE_DURATION_MS) + } + } + + private fun cancelDurationLimit() { + durationLimitCallback?.let(mainHandler::removeCallbacks) + durationLimitCallback = null } private fun stopForDurationLimit(generation: Long) { if (generation == requestGeneration && controller.phase == VoiceKeyboardPhase.RECORDING) { - stopRecording() + stopRecording(generation) } } - private fun stopRecording() { + private fun stopRecording(expectedGeneration: Long = requestGeneration) { + if (expectedGeneration != requestGeneration) return if (!controller.recordingStopped()) return - mainHandler.removeCallbacksAndMessages(null) - val generation = requestGeneration - render(R.string.voice_keyboard_transcribing) + recordGestureCoordinator.finish(expectedGeneration) + activeTouchPointerId = null + activeTouchGeneration = null + cancelDurationLimit() + val generation = expectedGeneration + val preparation = requestPreparation + render( + if (preparation?.isDone == true && runCatching { preparation.get().isSuccess }.getOrDefault(false)) { + R.string.voice_keyboard_transcribing + } else { + R.string.voice_keyboard_preparing + }, + ) workExecutor.execute { - val result = recorder.stop().mapCatching { samples -> - if (samples.isEmpty()) null else NativeTranscriptionBridge.transcribeChunk(samples)?.text + val samplesResult = recorder.stop() + val samples = samplesResult.getOrElse { error -> + mainHandler.post { + if (generation != requestGeneration) return@post + controller.fail() + requestPreparation = null + render(R.string.voice_keyboard_transcription_failed) + } + return@execute } + if (samples.isEmpty()) { + mainHandler.post { + if (generation != requestGeneration) return@post + requestPreparation = null + controller.cancel() + render(R.string.voice_keyboard_no_speech) + } + return@execute + } + val preparationResult = runCatching { + checkNotNull(preparation) { "Speech model preparation was not started" } + preparation.get().getOrThrow() + } + mainHandler.post { + if (generation != requestGeneration || controller.phase != VoiceKeyboardPhase.WAITING_FOR_MODEL) { + return@post + } + preparationResult.onFailure { + requestPreparation = null + controller.fail() + render(R.string.voice_keyboard_model_unavailable, showSetup = true) + }.onSuccess { + if (!controller.modelReady()) return@onSuccess + render(R.string.voice_keyboard_transcribing) + transcribeCapturedSamples(generation, samples) + } + } + } + } + + private fun transcribeCapturedSamples(generation: Long, samples: FloatArray) { + workExecutor.execute { + val result = runCatching { NativeTranscriptionBridge.transcribeChunk(samples)?.text } mainHandler.post { if (generation != requestGeneration || controller.phase != VoiceKeyboardPhase.TRANSCRIBING) return@post + requestPreparation = null result.onSuccess { text -> val completed = controller.completeTranscription(text) when { @@ -237,16 +449,36 @@ class VoiceKeyboardInputMethodService : InputMethodService() { } } - private fun cancelCurrentRequest() { + private fun cancelCurrentRequest(expectedGeneration: Long = requestGeneration) { + if (expectedGeneration != requestGeneration) return requestGeneration += 1 - mainHandler.removeCallbacksAndMessages(null) + cancelDurationLimit() if (controller.phase == VoiceKeyboardPhase.RECORDING) { workExecutor.execute { recorder.cancel() } } + requestPreparation = null + recordGestureCoordinator.finish(expectedGeneration) + activeTouchPointerId = null + activeTouchGeneration = null controller.cancel() render(R.string.voice_keyboard_ready) } + private fun cancelActiveDictationForLifecycle() { + if (controller.phase in setOf( + VoiceKeyboardPhase.RECORDING, + VoiceKeyboardPhase.WAITING_FOR_MODEL, + VoiceKeyboardPhase.TRANSCRIBING, + ) + ) { + cancelCurrentRequest() + } else { + recordGestureCoordinator.clear() + activeTouchPointerId = null + activeTouchGeneration = null + } + } + private fun flushPendingResult() { val pending = controller.pendingResult() ?: return if (commitToCurrentEditor(pending)) { @@ -266,7 +498,7 @@ class VoiceKeyboardInputMethodService : InputMethodService() { } private fun isEditorReady(): Boolean = inputActive && controller.phase !in setOf( - VoiceKeyboardPhase.PREPARING, + VoiceKeyboardPhase.WAITING_FOR_MODEL, VoiceKeyboardPhase.TRANSCRIBING, ) @@ -307,46 +539,37 @@ class VoiceKeyboardInputMethodService : InputMethodService() { statusView?.setText(status) val phase = controller.phase recordButton?.apply { - val busy = warmUpActive || phase in setOf(VoiceKeyboardPhase.PREPARING, VoiceKeyboardPhase.TRANSCRIBING) - isEnabled = phase != VoiceKeyboardPhase.RESULT_PENDING && !warmUpActive + val busy = phase in setOf(VoiceKeyboardPhase.WAITING_FOR_MODEL, VoiceKeyboardPhase.TRANSCRIBING) + isEnabled = phase != VoiceKeyboardPhase.RESULT_PENDING alpha = if (isEnabled) 1f else 0.6f background = getDrawable( - when { - warmUpActive -> R.drawable.voice_keyboard_mic_busy - else -> when (phase) { + when (phase) { VoiceKeyboardPhase.RECORDING -> R.drawable.voice_keyboard_mic_recording - VoiceKeyboardPhase.PREPARING, + VoiceKeyboardPhase.WAITING_FOR_MODEL, VoiceKeyboardPhase.TRANSCRIBING, VoiceKeyboardPhase.RESULT_PENDING, -> R.drawable.voice_keyboard_mic_busy VoiceKeyboardPhase.ERROR -> R.drawable.voice_keyboard_mic_error VoiceKeyboardPhase.IDLE -> R.drawable.voice_keyboard_mic_idle - } }, ) setImageResource( - when { - warmUpActive -> R.drawable.ic_voice_keyboard_mic - else -> when (phase) { + when (phase) { VoiceKeyboardPhase.RECORDING, - VoiceKeyboardPhase.PREPARING, + VoiceKeyboardPhase.WAITING_FOR_MODEL, VoiceKeyboardPhase.TRANSCRIBING, -> R.drawable.ic_voice_keyboard_stop else -> R.drawable.ic_voice_keyboard_mic - } }, ) contentDescription = context.getString( - when { - warmUpActive -> R.string.voice_keyboard_preparing - else -> when (phase) { + when (phase) { VoiceKeyboardPhase.RECORDING -> R.string.voice_keyboard_stop - VoiceKeyboardPhase.PREPARING, + VoiceKeyboardPhase.WAITING_FOR_MODEL, VoiceKeyboardPhase.TRANSCRIBING, -> R.string.voice_keyboard_cancel VoiceKeyboardPhase.ERROR -> R.string.voice_keyboard_record else -> R.string.voice_keyboard_record - } }, ) keepScreenOn = phase == VoiceKeyboardPhase.RECORDING @@ -363,6 +586,7 @@ class VoiceKeyboardInputMethodService : InputMethodService() { } private companion object { + const val PASSIVE_WARMUP_DELAY_MS = 400L const val MAX_PHRASE_DURATION_MS = 30_000L } } diff --git a/app/src/main/java/me/maxistar/voiceinbox/VoiceKeyboardWarmupScheduler.kt b/app/src/main/java/me/maxistar/voiceinbox/VoiceKeyboardWarmupScheduler.kt new file mode 100644 index 0000000..77eec52 --- /dev/null +++ b/app/src/main/java/me/maxistar/voiceinbox/VoiceKeyboardWarmupScheduler.kt @@ -0,0 +1,32 @@ +package me.maxistar.voiceinbox + +internal interface VoiceKeyboardDelayScheduler { + fun postDelayed(runnable: Runnable, delayMillis: Long) + fun removeCallbacks(runnable: Runnable) +} + +internal class VoiceKeyboardWarmupScheduler( + private val scheduler: VoiceKeyboardDelayScheduler, + private val delayMillis: Long, +) { + private var pending: Runnable? = null + + fun schedule(action: () -> Unit) { + cancel() + lateinit var callback: Runnable + callback = Runnable { + if (pending !== callback) return@Runnable + pending = null + action() + } + pending = callback + scheduler.postDelayed(callback, delayMillis) + } + + fun cancel() { + pending?.let(scheduler::removeCallbacks) + pending = null + } + + fun isScheduled(): Boolean = pending != null +} diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 0aa3378..a8cf443 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -105,6 +105,49 @@ android:text="Select a supported local model package" /> + + + + + + + + + + + + + + android:paddingBottom="9dp"> + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 682e320..ac69c78 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,9 +2,10 @@ Voice Inbox Voice Inbox keyboard Offline dictation - Tap Record to dictate + Tap to record, or hold while speaking Preparing speech model… - Listening… Tap Stop when finished. + Listening… Release to transcribe. + Listening… Tap Stop when finished. Transcribing locally… Text is ready. Focus an editor to insert it, or dismiss it. No speech detected. Try again. @@ -24,6 +25,8 @@ Return to previous keyboard Microphone permission granted. Return to the Voice Inbox keyboard. Microphone permission was not granted. + Dismiss voice keyboard suggestion + Android keyboard settings are unavailable on this device. See the keyboard guide for manual steps. Select output file Change output file Select audio folder @@ -46,6 +49,15 @@ Website Legal information No app can open this link. + Voice keyboard + Compact Android dictation for any editable field. Recognition runs locally after model setup; this is not a full typing keyboard. + Not enabled + Enabled · Choose it from Android’s keyboard picker + Ready · Currently selected + Enable + Choose keyboard + Change keyboard + Keyboard guide Nightly transcription Automatically scan and transcribe new notes around the selected time. Scheduled time: %1$02d:%2$02d diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidInlineOnboardingTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidInlineOnboardingTest.kt index 49828f5..44d438f 100644 --- a/app/src/test/java/me/maxistar/voiceinbox/AndroidInlineOnboardingTest.kt +++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidInlineOnboardingTest.kt @@ -74,16 +74,33 @@ class AndroidInlineOnboardingTest { assertTrue(presentation.steps.single { it.kind == AndroidOnboardingStepKind.OUTPUT }.optional) assertTrue(presentation.steps.single { it.kind == AndroidOnboardingStepKind.FOLDER }.optional) + assertTrue(presentation.steps.single { it.kind == AndroidOnboardingStepKind.KEYBOARD }.optional) } @Test fun fullyConfiguredSetupRetiresAndCompletesOnlyAfterHydration() { assertTrue(present(model = readyModel()).visible) + assertTrue( + present( + model = readyModel(), + output = readyOutput(), + folder = FolderSetupSnapshot(FolderSetupSnapshotState.READY), + ).visible, + ) + assertEquals( + TaskActionKind.ENABLE_VOICE_KEYBOARD, + present( + model = readyModel(), + output = readyOutput(), + folder = FolderSetupSnapshot(FolderSetupSnapshotState.READY), + ).action?.kind, + ) assertFalse( present( model = readyModel(), output = readyOutput(), folder = FolderSetupSnapshot(FolderSetupSnapshotState.READY), + keyboardStatus = AndroidVoiceKeyboardStatus.ENABLED, ).visible, ) assertTrue( @@ -93,6 +110,8 @@ class AndroidInlineOnboardingTest { readyModel(), readyOutput(), FolderSetupSnapshot(FolderSetupSnapshotState.READY), + AndroidVoiceKeyboardStatus.ENABLED, + true, ), ) assertFalse( @@ -102,6 +121,8 @@ class AndroidInlineOnboardingTest { readyModel(), OutputSetupSnapshot(OutputSetupSnapshotState.REQUIRED), FolderSetupSnapshot(FolderSetupSnapshotState.UNSELECTED), + AndroidVoiceKeyboardStatus.DISABLED, + true, ), ) assertFalse( @@ -111,6 +132,8 @@ class AndroidInlineOnboardingTest { readyModel(), OutputSetupSnapshot(OutputSetupSnapshotState.REQUIRED), FolderSetupSnapshot(FolderSetupSnapshotState.UNSELECTED), + AndroidVoiceKeyboardStatus.DISABLED, + false, ), ) } @@ -122,7 +145,18 @@ class AndroidInlineOnboardingTest { model: ModelSetupSnapshot = ModelSetupSnapshot(ModelSetupSnapshotState.REQUIRED, downloadAvailable = true), output: OutputSetupSnapshot = OutputSetupSnapshot(OutputSetupSnapshotState.REQUIRED), folder: FolderSetupSnapshot = FolderSetupSnapshot(FolderSetupSnapshotState.UNSELECTED), - ) = AndroidOnboardingHintPresenter.present(lifecycle, filter, hydration, model, output, folder) + keyboardStatus: AndroidVoiceKeyboardStatus = AndroidVoiceKeyboardStatus.DISABLED, + keyboardKnown: Boolean = true, + ) = AndroidOnboardingHintPresenter.present( + lifecycle, + filter, + hydration, + model, + output, + folder, + keyboardStatus, + keyboardKnown, + ) private fun hydrated() = AndroidMainScreenHydration(true, true, true, true) private fun readyModel() = ModelSetupSnapshot(ModelSetupSnapshotState.READY) diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt index 612c1ab..c146ff3 100644 --- a/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt +++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt @@ -220,6 +220,7 @@ class AndroidMainScreenStateHostTest { folder = FolderSetupSnapshot(FolderSetupSnapshotState.UNSELECTED), hydration = hydrated(), onboardingLifecycle = AndroidOnboardingHintLifecycle.ACTIVE, + keyboardKnown = true, ), ) assertTrue(initial.onboardingHint.visible) @@ -236,6 +237,7 @@ class AndroidMainScreenStateHostTest { folder = FolderSetupSnapshot(FolderSetupSnapshotState.UNSELECTED), hydration = hydrated(), onboardingLifecycle = AndroidOnboardingHintLifecycle.ACTIVE, + keyboardKnown = true, ), ) assertTrue(directModelCompletion.onboardingHint.visible) @@ -270,6 +272,7 @@ class AndroidMainScreenStateHostTest { folder = FolderSetupSnapshot(FolderSetupSnapshotState.UNSELECTED), hydration = hydrated(), onboardingLifecycle = AndroidOnboardingHintLifecycle.ACTIVE, + keyboardKnown = true, ), ) assertFalse(allFilter.onboardingHint.visible) diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidTaskActionRouterTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidTaskActionRouterTest.kt index af54ef0..4db487d 100644 --- a/app/src/test/java/me/maxistar/voiceinbox/AndroidTaskActionRouterTest.kt +++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidTaskActionRouterTest.kt @@ -183,6 +183,69 @@ class AndroidTaskActionRouterTest { assertEquals(0, callCount) } + @Test + fun discoveryRoutesOnlyCurrentSetupAndDocumentationActions() { + var state = AndroidTaskListSnapshotMapper.state( + AndroidMainScreenInput( + model = ModelSetupSnapshot(ModelSetupSnapshotState.READY), + output = OutputSetupSnapshot(OutputSetupSnapshotState.READY), + folder = FolderSetupSnapshot(FolderSetupSnapshotState.READY), + hydration = AndroidMainScreenHydration(true, true, true, true), + keyboardKnown = true, + keyboardStatus = AndroidVoiceKeyboardStatus.DISABLED, + keyboardDiscoveryLifecycle = AndroidVoiceKeyboardDiscoveryLifecycle.ELIGIBLE, + ), + ) + val calls = mutableListOf() + val router = AndroidTaskActionRouter({ state }) { kind, _ -> calls += kind } + + assertTrue( + router.route( + request( + TaskListDisplayItem.KeyboardDiscovery.STABLE_KEY, + null, + TaskActionKind.ENABLE_VOICE_KEYBOARD, + ), + ), + ) + assertTrue( + router.route( + request( + TaskListDisplayItem.KeyboardDiscovery.STABLE_KEY, + null, + TaskActionKind.OPEN_VOICE_KEYBOARD_DOCUMENTATION, + ), + ), + ) + assertFalse( + router.route( + request( + TaskListDisplayItem.KeyboardDiscovery.STABLE_KEY, + null, + TaskActionKind.SELECT_FOLDER, + ), + ), + ) + + state = state.copy(keyboardDiscovery = AndroidVoiceKeyboardDiscoveryPresentation.HIDDEN) + assertFalse( + router.route( + request( + TaskListDisplayItem.KeyboardDiscovery.STABLE_KEY, + null, + TaskActionKind.ENABLE_VOICE_KEYBOARD, + ), + ), + ) + assertEquals( + listOf( + TaskActionKind.ENABLE_VOICE_KEYBOARD, + TaskActionKind.OPEN_VOICE_KEYBOARD_DOCUMENTATION, + ), + calls, + ) + } + private fun state( modelReady: Boolean = true, filter: TaskListFilter = TaskListFilter.NEW, @@ -222,6 +285,7 @@ class AndroidTaskActionRouterTest { folder = folder, hydration = AndroidMainScreenHydration(true, true, true, true), onboardingLifecycle = lifecycle, + keyboardKnown = true, ) private fun request(stableId: String, entryId: Long?, kind: TaskActionKind) = diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidVoiceKeyboardDiscoveryTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidVoiceKeyboardDiscoveryTest.kt new file mode 100644 index 0000000..4e9d9e5 --- /dev/null +++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidVoiceKeyboardDiscoveryTest.kt @@ -0,0 +1,127 @@ +package me.maxistar.voiceinbox + +import me.maxistar.voiceinbox.core.ModelSetupSnapshot +import me.maxistar.voiceinbox.core.ModelSetupSnapshotState +import me.maxistar.voiceinbox.core.TaskActionKind +import me.maxistar.voiceinbox.core.TaskListFilter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidVoiceKeyboardDiscoveryTest { + @Test + fun statusResolverNormalizesComponentsAndDistinguishesAllStates() { + val service = "me.maxistar.voiceinbox/me.maxistar.voiceinbox.VoiceKeyboardInputMethodService" + val shortService = "me.maxistar.voiceinbox/.VoiceKeyboardInputMethodService" + + assertEquals( + AndroidVoiceKeyboardStatus.DISABLED, + AndroidVoiceKeyboardStatusResolver.resolve(service, emptyList(), null), + ) + assertEquals( + AndroidVoiceKeyboardStatus.ENABLED, + AndroidVoiceKeyboardStatusResolver.resolve(service, listOf(shortService), "other/.Keyboard"), + ) + assertEquals( + AndroidVoiceKeyboardStatus.SELECTED, + AndroidVoiceKeyboardStatusResolver.resolve(service, listOf(shortService), shortService), + ) + } + + @Test + fun actionPresentationUsesEnableOnlyForDisabledStatus() { + assertEquals( + AndroidVoiceKeyboardSystemAction.ENABLE, + AndroidVoiceKeyboardActionPresenter.systemAction(AndroidVoiceKeyboardStatus.DISABLED), + ) + assertEquals( + TaskActionKind.ENABLE_VOICE_KEYBOARD, + AndroidVoiceKeyboardActionPresenter.taskAction(AndroidVoiceKeyboardStatus.DISABLED), + ) + assertEquals( + AndroidVoiceKeyboardSystemAction.CHOOSE, + AndroidVoiceKeyboardActionPresenter.systemAction(AndroidVoiceKeyboardStatus.ENABLED), + ) + assertEquals( + AndroidVoiceKeyboardSystemAction.CHOOSE, + AndroidVoiceKeyboardActionPresenter.systemAction(AndroidVoiceKeyboardStatus.SELECTED), + ) + } + + @Test + fun migrationMakesOnlyLegacyTerminalOnboardingEligible() { + val legacyStorage = FakeStorage() + val legacy = AndroidVoiceKeyboardDiscoveryStore(legacyStorage) + assertEquals( + AndroidVoiceKeyboardDiscoveryLifecycle.ELIGIBLE, + legacy.loadOrInitialize(AndroidOnboardingHintLifecycle.COMPLETED), + ) + assertEquals("eligible", legacyStorage.raw) + + val freshStorage = FakeStorage() + val fresh = AndroidVoiceKeyboardDiscoveryStore(freshStorage) + assertEquals( + AndroidVoiceKeyboardDiscoveryLifecycle.SUPPRESSED, + fresh.loadOrInitialize(AndroidOnboardingHintLifecycle.ACTIVE), + ) + assertEquals( + AndroidVoiceKeyboardDiscoveryLifecycle.SUPPRESSED, + fresh.loadOrInitialize(AndroidOnboardingHintLifecycle.DISMISSED), + ) + } + + @Test + fun presenterRequiresEligibleNewReadyKnownAndDisabled() { + val visible = present() + assertTrue(visible.visible) + assertEquals(TaskActionKind.ENABLE_VOICE_KEYBOARD, visible.setupAction) + assertFalse(present(lifecycle = AndroidVoiceKeyboardDiscoveryLifecycle.DISMISSED).visible) + assertFalse(present(filter = TaskListFilter.ALL).visible) + assertFalse(present(modelKnown = false).visible) + assertFalse(present(model = ModelSetupSnapshot(ModelSetupSnapshotState.REQUIRED)).visible) + assertFalse(present(keyboardKnown = false).visible) + assertFalse(present(keyboardStatus = AndroidVoiceKeyboardStatus.ENABLED).visible) + } + + @Test + fun eligibleDiscoveryCompletesOnceKeyboardIsEnabled() { + assertFalse( + AndroidVoiceKeyboardDiscoveryPresenter.shouldComplete( + AndroidVoiceKeyboardDiscoveryLifecycle.ELIGIBLE, + AndroidVoiceKeyboardStatus.DISABLED, + true, + ), + ) + assertTrue( + AndroidVoiceKeyboardDiscoveryPresenter.shouldComplete( + AndroidVoiceKeyboardDiscoveryLifecycle.ELIGIBLE, + AndroidVoiceKeyboardStatus.ENABLED, + true, + ), + ) + } + + private fun present( + lifecycle: AndroidVoiceKeyboardDiscoveryLifecycle = AndroidVoiceKeyboardDiscoveryLifecycle.ELIGIBLE, + filter: TaskListFilter = TaskListFilter.NEW, + model: ModelSetupSnapshot = ModelSetupSnapshot(ModelSetupSnapshotState.READY), + modelKnown: Boolean = true, + keyboardStatus: AndroidVoiceKeyboardStatus = AndroidVoiceKeyboardStatus.DISABLED, + keyboardKnown: Boolean = true, + ) = AndroidVoiceKeyboardDiscoveryPresenter.present( + lifecycle, + filter, + model, + modelKnown, + keyboardStatus, + keyboardKnown, + ) + + private class FakeStorage(var raw: String? = null) : AndroidVoiceKeyboardDiscoveryStorage { + override fun loadRaw(): String? = raw + override fun saveRaw(value: String) { + raw = value + } + } +} diff --git a/app/src/test/java/me/maxistar/voiceinbox/HybridRecordFlowTest.kt b/app/src/test/java/me/maxistar/voiceinbox/HybridRecordFlowTest.kt new file mode 100644 index 0000000..d87b2fc --- /dev/null +++ b/app/src/test/java/me/maxistar/voiceinbox/HybridRecordFlowTest.kt @@ -0,0 +1,132 @@ +package me.maxistar.voiceinbox + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HybridRecordFlowTest { + @Test + fun shortTapStartsOnceAndSecondActivationStopsOnce() { + val flow = FakeHybridRecordFlow() + + flow.down(generation = 1, atMillis = 0) + flow.up(generation = 1, atMillis = 100) + flow.activation(generation = 1) + flow.modelReady(generation = 1) + + assertEquals(1, flow.startCount) + assertEquals(1, flow.stopCount) + assertEquals(1, flow.transcriptionCount) + } + + @Test + fun heldReleaseStopsAndTranscribesExactlyOnce() { + val flow = FakeHybridRecordFlow() + + flow.down(generation = 1, atMillis = 0) + flow.up(generation = 1, atMillis = 500) + flow.modelReady(generation = 1) + flow.up(generation = 1, atMillis = 700) + + assertEquals(1, flow.startCount) + assertEquals(1, flow.stopCount) + assertEquals(1, flow.transcriptionCount) + } + + @Test + fun cancellationDiscardsWithoutTranscription() { + val flow = FakeHybridRecordFlow() + + flow.down(generation = 1, atMillis = 0) + flow.cancel(generation = 1) + flow.modelReady(generation = 1) + + assertEquals(1, flow.startCount) + assertEquals(1, flow.cancelCount) + assertEquals(0, flow.transcriptionCount) + } + + @Test + fun durationAndReleaseRaceHasOneTerminalTransition() { + val flow = FakeHybridRecordFlow() + + flow.down(generation = 1, atMillis = 0) + flow.durationLimit(generation = 1) + flow.modelReady(generation = 1) + flow.up(generation = 1, atMillis = 800) + + assertEquals(1, flow.stopCount) + assertEquals(1, flow.transcriptionCount) + } + + @Test + fun recordingStopsBeforeModelAndTranscribesOnlyAfterPreparation() { + val flow = FakeHybridRecordFlow() + + flow.down(generation = 1, atMillis = 0) + flow.up(generation = 1, atMillis = 500) + + assertEquals(1, flow.startCount) + assertEquals(1, flow.stopCount) + assertEquals(0, flow.transcriptionCount) + + flow.modelReady(generation = 1) + + assertEquals(1, flow.transcriptionCount) + } + + private class FakeHybridRecordFlow { + private val controller = VoiceKeyboardController() + private val gestures = HybridRecordGestureCoordinator(HybridRecordGesturePolicy(500)) + private var currentGeneration = 0L + private var terminal = false + var startCount = 0 + var stopCount = 0 + var cancelCount = 0 + var transcriptionCount = 0 + + fun down(generation: Long, atMillis: Long) { + currentGeneration = generation + terminal = false + assertTrue(gestures.begin(0, generation, atMillis)) + assertTrue(controller.beginRecording()) + startCount += 1 + } + + fun up(generation: Long, atMillis: Long) { + val release = gestures.release(0, generation, atMillis) ?: return + if (release.release == HybridRecordRelease.STOP_AND_TRANSCRIBE) stop(generation) + } + + fun activation(generation: Long) { + stop(generation) + } + + fun durationLimit(generation: Long) { + stop(generation) + } + + fun modelReady(generation: Long) { + if (generation != currentGeneration || terminal) return + if (!controller.modelReady()) return + terminal = true + transcriptionCount += 1 + gestures.finish(generation) + } + + fun cancel(generation: Long) { + if (generation != currentGeneration || terminal) return + terminal = true + cancelCount += 1 + controller.cancel() + gestures.finish(generation) + } + + private fun stop(generation: Long) { + if (generation != currentGeneration || terminal) return + if (!controller.recordingStopped()) return + stopCount += 1 + } + } +} diff --git a/app/src/test/java/me/maxistar/voiceinbox/HybridRecordGestureCoordinatorTest.kt b/app/src/test/java/me/maxistar/voiceinbox/HybridRecordGestureCoordinatorTest.kt new file mode 100644 index 0000000..31c9340 --- /dev/null +++ b/app/src/test/java/me/maxistar/voiceinbox/HybridRecordGestureCoordinatorTest.kt @@ -0,0 +1,77 @@ +package me.maxistar.voiceinbox + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class HybridRecordGestureCoordinatorTest { + private val policy = HybridRecordGesturePolicy(holdThresholdMillis = 500) + + @Test + fun shortReleaseLatchesRecording() { + val coordinator = HybridRecordGestureCoordinator(policy) + assertTrue(coordinator.begin(pointerId = 3, generation = 7, eventTimeMillis = 1_000)) + + assertEquals( + HybridRecordReleaseEvent(7, HybridRecordRelease.LATCH), + coordinator.release(pointerId = 3, generation = 7, eventTimeMillis = 1_499), + ) + assertEquals(HybridRecordPreparationAction.START_LATCHED, coordinator.preparationAction(7)) + } + + @Test + fun releaseAtThresholdStopsAndTranscribes() { + val coordinator = HybridRecordGestureCoordinator(policy) + coordinator.begin(pointerId = 1, generation = 2, eventTimeMillis = 100) + + assertEquals( + HybridRecordRelease.STOP_AND_TRANSCRIBE, + coordinator.release(pointerId = 1, generation = 2, eventTimeMillis = 600)?.release, + ) + assertEquals(HybridRecordPreparationAction.CANCEL, coordinator.preparationAction(2)) + } + + @Test + fun preparationWhilePointerIsDownStartsHeldRecording() { + val coordinator = HybridRecordGestureCoordinator(policy) + coordinator.begin(pointerId = 1, generation = 4, eventTimeMillis = 100) + + assertEquals(HybridRecordPreparationAction.START_HELD, coordinator.preparationAction(4)) + assertTrue(coordinator.isPointerDown(4)) + } + + @Test + fun cancellationBeforePreparationPreventsDelayedStart() { + val coordinator = HybridRecordGestureCoordinator(policy) + coordinator.begin(pointerId = 1, generation = 4, eventTimeMillis = 100) + + assertTrue(coordinator.cancel(pointerId = 1, generation = 4)) + assertEquals(HybridRecordPreparationAction.CANCEL, coordinator.preparationAction(4)) + } + + @Test + fun anotherPointerAndStaleGenerationCannotAlterSession() { + val coordinator = HybridRecordGestureCoordinator(policy) + coordinator.begin(pointerId = 1, generation = 4, eventTimeMillis = 100) + + assertFalse(coordinator.begin(pointerId = 2, generation = 5, eventTimeMillis = 101)) + assertNull(coordinator.release(pointerId = 2, generation = 4, eventTimeMillis = 700)) + assertNull(coordinator.release(pointerId = 1, generation = 3, eventTimeMillis = 700)) + assertFalse(coordinator.cancel(pointerId = 2, generation = 4)) + assertEquals(HybridRecordPreparationAction.IGNORE, coordinator.preparationAction(3)) + assertTrue(coordinator.isPointerDown(4)) + } + + @Test + fun finishedGenerationDoesNotAffectNextRequest() { + val coordinator = HybridRecordGestureCoordinator(policy) + coordinator.begin(pointerId = 1, generation = 4, eventTimeMillis = 100) + coordinator.finish(4) + + assertTrue(coordinator.begin(pointerId = 2, generation = 5, eventTimeMillis = 200)) + assertNull(coordinator.release(pointerId = 1, generation = 4, eventTimeMillis = 800)) + assertTrue(coordinator.isPointerDown(5)) + } +} diff --git a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelWarmupCoordinatorTest.kt b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelWarmupCoordinatorTest.kt index 1329074..ab6dccb 100644 --- a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelWarmupCoordinatorTest.kt +++ b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelWarmupCoordinatorTest.kt @@ -32,6 +32,7 @@ class SpeechModelWarmupCoordinatorTest { assertTrue(first.get().isSuccess) assertEquals(1, preparations) assertTrue(coordinator.state() is SpeechModelWarmupState.Ready) + assertTrue(coordinator.state(repository) is SpeechModelWarmupState.Ready) } @Test @@ -104,6 +105,8 @@ class SpeechModelWarmupCoordinatorTest { assertTrue( state.installation.endsWith(":${secondRepository.installedDirectory.canonicalPath}"), ) + assertTrue(coordinator.state(firstRepository) is SpeechModelWarmupState.Idle) + assertTrue(coordinator.state(secondRepository) is SpeechModelWarmupState.Ready) } private fun coordinator( diff --git a/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt b/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt index 1567da9..bbabdde 100644 --- a/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt +++ b/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt @@ -150,6 +150,35 @@ class TaskListDisplayItemsTest { assertTrue(items(filter = TaskListFilter.ALL).none { it is TaskListDisplayItem.OnboardingHint }) } + @Test + fun keyboardDiscoveryIsStableDismissibleAndOrderedAfterOnboarding() { + val onboarding = onboardingHint() + val discovery = AndroidVoiceKeyboardDiscoveryPresentation(visible = true) + val withBoth = items(onboardingHint = onboarding, keyboardDiscovery = discovery) + + assertEquals( + listOf( + TaskListDisplayItem.OnboardingHint.STABLE_KEY, + TaskListDisplayItem.KeyboardDiscovery.STABLE_KEY, + TaskListDisplayItem.Empty.STABLE_KEY, + ), + withBoth.map { it.stableKey }, + ) + val changed = discovery.copy(setupLabel = "Choose keyboard") + assertTrue( + TaskListDisplayItemDiff.areItemsTheSame( + withBoth[1], + items(keyboardDiscovery = changed).first(), + ), + ) + assertFalse( + TaskListDisplayItemDiff.areContentsTheSame( + withBoth[1], + items(keyboardDiscovery = changed).first(), + ), + ) + } + private fun items( filter: TaskListFilter = TaskListFilter.NEW, model: ModelSetupSnapshot = ModelSetupSnapshot(ModelSetupSnapshotState.READY), @@ -157,6 +186,7 @@ class TaskListDisplayItemsTest { audio: List = emptyList(), transcription: TranscriptionTaskSnapshot = TranscriptionTaskSnapshot(), onboardingHint: AndroidOnboardingHintPresentation = AndroidOnboardingHintPresentation.HIDDEN, + keyboardDiscovery: AndroidVoiceKeyboardDiscoveryPresentation = AndroidVoiceKeyboardDiscoveryPresentation.HIDDEN, ): List = TaskListDisplayItems.from( TaskListPresentationController.state( TaskListInput( @@ -169,6 +199,7 @@ class TaskListDisplayItemsTest { ), ), onboardingHint, + keyboardDiscovery, ) private fun onboardingHint() = AndroidOnboardingHintPresentation( @@ -177,6 +208,7 @@ class TaskListDisplayItemsTest { AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.MODEL, "Install speech model", false), AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.OUTPUT, "Select transcript output", false), AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.FOLDER, "Select audio folder · Optional", false, true), + AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.KEYBOARD, "Enable voice keyboard · Optional", false, true), ), action = AndroidOnboardingHintAction("Start setup", true, TaskActionKind.DOWNLOAD_MODEL), ) diff --git a/app/src/test/java/me/maxistar/voiceinbox/VoiceKeyboardControllerTest.kt b/app/src/test/java/me/maxistar/voiceinbox/VoiceKeyboardControllerTest.kt index 4893a63..d02797f 100644 --- a/app/src/test/java/me/maxistar/voiceinbox/VoiceKeyboardControllerTest.kt +++ b/app/src/test/java/me/maxistar/voiceinbox/VoiceKeyboardControllerTest.kt @@ -11,15 +11,28 @@ class VoiceKeyboardControllerTest { fun serializesRecordingAndTranscriptionRequests() { val controller = VoiceKeyboardController() - assertTrue(controller.beginPreparation()) - assertFalse(controller.beginPreparation()) - assertTrue(controller.recordingStarted()) - assertFalse(controller.beginPreparation()) + assertTrue(controller.beginRecording()) + assertFalse(controller.beginRecording()) assertTrue(controller.recordingStopped()) assertFalse(controller.recordingStopped()) + assertEquals(VoiceKeyboardPhase.WAITING_FOR_MODEL, controller.phase) + assertTrue(controller.modelReady()) assertEquals(VoiceKeyboardPhase.TRANSCRIBING, controller.phase) } + @Test + fun modelCompletionCannotAdvanceRecordingOrAStaleRequest() { + val controller = VoiceKeyboardController() + + assertTrue(controller.beginRecording()) + assertFalse(controller.modelReady()) + assertEquals(VoiceKeyboardPhase.RECORDING, controller.phase) + + controller.cancel() + assertFalse(controller.modelReady()) + assertEquals(VoiceKeyboardPhase.IDLE, controller.phase) + } + @Test fun emptyTranscriptionDoesNotCreatePendingText() { val controller = transcribingController() @@ -58,11 +71,11 @@ class VoiceKeyboardControllerTest { fun recoverableSetupFailureReturnsToRecordableState() { val controller = VoiceKeyboardController() - assertTrue(controller.beginPreparation()) + assertTrue(controller.beginRecording()) controller.fail() assertEquals(VoiceKeyboardPhase.ERROR, controller.phase) - assertTrue(controller.beginPreparation()) + assertTrue(controller.beginRecording()) } @Test @@ -78,8 +91,8 @@ class VoiceKeyboardControllerTest { } private fun transcribingController(): VoiceKeyboardController = VoiceKeyboardController().apply { - beginPreparation() - recordingStarted() + beginRecording() recordingStopped() + modelReady() } } diff --git a/app/src/test/java/me/maxistar/voiceinbox/VoiceKeyboardWarmupSchedulerTest.kt b/app/src/test/java/me/maxistar/voiceinbox/VoiceKeyboardWarmupSchedulerTest.kt new file mode 100644 index 0000000..6d4fb6c --- /dev/null +++ b/app/src/test/java/me/maxistar/voiceinbox/VoiceKeyboardWarmupSchedulerTest.kt @@ -0,0 +1,71 @@ +package me.maxistar.voiceinbox + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class VoiceKeyboardWarmupSchedulerTest { + @Test + fun transientViewCancellationPreventsWarmup() { + val delay = FakeDelayScheduler() + val scheduler = VoiceKeyboardWarmupScheduler(delay, delayMillis = 400) + var starts = 0 + + scheduler.schedule { starts += 1 } + assertTrue(scheduler.isScheduled()) + scheduler.cancel() + delay.runPending() + + assertFalse(scheduler.isScheduled()) + assertEquals(0, starts) + } + + @Test + fun reschedulingKeepsOnlyTheNewestViewCallback() { + val delay = FakeDelayScheduler() + val scheduler = VoiceKeyboardWarmupScheduler(delay, delayMillis = 400) + var firstStarts = 0 + var secondStarts = 0 + + scheduler.schedule { firstStarts += 1 } + scheduler.schedule { secondStarts += 1 } + delay.runPending() + + assertEquals(0, firstStarts) + assertEquals(1, secondStarts) + assertFalse(scheduler.isScheduled()) + } + + @Test + fun dictationCanCancelDelayAndStartPreparationImmediately() { + val delay = FakeDelayScheduler() + val scheduler = VoiceKeyboardWarmupScheduler(delay, delayMillis = 400) + var passiveStarts = 0 + var requestStarts = 0 + + scheduler.schedule { passiveStarts += 1 } + scheduler.cancel() + requestStarts += 1 + delay.runPending() + + assertEquals(0, passiveStarts) + assertEquals(1, requestStarts) + } + + private class FakeDelayScheduler : VoiceKeyboardDelayScheduler { + private val callbacks = mutableListOf() + + override fun postDelayed(runnable: Runnable, delayMillis: Long) { + callbacks += runnable + } + + override fun removeCallbacks(runnable: Runnable) { + callbacks.remove(runnable) + } + + fun runPending() { + callbacks.toList().also { callbacks.clear() }.forEach(Runnable::run) + } + } +} diff --git a/screenshots/illustrations/keyboard_en.png b/screenshots/illustrations/keyboard_en.png new file mode 100644 index 0000000..1d5dd35 Binary files /dev/null and b/screenshots/illustrations/keyboard_en.png differ diff --git a/screenshots/illustrations/mixed_screenshots.png b/screenshots/illustrations/mixed_screenshots.png new file mode 100644 index 0000000..2fc3432 Binary files /dev/null and b/screenshots/illustrations/mixed_screenshots.png differ diff --git a/screenshots/keyboard_en/01_listening.jpg b/screenshots/keyboard_en/01_listening.jpg new file mode 100644 index 0000000..aaf4a87 Binary files /dev/null and b/screenshots/keyboard_en/01_listening.jpg differ diff --git a/screenshots/keyboard_en/02_transcribing.jpg b/screenshots/keyboard_en/02_transcribing.jpg new file mode 100644 index 0000000..155b21d Binary files /dev/null and b/screenshots/keyboard_en/02_transcribing.jpg differ diff --git a/screenshots/keyboard_en/04_keyboard_select.jpg b/screenshots/keyboard_en/04_keyboard_select.jpg new file mode 100644 index 0000000..c9d3b69 Binary files /dev/null and b/screenshots/keyboard_en/04_keyboard_select.jpg differ diff --git a/screenshots/keyboard_en/05_grant_permissions.jpg b/screenshots/keyboard_en/05_grant_permissions.jpg new file mode 100644 index 0000000..b1a929c Binary files /dev/null and b/screenshots/keyboard_en/05_grant_permissions.jpg differ diff --git a/screenshots/mixed_screenshots/01_android_voice_inbox_list.jpg b/screenshots/mixed_screenshots/01_android_voice_inbox_list.jpg new file mode 100644 index 0000000..33dfac6 Binary files /dev/null and b/screenshots/mixed_screenshots/01_android_voice_inbox_list.jpg differ diff --git a/screenshots/mixed_screenshots/03_android_settings.jpg b/screenshots/mixed_screenshots/03_android_settings.jpg new file mode 100644 index 0000000..0628fc2 Binary files /dev/null and b/screenshots/mixed_screenshots/03_android_settings.jpg differ diff --git a/screenshots/mixed_screenshots/03_result.jpg b/screenshots/mixed_screenshots/03_result.jpg new file mode 100644 index 0000000..f57b47d Binary files /dev/null and b/screenshots/mixed_screenshots/03_result.jpg differ diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt index 30a0024..5e0e6f8 100644 --- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt +++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt @@ -49,6 +49,9 @@ enum class TaskActionKind { STOP, SHOW_TEXT, IMPORT_AUDIO, + ENABLE_VOICE_KEYBOARD, + CHOOSE_VOICE_KEYBOARD, + OPEN_VOICE_KEYBOARD_DOCUMENTATION, } data class TaskActionPresentation( diff --git a/website/public/screenshots/android-inbox.jpg b/website/public/screenshots/android-inbox.jpg new file mode 100644 index 0000000..ed2a762 Binary files /dev/null and b/website/public/screenshots/android-inbox.jpg differ diff --git a/website/public/screenshots/android-voice-keyboard-guide.png b/website/public/screenshots/android-voice-keyboard-guide.png new file mode 100644 index 0000000..1d5dd35 Binary files /dev/null and b/website/public/screenshots/android-voice-keyboard-guide.png differ diff --git a/website/public/screenshots/android-workflows.png b/website/public/screenshots/android-workflows.png new file mode 100644 index 0000000..2fc3432 Binary files /dev/null and b/website/public/screenshots/android-workflows.png differ diff --git a/website/src/layouts/Layout.astro b/website/src/layouts/Layout.astro index 560bcac..4896438 100644 --- a/website/src/layouts/Layout.astro +++ b/website/src/layouts/Layout.astro @@ -14,6 +14,7 @@ const githubUrl = 'https://github.com/maxistar/Voice-Inbox'; const releaseUrl = `${githubUrl}/releases`; const nav = [ ['Home', '/'], + ['Docs', '/docs/'], ['Setup', '/setup/'], ['Testing', '/testing/'], ['FAQ', '/faq/'], diff --git a/website/src/pages/docs.astro b/website/src/pages/docs.astro index b711056..eb73cc5 100644 --- a/website/src/pages/docs.astro +++ b/website/src/pages/docs.astro @@ -5,17 +5,18 @@ const base = import.meta.env.BASE_URL.replace(/\/$/, ''); ---

Voice Inbox documentation

-

From audio files to a local text inbox.

+

Choose how you want to use Voice Inbox.

- Voice Inbox is designed for accumulated recordings, not live dictation. Install a speech model, - choose where transcripts are written, add audio files, then transcribe when you are ready. + Process accumulated recordings into a searchable text inbox on Android or iOS, or use the + Android-only Voice Inbox keyboard for short phrases directly inside an editable field.

@@ -23,17 +24,24 @@ const base = import.meta.env.BASE_URL.replace(/\/$/, '');
-

1. Set up Voice Inbox

-

Install or download a compatible model, create or select an output document, and import audio.

+

Recordings workflow

+

Process audio into your inbox

+

Install a compatible model, create or select an output document, import recordings, preview them, and transcribe individually or in a batch.

Read the setup guide →
-

2. Get answers

+

Android dictation

+

Dictate a short phrase in any app

+

Enable the compact Voice Inbox keyboard, prepare its local model, and insert recognized text into the active editor.

+ Read the voice-keyboard guide → +
+
+

Get answers

Find help with device requirements, audio files, model installation, transcripts, and failures.

Read the FAQ →
-

3. Join testing

+

Join testing

Voice Inbox for Android is currently distributed through Google Play closed testing.

Read testing instructions →
@@ -53,9 +61,10 @@ const base = import.meta.env.BASE_URL.replace(/\/$/, '');

Platform notes

- Android has the broader workflow today, including individual imports and sharing, optional folder - processing, and scheduled transcription. iOS is an active MVP with local import, model installation, - preview, transcription, and text output; its automation features are still evolving. + Android includes individual imports and sharing, optional folder processing, scheduled transcription, + and the Android-only voice keyboard. The iOS MVP supports app-local, shared, and selected-folder imports, + preview, batch transcription, transcript review, output append, and startup processing while the app is + active. Scheduled or background transcription is not available on iOS.

diff --git a/website/src/pages/docs/voice-keyboard.astro b/website/src/pages/docs/voice-keyboard.astro new file mode 100644 index 0000000..cfc6f85 --- /dev/null +++ b/website/src/pages/docs/voice-keyboard.astro @@ -0,0 +1,121 @@ +--- +import Layout from '../../layouts/Layout.astro'; + +const base = import.meta.env.BASE_URL.replace(/\/$/, ''); +const keyboardScreenshot = `${base}/screenshots/android-voice-keyboard-guide.png`; +--- + + + +
+

Android-only short-phrase dictation

+

Dictate into an editable field with Voice Inbox.

+

+ Voice Inbox can appear as a compact Android input method. It records a short phrase, recognizes it + locally with your installed speech model, and inserts a non-empty result into the active editor. + It is not a full typing keyboard and is not currently available on iOS. +

+
+ +
+
+

1. Prepare Voice Inbox

+

+ Open Voice Inbox and install a speech model first. The keyboard uses the same selected local model as + recording transcription. Recognition runs on your device after the model is ready. +

+
+
+

2. Enable the keyboard in Android

+

+ In Voice Inbox Settings, open Voice keyboard and choose the setup action. Android opens + its own input-method settings, where you explicitly enable Voice Inbox. Android controls this security + confirmation; Voice Inbox cannot enable itself. +

+
+
+

3. Select Voice Inbox

+

+ Tap the keyboard action again, or use Android's keyboard switcher, and select Voice Inbox. Open an app + with an editable text field before you start dictating. +

+
+
+

4. Allow microphone access

+

+ The keyboard needs Android microphone permission to capture your phrase. If permission or a valid model + is missing, return to Voice Inbox setup and follow the displayed recovery action. +

+
+
+

5. Wait for model preparation

+

+ The first use can show a preparation status while the local model is loaded. Recording becomes available + after warm-up succeeds. This can take noticeable time on some devices but does not upload your audio. +

+
+
+

6. Record a short phrase

+

+ Tap the main dictation control to start and tap again to stop, or hold it while speaking and release to + stop. Wait while the phrase is transcribed. A non-empty result is inserted into the active editable field. +

+
+
+

7. Return to your previous keyboard

+

+ Use the keyboard-switch control in Voice Inbox to return to the previous input method. If Android cannot + restore it directly, use the system keyboard picker. +

+
+
+ +
+

What the keyboard does not promise

+

+ Voice Inbox is intended for short phrases, not continuous background listening or live partial + transcription. Some editors may restrict text insertion, and recognition quality and speed depend on the + selected model, language, audio, and device. +

+
+ +
+

Android workflow

+

Voice Inbox inside an editor

+
+
+ Four Android examples showing Voice Inbox listening, transcribing locally, available in the keyboard picker, and displaying microphone-permission recovery +
+
+ Voice Inbox listening, transcribing locally, appearing in Android's keyboard picker, and guiding the + user back to setup when microphone permission is missing. +
+
+
+ +
+ +
+
diff --git a/website/src/pages/faq.astro b/website/src/pages/faq.astro index 101ce5b..4c467e5 100644 --- a/website/src/pages/faq.astro +++ b/website/src/pages/faq.astro @@ -1,5 +1,6 @@ --- import Layout from '../layouts/Layout.astro'; +const base = import.meta.env.BASE_URL.replace(/\/$/, ''); ---