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 @@ -381,6 +381,137 @@ class MainActivityInstrumentedTest {
}
}

@Test
fun inlineOnboardingAdvancesInPlaceAndDismissalSurvivesRecreation() {
clearOnboardingActivityState()
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
scenario.onActivity { activity ->
setField(activity, "onboardingHintLifecycle", AndroidOnboardingHintLifecycle.ACTIVE)
stateHost(activity).replace(onboardingInput())
}
awaitActivity(scenario) { activity ->
displayItems(activity).any { it is TaskListDisplayItem.OnboardingHint }
}
scenario.onActivity { activity ->
val items = displayItems(activity)
val hintIndex = items.indexOfFirst { it is TaskListDisplayItem.OnboardingHint }
assertTrue(items.take(hintIndex).all { it is TaskListDisplayItem.Setup })
val hint = items[hintIndex] as TaskListDisplayItem.OnboardingHint
assertEquals(TaskActionKind.DOWNLOAD_MODEL, hint.presentation.action?.kind)
assertTrue(hint.presentation.downloadDisclosure?.contains("downloads") == true)
}

scenario.onActivity { activity ->
stateHost(activity).replace(
onboardingInput(
model = me.maxistar.voiceinbox.core.ModelSetupSnapshot(
state = ModelSetupSnapshotState.INSTALLING,
installationPhase = "Installing local model",
canCancel = true,
),
),
)
}
awaitActivity(scenario) { activity ->
displayItems(activity).filterIsInstance<TaskListDisplayItem.OnboardingHint>()
.singleOrNull()?.presentation?.action?.enabled == false
}
scenario.onActivity { activity ->
val items = displayItems(activity)
val model = items.filterIsInstance<TaskListDisplayItem.Setup>().first()
val hint = items.filterIsInstance<TaskListDisplayItem.OnboardingHint>().single()
assertTrue(model.task.progress != null)
assertTrue(model.task.actions.any { it.kind == TaskActionKind.CANCEL_MODEL_DOWNLOAD })
assertEquals("Installing speech model…", hint.presentation.action?.label)
}

scenario.onActivity { activity ->
stateHost(activity).replace(
onboardingInput(
model = me.maxistar.voiceinbox.core.ModelSetupSnapshot(ModelSetupSnapshotState.READY),
),
)
}
awaitActivity(scenario) { activity ->
displayItems(activity).filterIsInstance<TaskListDisplayItem.OnboardingHint>()
.singleOrNull()?.presentation?.action?.kind == TaskActionKind.SELECT_OUTPUT
}
scenario.onActivity { activity ->
stateHost(activity).replace(
onboardingInput(
model = me.maxistar.voiceinbox.core.ModelSetupSnapshot(ModelSetupSnapshotState.READY),
output = me.maxistar.voiceinbox.core.OutputSetupSnapshot(
me.maxistar.voiceinbox.core.OutputSetupSnapshotState.READY,
),
),
)
}
awaitActivity(scenario) { activity ->
displayItems(activity).filterIsInstance<TaskListDisplayItem.OnboardingHint>()
.singleOrNull()?.presentation?.action?.kind == TaskActionKind.SELECT_FOLDER
}

scenario.onActivity { activity ->
activity.findViewById<android.view.View>(R.id.onboardingClose).performClick()
}
awaitActivity(scenario) { activity ->
displayItems(activity).none { it is TaskListDisplayItem.OnboardingHint }
}
val context = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals(
AndroidOnboardingHintLifecycle.DISMISSED,
AndroidOnboardingHintStore(
context.getSharedPreferences(AndroidOnboardingHintStore.PREFERENCES_NAME, Context.MODE_PRIVATE),
).load(),
)

scenario.recreate()
awaitActivity(scenario) { activity -> stateHost(activity).currentInput.hydration.modelKnown }
scenario.onActivity { activity ->
assertTrue(displayItems(activity).none { it is TaskListDisplayItem.OnboardingHint })
}
}
}

@Test
fun inlineOnboardingCompletesOnlyFromHydratedReadySetupAndDoesNotReplay() {
clearOnboardingActivityState()
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
scenario.onActivity { activity ->
setField(activity, "onboardingHintLifecycle", AndroidOnboardingHintLifecycle.ACTIVE)
setField(activity, "modelSetupState", ModelSetupSnapshotState.READY)
setField(activity, "modelReady", true)
setField(activity, "outputUri", Uri.parse("content://test/output"))
setField(activity, "outputAccessReady", true)
setField(activity, "folderUri", Uri.parse("content://test/folder"))
setField(activity, "folderAccessReady", true)
setField(activity, "modelPresentationKnown", true)
setField(activity, "outputPresentationKnown", true)
setField(activity, "folderPresentationKnown", true)
invoke(activity, "publishTaskState")
}
awaitActivity(scenario) { activity ->
stateHost(activity).currentInput.onboardingLifecycle == AndroidOnboardingHintLifecycle.COMPLETED
}
val context = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals(
AndroidOnboardingHintLifecycle.COMPLETED,
AndroidOnboardingHintStore(
context.getSharedPreferences(AndroidOnboardingHintStore.PREFERENCES_NAME, Context.MODE_PRIVATE),
).load(),
)

scenario.onActivity { activity ->
setField(activity, "modelSetupState", ModelSetupSnapshotState.REQUIRED)
setField(activity, "modelReady", false)
invoke(activity, "publishTaskState")
}
awaitActivity(scenario) { activity ->
displayItems(activity).none { it is TaskListDisplayItem.OnboardingHint }
}
}
}

private fun clearActivityState() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
WorkManager.getInstance(context).cancelUniqueWork(TranscriptionWorker.UNIQUE_WORK_NAME).result.get(30, TimeUnit.SECONDS)
Expand All @@ -389,10 +520,39 @@ class MainActivityInstrumentedTest {
context.getSharedPreferences("speech_model_import", Context.MODE_PRIVATE).edit().clear().commit()
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.deleteDatabase(AndroidSqlDelightAudioCatalogFactory.DATABASE_NAME)
java.io.File(context.filesDir, AndroidAudioImportConstants.DIRECTORY_NAME).deleteRecursively()
}

private fun clearOnboardingActivityState() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
context.getSharedPreferences("speech_model_import", Context.MODE_PRIVATE).edit().clear().commit()
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.deleteDatabase(AndroidSqlDelightAudioCatalogFactory.DATABASE_NAME)
java.io.File(context.filesDir, AndroidAudioImportConstants.DIRECTORY_NAME).deleteRecursively()
}

private fun onboardingInput(
model: me.maxistar.voiceinbox.core.ModelSetupSnapshot = me.maxistar.voiceinbox.core.ModelSetupSnapshot(
ModelSetupSnapshotState.REQUIRED,
downloadAvailable = true,
),
output: me.maxistar.voiceinbox.core.OutputSetupSnapshot = me.maxistar.voiceinbox.core.OutputSetupSnapshot(
me.maxistar.voiceinbox.core.OutputSetupSnapshotState.REQUIRED,
),
) = AndroidMainScreenInput(
model = model,
output = output,
folder = me.maxistar.voiceinbox.core.FolderSetupSnapshot(
me.maxistar.voiceinbox.core.FolderSetupSnapshotState.UNSELECTED,
),
hydration = AndroidMainScreenHydration(true, true, true, true),
onboardingLifecycle = AndroidOnboardingHintLifecycle.ACTIVE,
)

private fun seedCatalogEntry(
source: Uri,
displayName: String,
Expand Down
208 changes: 208 additions & 0 deletions app/src/main/java/me/maxistar/voiceinbox/AndroidInlineOnboarding.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package me.maxistar.voiceinbox

import android.content.SharedPreferences
import me.maxistar.voiceinbox.core.FolderSetupSnapshot
import me.maxistar.voiceinbox.core.FolderSetupSnapshotState
import me.maxistar.voiceinbox.core.ModelSetupSnapshot
import me.maxistar.voiceinbox.core.ModelSetupSnapshotState
import me.maxistar.voiceinbox.core.OutputSetupSnapshot
import me.maxistar.voiceinbox.core.OutputSetupSnapshotState
import me.maxistar.voiceinbox.core.TaskActionKind
import me.maxistar.voiceinbox.core.TaskListFilter

enum class AndroidOnboardingHintLifecycle {
ACTIVE,
DISMISSED,
COMPLETED,
}

interface AndroidOnboardingHintStorage {
fun loadRaw(): String?
fun saveRaw(value: String)
}

class AndroidOnboardingHintStore(
private val storage: AndroidOnboardingHintStorage,
) {
constructor(preferences: SharedPreferences) : this(
SharedPreferencesAndroidOnboardingHintStorage(preferences),
)

fun load(): AndroidOnboardingHintLifecycle = when (storage.loadRaw()) {
VALUE_DISMISSED -> AndroidOnboardingHintLifecycle.DISMISSED
VALUE_COMPLETED -> AndroidOnboardingHintLifecycle.COMPLETED
else -> AndroidOnboardingHintLifecycle.ACTIVE
}

fun save(lifecycle: AndroidOnboardingHintLifecycle) {
storage.saveRaw(
when (lifecycle) {
AndroidOnboardingHintLifecycle.ACTIVE -> VALUE_ACTIVE
AndroidOnboardingHintLifecycle.DISMISSED -> VALUE_DISMISSED
AndroidOnboardingHintLifecycle.COMPLETED -> VALUE_COMPLETED
},
)
}

companion object {
const val PREFERENCES_NAME = "android_inline_onboarding"
private const val VALUE_ACTIVE = "active"
private const val VALUE_DISMISSED = "dismissed"
private const val VALUE_COMPLETED = "completed"
}
}

private class SharedPreferencesAndroidOnboardingHintStorage(
private val preferences: SharedPreferences,
) : AndroidOnboardingHintStorage {
override fun loadRaw(): String? = preferences.getString(KEY_LIFECYCLE, null)

override fun saveRaw(value: String) {
preferences.edit().putString(KEY_LIFECYCLE, value).apply()
}

companion object {
private const val KEY_LIFECYCLE = "hint_lifecycle"
}
}

enum class AndroidOnboardingStepKind {
MODEL,
OUTPUT,
FOLDER,
}

data class AndroidOnboardingChecklistStep(
val kind: AndroidOnboardingStepKind,
val label: String,
val complete: Boolean,
val optional: Boolean = false,
)

data class AndroidOnboardingHintAction(
val label: String,
val enabled: Boolean,
val kind: TaskActionKind,
)

data class AndroidOnboardingHintPresentation(
val visible: Boolean = false,
val title: String = "Set up Voice Inbox",
val explanation: String = "Follow these steps, or use the setup tasks above in any order.",
val downloadDisclosure: String? = null,
val steps: List<AndroidOnboardingChecklistStep> = emptyList(),
val action: AndroidOnboardingHintAction? = null,
) {
companion object {
val HIDDEN = AndroidOnboardingHintPresentation()
}
}

object AndroidOnboardingHintPresenter {
fun present(
lifecycle: AndroidOnboardingHintLifecycle,
filter: TaskListFilter,
hydration: AndroidMainScreenHydration,
model: ModelSetupSnapshot,
output: OutputSetupSnapshot,
folder: FolderSetupSnapshot,
): AndroidOnboardingHintPresentation {
if (
lifecycle != AndroidOnboardingHintLifecycle.ACTIVE ||
filter != TaskListFilter.NEW ||
!setupKnown(hydration) ||
allStepsComplete(model, output, folder)
) {
return AndroidOnboardingHintPresentation.HIDDEN
}

val steps = listOf(
AndroidOnboardingChecklistStep(
kind = AndroidOnboardingStepKind.MODEL,
label = "Install speech model",
complete = model.state == ModelSetupSnapshotState.READY,
),
AndroidOnboardingChecklistStep(
kind = AndroidOnboardingStepKind.OUTPUT,
label = "Select transcript output",
complete = output.state == OutputSetupSnapshotState.READY,
),
AndroidOnboardingChecklistStep(
kind = AndroidOnboardingStepKind.FOLDER,
label = "Select audio folder · Optional",
complete = folder.state == FolderSetupSnapshotState.READY,
optional = true,
),
)
val action = nextAction(model, output, folder)
return AndroidOnboardingHintPresentation(
visible = true,
steps = steps,
downloadDisclosure = "Start setup downloads the speech model to this device."
.takeIf {
action.enabled && action.kind in setOf(
TaskActionKind.DOWNLOAD_MODEL,
TaskActionKind.RETRY_MODEL_DOWNLOAD,
)
},
action = action,
)
}

fun shouldComplete(
lifecycle: AndroidOnboardingHintLifecycle,
hydration: AndroidMainScreenHydration,
model: ModelSetupSnapshot,
output: OutputSetupSnapshot,
folder: FolderSetupSnapshot,
): Boolean = lifecycle == AndroidOnboardingHintLifecycle.ACTIVE &&
setupKnown(hydration) &&
allStepsComplete(model, output, folder)

private fun setupKnown(hydration: AndroidMainScreenHydration): Boolean =
hydration.modelKnown && hydration.outputKnown && hydration.folderKnown

private fun allStepsComplete(
model: ModelSetupSnapshot,
output: OutputSetupSnapshot,
folder: FolderSetupSnapshot,
): Boolean = model.state == ModelSetupSnapshotState.READY &&
output.state == OutputSetupSnapshotState.READY &&
folder.state == FolderSetupSnapshotState.READY

private fun nextAction(
model: ModelSetupSnapshot,
output: OutputSetupSnapshot,
folder: FolderSetupSnapshot,
): AndroidOnboardingHintAction = when {
model.state == ModelSetupSnapshotState.INSTALLING -> AndroidOnboardingHintAction(
label = "Installing speech model…",
enabled = false,
kind = TaskActionKind.DOWNLOAD_MODEL,
)
model.state != ModelSetupSnapshotState.READY && model.downloadAvailable -> AndroidOnboardingHintAction(
label = if (model.state == ModelSetupSnapshotState.INVALID) "Retry setup" else "Start setup",
enabled = true,
kind = if (model.state == ModelSetupSnapshotState.INVALID) {
TaskActionKind.RETRY_MODEL_DOWNLOAD
} else {
TaskActionKind.DOWNLOAD_MODEL
},
)
model.state != ModelSetupSnapshotState.READY -> AndroidOnboardingHintAction(
label = "Install model from folder",
enabled = true,
kind = TaskActionKind.IMPORT_MODEL,
)
output.state != OutputSetupSnapshotState.READY -> AndroidOnboardingHintAction(
label = "Select output file",
enabled = true,
kind = TaskActionKind.SELECT_OUTPUT,
)
else -> AndroidOnboardingHintAction(
label = "Select audio folder (optional)",
enabled = folder.state != FolderSetupSnapshotState.SCANNING,
kind = TaskActionKind.SELECT_FOLDER,
)
}
}
Loading
Loading