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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,7 @@ class MainActivityInstrumentedTest {
}
awaitActivity(scenario) { activity ->
displayItems(activity).filterIsInstance<TaskListDisplayItem.OnboardingHint>()
.singleOrNull()?.presentation?.action?.kind == TaskActionKind.CREATE_OUTPUT
.singleOrNull()?.presentation?.action?.kind == TaskActionKind.SELECT_OUTPUT
}
scenario.onActivity { activity ->
stateHost(activity).replace(
Expand Down Expand Up @@ -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 ->
Expand All @@ -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<android.view.View>(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)
Expand All @@ -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()
}
Expand All @@ -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()
}
Expand All @@ -583,6 +638,8 @@ class MainActivityInstrumentedTest {
),
hydration = AndroidMainScreenHydration(true, true, true, true),
onboardingLifecycle = AndroidOnboardingHintLifecycle.ACTIVE,
keyboardStatus = AndroidVoiceKeyboardStatus.DISABLED,
keyboardKnown = true,
)

private fun seedCatalogEntry(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<ImageButton>(R.id.voiceKeyboardRecord).contentDescription,
)
val recordButton = view.findViewById<ImageButton>(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<ImageButton>(R.id.voiceKeyboardNextKeyboard).contentDescription,
Expand All @@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ enum class AndroidOnboardingStepKind {
MODEL,
OUTPUT,
FOLDER,
KEYBOARD,
}

data class AndroidOnboardingChecklistStep(
Expand Down Expand Up @@ -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
}
Expand All @@ -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,
Expand All @@ -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…",
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
Loading
Loading