diff --git a/.gitignore b/.gitignore
index 9f4d7b4..c91454d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
*.iml
.gradle
+/.kotlin/
/local.properties
/.idea/caches
/.idea/libraries
diff --git a/README.md b/README.md
index 565662d..1d9b103 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,8 @@ into an existing plain-text (`.txt`) or Markdown (`.md`) file.
1. Download and verify the speech model on first launch.
2. Select an existing writable `.txt` or `.md` file.
3. Select a folder containing audio recordings.
-4. Review discovered recordings in the **New** tab and choose **Transcribe all**.
+4. Review discovered recordings in the **New** tab and choose **Transcribe all**, or
+ use the startup-processing policy when the app opens.
5. The application processes new recordings sequentially in foreground work and
appends one entry per successful file containing its filename, recording
time, and recognized text.
@@ -18,6 +19,20 @@ application preserves existing text and separates each new entry with one blank
line. Folder scanning runs when the application starts and when **Refresh** is
selected. It scans direct children only and does not traverse nested folders.
+## Automatic Processing
+
+Android Settings provides two independent automation options:
+
+- **Startup processing** runs after the app opens, restores its selections, and
+ finishes scanning the audio folder. It defaults to asking before processing;
+ it can instead transcribe automatically or leave files queued.
+- **Nightly transcription** uses Android background work around the selected
+ time. Android may delay this work to save battery, so exact timing is not
+ guaranteed.
+
+Both options reuse the same batch transcription worker and exclude failed files,
+which remain available through their individual **Retry** actions.
+
## Incremental Catalog
The application stores folder entries and processing state in a private SQLite
@@ -83,15 +98,30 @@ Requirements:
The Gradle build extracts ONNX Runtime, builds the Rust JNI library for
`arm64-v8a`, and packages the required native libraries.
-## iOS Shell
+## iOS Application
+
+The repository includes a SwiftUI iOS application at
+`iosApp/VoiceInbox.xcodeproj`. It uses the generated `Shared` Kotlin
+Multiplatform framework for the shared catalog, transcription rules, and speech
+model metadata, and links the Rust transcription engine through the native iOS
+bridge.
+
+The current iOS application supports:
+
+- selecting and scanning an audio inbox folder
+- importing individual audio files and receiving files through the share
+ extension
+- selecting an existing transcript output file
+- downloading or manually installing the pinned speech model
+- previewing audio and transcribing or retrying individual files
+- processing queued files when the application starts, with **Ask**, **Yes**, and
+ **No** startup policies
+- viewing processed transcripts and persisting catalog state
+- changing storage and startup-processing preferences from Settings
-The repository includes a minimal SwiftUI iOS shell at
-`iosApp/VoiceInbox.xcodeproj`. It is a KMP wiring milestone: the app launches to
-a simple screen and imports the generated `Shared` Kotlin Multiplatform
-framework to display speech-model metadata from shared core. The shell also
-includes a Settings screen that persists scheduled transcription preferences in
-iOS `UserDefaults` and normalizes them through shared scheduling rules; iOS
-scheduled execution itself remains future work.
+Both applications provide configurable startup processing. Unlike Android, iOS
+does not currently schedule nightly background transcription. Its automatic
+processing is evaluated when the application starts or becomes active.
Open the project from `notes_recognition`:
@@ -122,9 +152,9 @@ Xcode SDK/configuration. The lower-level shared compile checks remain:
./gradlew :shared:compileKotlinIosSimulatorArm64 :shared:compileKotlinIosX64 :shared:compileKotlinIosArm64
```
-This shell intentionally does not implement document picking, folder scanning,
-audio playback, catalog persistence, transcription execution, settings,
-scheduling, output-file writing, or Rust/iOS native bridging.
+The iOS and Android applications share workflow rules and catalog behavior, but
+their platform integrations remain native: SwiftUI and Apple document pickers on
+iOS, and Android views, the Storage Access Framework, and WorkManager on Android.
## GitHub Actions
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 07fa8df..a4e405e 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -85,6 +85,9 @@ dependencies {
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.viewmodel.ktx)
+ implementation(libs.androidx.recyclerview)
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.22.0")
implementation("androidx.work:work-runtime-ktx:2.10.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/AudioShareIntentInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/AudioShareIntentInstrumentedTest.kt
new file mode 100644
index 0000000..148cabd
--- /dev/null
+++ b/app/src/androidTest/java/me/maxistar/voiceinbox/AudioShareIntentInstrumentedTest.kt
@@ -0,0 +1,158 @@
+package me.maxistar.voiceinbox
+
+import android.content.ClipData
+import android.content.Intent
+import android.net.Uri
+import android.provider.DocumentsContract
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import me.maxistar.voiceinbox.core.AndroidSqlDelightAudioCatalogFactory
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import java.util.concurrent.TimeUnit
+
+@RunWith(AndroidJUnit4::class)
+class AudioShareIntentInstrumentedTest {
+ private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
+
+ @Before
+ fun clearState() {
+ context.deleteDatabase(AndroidSqlDelightAudioCatalogFactory.DATABASE_NAME)
+ File(context.filesDir, AndroidAudioImportConstants.DIRECTORY_NAME).deleteRecursively()
+ WorkManager.getInstance(context)
+ .cancelUniqueWork(TranscriptionWorker.UNIQUE_WORK_NAME)
+ .result.get(30, TimeUnit.SECONDS)
+ }
+
+ @Test
+ fun parserNormalizesSingleMultipleClipAndMalformedIntents() {
+ val wav = documentUri("wav")
+ val m4a = documentUri("m4a")
+ val single = Intent(Intent.ACTION_SEND)
+ .putExtra(Intent.EXTRA_STREAM, wav)
+ assertEquals(listOf(wav), AudioShareIntentParser.streamUris(single))
+
+ val multiple = Intent(Intent.ACTION_SEND_MULTIPLE)
+ .putParcelableArrayListExtra(Intent.EXTRA_STREAM, arrayListOf(wav, m4a, wav))
+ .apply { clipData = ClipData.newRawUri("audio", m4a) }
+ assertEquals(listOf(m4a, wav), AudioShareIntentParser.streamUris(multiple))
+ assertTrue(AudioShareIntentParser.streamUris(Intent(Intent.ACTION_SEND)).isEmpty())
+ assertTrue(AudioShareIntentParser.streamUris(Intent(Intent.ACTION_VIEW)).isEmpty())
+ }
+
+ @Test
+ fun coldWarmAndRecreatedDeliveryCreatesOneDurableImport() {
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ scenario.onActivity { activity ->
+ MainActivity::class.java.getDeclaredMethod("handleShareIntent", Intent::class.java)
+ .apply { isAccessible = true }
+ .invoke(activity, shareIntent(documentUri("wav")))
+ }
+ awaitImportFinished(scenario)
+ assertEquals(1, importedRows())
+ assertTrue(importedDocumentIds().single().startsWith("content://${context.packageName}.files/"))
+ assertTrue(
+ WorkManager.getInstance(context)
+ .getWorkInfosForUniqueWork(TranscriptionWorker.UNIQUE_WORK_NAME)
+ .get(30, TimeUnit.SECONDS)
+ .none { it.state in ACTIVE_WORK_STATES },
+ )
+
+ scenario.onActivity { activity ->
+ MainActivity::class.java.getDeclaredMethod("onNewIntent", Intent::class.java)
+ .apply { isAccessible = true }
+ .invoke(activity, shareIntent(documentUri("wav")))
+ }
+ awaitImportFinished(scenario)
+ assertEquals(1, importedRows())
+ }
+ }
+
+ @Test
+ fun pickerStyleMultipleInputKeepsAcceptedItemWhenAnotherIsRejected() {
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ scenario.onActivity { activity ->
+ MainActivity::class.java.getDeclaredMethod("ingestAudioUris", List::class.java)
+ .apply { isAccessible = true }
+ .invoke(activity, listOf(documentUri("m4a"), documentUri("text")))
+ }
+ awaitImportFinished(scenario)
+ assertEquals(1, importedRows())
+ }
+ }
+
+ @Test
+ fun telegramStyleOggAndOpusMultipleShareImportsBothItems() {
+ val multiple = Intent(Intent.ACTION_SEND_MULTIPLE)
+ .setType("audio/*")
+ .putParcelableArrayListExtra(
+ Intent.EXTRA_STREAM,
+ arrayListOf(documentUri("ogg"), documentUri("opus")),
+ )
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ scenario.onActivity { activity ->
+ MainActivity::class.java.getDeclaredMethod("handleShareIntent", Intent::class.java)
+ .apply { isAccessible = true }
+ .invoke(activity, multiple)
+ }
+ awaitImportFinished(scenario)
+ assertEquals(2, importedRows())
+ }
+ }
+
+ private fun awaitImportFinished(scenario: ActivityScenario) {
+ repeat(100) {
+ var active = true
+ scenario.onActivity { activity ->
+ active = MainActivity::class.java.getDeclaredField("ingestionActive")
+ .apply { isAccessible = true }
+ .getBoolean(activity)
+ }
+ if (!active) return
+ Thread.sleep(25)
+ }
+ error("Timed out waiting for audio ingestion")
+ }
+
+ private fun importedRows(): Int {
+ val repository = AndroidSqlDelightAudioCatalogFactory(context).create()
+ return try {
+ repository.importedFiles(AndroidAudioImportConstants.SOURCE_ID).size
+ } finally {
+ repository.close()
+ }
+ }
+
+ private fun importedDocumentIds(): List {
+ val repository = AndroidSqlDelightAudioCatalogFactory(context).create()
+ return try {
+ repository.importedFiles(AndroidAudioImportConstants.SOURCE_ID).map { it.documentUri }
+ } finally {
+ repository.close()
+ }
+ }
+
+ private fun shareIntent(uri: Uri): Intent =
+ Intent(context, MainActivity::class.java)
+ .setAction(Intent.ACTION_SEND)
+ .setType("audio/wav")
+ .putExtra(Intent.EXTRA_STREAM, uri)
+
+ private fun documentUri(id: String): Uri =
+ DocumentsContract.buildDocumentUri(TestAudioDocumentsProvider.AUTHORITY, id)
+
+ private companion object {
+ val ACTIVE_WORK_STATES = setOf(
+ WorkInfo.State.ENQUEUED,
+ WorkInfo.State.BLOCKED,
+ WorkInfo.State.RUNNING,
+ )
+ }
+}
diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
index 7cbdd92..31694a1 100644
--- a/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
+++ b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
@@ -1,188 +1,478 @@
package me.maxistar.voiceinbox
-import me.maxistar.voiceinbox.core.*
-
import android.content.Context
import android.net.Uri
+import android.provider.DocumentsContract
+import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu
-import androidx.test.espresso.action.ViewActions.click
-import androidx.test.espresso.action.ViewActions.longClick
-import androidx.test.espresso.action.ViewActions.scrollTo
import androidx.test.espresso.assertion.ViewAssertions.matches
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
-import org.hamcrest.Matchers.containsString
-import org.hamcrest.Matchers.not
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.floatingactionbutton.FloatingActionButton
+import me.maxistar.voiceinbox.core.AudioFileState
+import me.maxistar.voiceinbox.core.AudioTaskPresentation
+import me.maxistar.voiceinbox.core.AndroidSqlDelightAudioCatalogFactory
+import me.maxistar.voiceinbox.core.ModelSetupSnapshotState
+import me.maxistar.voiceinbox.core.TaskActionKind
+import me.maxistar.voiceinbox.core.TaskListFilter
+import me.maxistar.voiceinbox.core.TranscriptionObservationState
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
+import java.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class)
class MainActivityInstrumentedTest {
@Test
- fun selectionSummariesAndMenuActionsAreVisible() {
+ fun taskListShellAndCurrentMenuActionsAreVisible() {
clearActivityState()
- ActivityScenario.launch(MainActivity::class.java).use {
- onView(withId(R.id.statusTitle)).check(matches(isDisplayed()))
- onView(withText(R.string.output_not_selected)).check(matches(isDisplayed()))
- onView(withText(R.string.folder_not_selected)).check(matches(isDisplayed()))
- onView(withId(R.id.selectOutput)).check(matches(isDisplayed()))
- onView(withId(R.id.selectFolder)).check(matches(isDisplayed()))
- onView(withContentDescription(R.string.menu_refresh_folder)).check(matches(isDisplayed()))
- onView(withId(R.id.transcribeAll)).check(matches(not(isDisplayed())))
-
- openActionBarOverflowOrOptionsMenu(
- InstrumentationRegistry.getInstrumentation().targetContext,
- )
- onView(withText(R.string.menu_select_output)).check(matches(isDisplayed()))
- onView(withText(R.string.menu_select_folder)).check(matches(isDisplayed()))
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ onView(withId(R.id.importAudio)).check(matches(isDisplayed()))
+ onView(withId(R.id.newTab)).check(matches(isDisplayed()))
+ onView(withId(R.id.processedTab)).check(matches(isDisplayed()))
+ onView(withId(R.id.allTab)).check(matches(isDisplayed()))
+ onView(withId(R.id.taskList)).check(matches(isDisplayed()))
+
+ awaitActivity(scenario) { activity ->
+ descendants(activity.findViewById(R.id.taskList))
+ .filterIsInstance()
+ .any()
+ }
+ scenario.onActivity { activity ->
+ assertTrue(activity.findViewById(R.id.importAudio) is FloatingActionButton)
+ val rowButtons = descendants(activity.findViewById(R.id.taskList))
+ .filterIsInstance()
+ .toList()
+ assertTrue(rowButtons.isNotEmpty())
+ assertTrue(rowButtons.all { it is MaterialButton })
+ }
+
+ openActionBarOverflowOrOptionsMenu(InstrumentationRegistry.getInstrumentation().targetContext)
+ onView(withText(R.string.menu_settings)).check(matches(isDisplayed()))
}
}
@Test
- fun transcribeAllIsVisibleOnlyOnNewTabWhenPendingWorkExists() {
+ fun setupSelectionActionsRemainAvailableBeforeModelReadiness() {
clearActivityState()
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
scenario.onActivity { activity ->
- MainActivity::class.java.getDeclaredField("modelReady")
- .apply { isAccessible = true }
- .setBoolean(activity, true)
- MainActivity::class.java.getDeclaredField("outputUri")
- .apply { isAccessible = true }
- .set(activity, Uri.parse("content://output"))
- MainActivity::class.java.getDeclaredField("folderUri")
- .apply { isAccessible = true }
- .set(activity, Uri.parse("content://folder"))
- MainActivity::class.java.getDeclaredField("pendingCount")
- .apply { isAccessible = true }
- .setInt(activity, 2)
- MainActivity::class.java.getDeclaredField("transcriptionState")
- .apply { isAccessible = true }
- .set(activity, TranscriptionObservationState.IDLE)
- MainActivity::class.java.getDeclaredMethod("updateControls")
- .apply { isAccessible = true }
- .invoke(activity)
- }
-
- onView(withId(R.id.transcribeAll)).perform(scrollTo()).check(matches(isDisplayed()))
+ setField(activity, "modelReady", false)
+ setField(activity, "modelSetupState", ModelSetupSnapshotState.REQUIRED)
+ setField(activity, "modelDownloadAvailable", true)
+ setField(activity, "transcriptionState", TranscriptionObservationState.IDLE)
+ invoke(activity, "updateControls")
+ }
+ awaitActivity(scenario) { activity ->
+ val setup = displayItems(activity).filterIsInstance()
+ setup.any { row -> row.task.actions.any { it.kind == TaskActionKind.DOWNLOAD_MODEL && it.enabled } } &&
+ setup.any { row -> row.task.actions.any { it.kind == TaskActionKind.IMPORT_MODEL && it.enabled } } &&
+ setup.any { row -> row.task.actions.any { it.kind == TaskActionKind.SELECT_OUTPUT && it.enabled } }
+ }
+ }
+ }
+
+ @Test
+ fun filtersUseOneStableDisplayListAndRestoreAcrossRecreation() {
+ clearActivityState()
+ val imported = Uri.parse(AndroidAudioImportConstants.SOURCE_ID)
+ seedCatalogEntry(imported, "pending.ogg", AudioFileState.PENDING, 10)
+ seedCatalogEntry(imported, "done.ogg", AudioFileState.PROCESSED, 20)
+
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ awaitActivity(scenario) { activity -> audioTitles(activity) == listOf("pending.ogg") }
+ scenario.onActivity { it.findViewById(R.id.allTab).performClick() }
+ awaitActivity(scenario) { activity -> audioTitles(activity).toSet() == setOf("pending.ogg", "done.ogg") }
+
+ val before = mutableMapOf()
scenario.onActivity { activity ->
- activity.findViewById(R.id.processedTab).performClick()
- MainActivity::class.java.getDeclaredMethod("updateControls")
- .apply { isAccessible = true }
- .invoke(activity)
+ displayItems(activity).forEach { before[it.stableKey] = TaskListAdapter.stableLongId(it.stableKey) }
}
+ scenario.recreate()
- onView(withId(R.id.transcribeAll)).check(matches(not(isDisplayed())))
+ awaitActivity(scenario) { activity ->
+ selectedFilter(activity) == TaskListFilter.ALL &&
+ audioTitles(activity).toSet() == setOf("pending.ogg", "done.ogg") &&
+ displayItems(activity).all { TaskListAdapter.stableLongId(it.stableKey) == before[it.stableKey] }
+ }
}
}
@Test
- fun activityRecreationKeepsStatusBlockVisible() {
+ fun activeModelInstallationReattachesAcrossRecreation() {
clearActivityState()
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val request = OneTimeWorkRequestBuilder()
+ .setInitialDelay(1, TimeUnit.DAYS)
+ .setInputData(
+ androidx.work.workDataOf(
+ SpeechModelImportWorker.KEY_TREE_URI to "content://model/tree/root",
+ ),
+ )
+ .build()
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ SpeechModelInstallationWork.UNIQUE_WORK_NAME,
+ ExistingWorkPolicy.REPLACE,
+ request,
+ ).result.get(30, TimeUnit.SECONDS)
+
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ awaitActivity(scenario) { activity -> activeModelRow(activity) }
scenario.recreate()
+ awaitActivity(scenario) { activity -> activeModelRow(activity) }
+ }
+ }
- onView(withId(R.id.statusTitle)).check(matches(isDisplayed()))
+ @Test
+ fun importOnlyProcessingRowsSurviveForegroundAndRecreationWithoutFolder() {
+ clearActivityState()
+ val imported = Uri.parse(AndroidAudioImportConstants.SOURCE_ID)
+ seedCatalogEntry(imported, "processing.ogg", AudioFileState.PROCESSING, 20)
+ seedCatalogEntry(imported, "pending.ogg", AudioFileState.PENDING, 10)
+
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ awaitActivity(scenario) { activity ->
+ audioTitles(activity).toSet() == setOf("processing.ogg", "pending.ogg")
+ }
+ scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED)
+ scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED)
+ awaitActivity(scenario) { activity ->
+ audioTitles(activity).toSet() == setOf("processing.ogg", "pending.ogg")
+ }
+ scenario.recreate()
+ awaitActivity(scenario) { activity ->
+ audioTitles(activity).toSet() == setOf("processing.ogg", "pending.ogg")
+ }
+ }
+ }
+
+ @Test
+ fun failedNoSpeechAndTranscriptActionsAppearInProcessedAndAll() {
+ clearActivityState()
+ val imported = Uri.parse(AndroidAudioImportConstants.SOURCE_ID)
+ seedCatalogEntry(imported, "failed.ogg", AudioFileState.FAILED, 10, "decode failed")
+ seedCatalogEntry(imported, "silent.ogg", AudioFileState.FAILED, 20, "No speech detected")
+ seedCatalogEntry(imported, "done.ogg", AudioFileState.PROCESSED, 30, transcript = "recognized words")
+
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ scenario.onActivity { it.findViewById(R.id.processedTab).performClick() }
+ awaitActivity(scenario) { activity ->
+ val rows = audioRows(activity).associateBy { it.task.title }
+ rows.keys == setOf("failed.ogg", "silent.ogg", "done.ogg") &&
+ rows.getValue("failed.ogg").task.actions.any { it.kind == TaskActionKind.RETRY_TRANSCRIPTION } &&
+ rows.getValue("silent.ogg").task.badge == "No speech" &&
+ rows.getValue("done.ogg").task.actions.any { it.kind == TaskActionKind.SHOW_TEXT }
+ }
}
}
@Test
- fun failedRowsShowErrorAndRetryAction() {
+ fun downloadAndImportUseOneInstallationWorkSlot() {
+ clearActivityState()
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val importRequest = OneTimeWorkRequestBuilder()
+ .setInitialDelay(1, TimeUnit.DAYS)
+ .setInputData(
+ androidx.work.workDataOf(
+ SpeechModelImportWorker.KEY_TREE_URI to "content://model/tree/root",
+ ),
+ )
+ .build()
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ SpeechModelInstallationWork.UNIQUE_WORK_NAME,
+ ExistingWorkPolicy.KEEP,
+ importRequest,
+ ).result.get(30, TimeUnit.SECONDS)
+
+ SpeechModelDownloadWorker.enqueue(context)
+
+ val active = WorkManager.getInstance(context)
+ .getWorkInfosForUniqueWork(SpeechModelInstallationWork.UNIQUE_WORK_NAME)
+ .get(30, TimeUnit.SECONDS)
+ .filter { !it.state.isFinished }
+ assertEquals(1, active.size)
+ assertEquals(importRequest.id, active.single().id)
+ }
+
+ @Test
+ fun refreshActionHasStableIdleBusyAndCompletedPresentation() {
clearActivityState()
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ awaitActivity(scenario) { activity ->
+ val hydration = stateHost(activity).currentInput.hydration
+ hydration.modelKnown && hydration.outputKnown && hydration.folderKnown && hydration.catalogKnown
+ }
+ scenario.onActivity { activity ->
+ val action = refreshAction(activity)
+ assertEquals(android.view.View.GONE, action.visibility)
+
+ stateHost(activity).update { input ->
+ input.copy(
+ folderSync = AndroidFolderSyncPresentation(
+ visible = true,
+ enabled = true,
+ ),
+ )
+ }
+ }
+ awaitActivity(scenario) { activity ->
+ val action = refreshAction(activity)
+ action.visibility == android.view.View.VISIBLE && action.isEnabled
+ }
+ scenario.onActivity { activity ->
+ val action = refreshAction(activity)
+ assertEquals(android.view.View.VISIBLE, action.visibility)
+ assertTrue(action.isEnabled)
+ assertEquals(
+ AndroidFolderSyncPresentation.ACCESSIBILITY_REFRESH,
+ action.contentDescription,
+ )
+ }
+
+ scenario.onActivity { activity ->
+ stateHost(activity).update { input ->
+ input.copy(folderSync = AndroidFolderSyncPresentation(visible = true))
+ }
+ }
+ awaitActivity(scenario) { activity -> !refreshAction(activity).isEnabled }
scenario.onActivity { activity ->
- val entry = AudioCatalogEntry(
- id = 42,
- folderUri = "content://folder",
- documentUri = "content://audio/broken.wav",
- displayName = "broken.wav",
- mimeType = "audio/wav",
- fingerprint = AudioFileFingerprint(sizeBytes = 1024, modifiedMillis = 10),
- state = AudioFileState.FAILED,
- stateBeforeMissing = null,
- lastError = "decode failed",
- processedAtMillis = null,
- transcriptText = null,
+ assertEquals(
+ AndroidFolderSyncPresentation.ACCESSIBILITY_REFRESH,
+ refreshAction(activity).contentDescription,
)
- val row = MainActivity::class.java
- .getDeclaredMethod(
- "createEntryView",
- AudioCatalogEntry::class.java,
- MainScreenRowState::class.java,
+ }
+
+ scenario.onActivity { activity ->
+ stateHost(activity).update { input ->
+ input.copy(
+ folderSync = AndroidFolderSyncPresentation(
+ visible = true,
+ active = true,
+ accessibilityLabel = AndroidFolderSyncPresentation.ACCESSIBILITY_REFRESHING,
+ ),
)
- .apply { isAccessible = true }
- .invoke(activity, entry, rowState(entry)) as android.view.View
- activity.findViewById(R.id.fileList).addView(row)
+ }
+ stateHost(activity).update { input ->
+ input.copy(folderSync = AndroidFolderSyncPresentation(visible = true, enabled = true))
+ }
}
+ Thread.sleep(220)
+ scenario.onActivity { activity -> assertEquals(0f, refreshAction(activity).rotation) }
- onView(withText("broken.wav")).perform(scrollTo()).check(matches(isDisplayed()))
- onView(withText(containsString("decode failed"))).perform(scrollTo()).check(matches(isDisplayed()))
- onView(withText("Play")).perform(scrollTo()).check(matches(isDisplayed()))
- onView(withText("Retry")).perform(scrollTo()).check(matches(isDisplayed()))
+ scenario.onActivity { activity ->
+ stateHost(activity).update { input ->
+ input.copy(
+ folderSync = AndroidFolderSyncPresentation(
+ visible = true,
+ active = true,
+ accessibilityLabel = AndroidFolderSyncPresentation.ACCESSIBILITY_REFRESHING,
+ ),
+ )
+ }
+ }
+ awaitActivity(scenario) { activity -> !refreshAction(activity).isEnabled }
+ scenario.onActivity { activity ->
+ val action = refreshAction(activity)
+ assertFalse(action.isEnabled)
+ assertEquals(
+ AndroidFolderSyncPresentation.ACCESSIBILITY_REFRESHING,
+ action.contentDescription,
+ )
+ val before = stateHost(activity).currentInput.folderSync
+ action.performClick()
+ assertEquals(before, stateHost(activity).currentInput.folderSync)
+ assertFalse(stateHost(activity).folderSyncCoordinator.state.active)
+ }
+ Thread.sleep(250)
+ scenario.onActivity { activity -> assertTrue(refreshAction(activity).rotation != 0f) }
+ scenario.onActivity { activity ->
+ stateHost(activity).update { input ->
+ input.copy(
+ folderSync = AndroidFolderSyncPresentation(
+ visible = true,
+ enabled = true,
+ ),
+ )
+ }
+ }
+ Thread.sleep(500)
+ awaitActivity(scenario) { activity ->
+ val action = refreshAction(activity)
+ action.isEnabled && action.rotation == 0f
+ }
+ scenario.onActivity { activity ->
+ val action = refreshAction(activity)
+ assertEquals(0f, action.rotation)
+ assertTrue(action.isEnabled)
+ stateHost(activity).update { input ->
+ input.copy(folderSync = AndroidFolderSyncPresentation())
+ }
+ }
+ awaitActivity(scenario) { activity ->
+ refreshAction(activity).visibility == android.view.View.GONE
+ }
}
}
@Test
- fun processedRowContextMenuShowsStoredTranscriptText() {
+ fun progressHeavyUpdatesKeepTheSameRowAndActionView() {
clearActivityState()
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ awaitActivity(scenario) { activity -> stateHost(activity).currentInput.hydration.modelKnown }
scenario.onActivity { activity ->
- val entry = AudioCatalogEntry(
- id = 43,
- folderUri = "content://folder",
- documentUri = "content://audio/done.wav",
- displayName = "done.wav",
- mimeType = "audio/wav",
- fingerprint = AudioFileFingerprint(sizeBytes = 2048, modifiedMillis = 20),
- state = AudioFileState.PROCESSED,
- stateBeforeMissing = null,
- lastError = null,
- processedAtMillis = 500,
- transcriptText = "recognized words from the note",
+ stateHost(activity).replace(
+ AndroidMainScreenInput(
+ model = me.maxistar.voiceinbox.core.ModelSetupSnapshot(
+ state = ModelSetupSnapshotState.INSTALLING,
+ installationPhase = "Downloading",
+ progressPercent = 10,
+ canCancel = true,
+ ),
+ 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),
+ ),
)
- val row = MainActivity::class.java
- .getDeclaredMethod(
- "createEntryView",
- AudioCatalogEntry::class.java,
- MainScreenRowState::class.java,
- )
- .apply { isAccessible = true }
- .invoke(activity, entry, rowState(entry)) as android.view.View
- activity.findViewById(R.id.fileList).addView(row)
}
+ awaitActivity(scenario) { activity -> displayItems(activity).singleOrNull()?.stableKey == "setup:model" }
- onView(withContentDescription("done.wav")).perform(longClick())
- onView(withText("Show text")).perform(click())
- onView(withText("recognized words from the note")).check(matches(isDisplayed()))
+ lateinit var rowBefore: android.view.View
+ lateinit var actionBefore: android.view.View
+ scenario.onActivity { activity ->
+ val list = activity.findViewById(R.id.taskList)
+ rowBefore = list.layoutManager!!.findViewByPosition(0)!!
+ actionBefore = rowBefore.findViewById(R.id.taskActions).getChildAt(0)
+ }
+ (20..100 step 10).forEach { percent ->
+ scenario.onActivity { activity ->
+ stateHost(activity).update { input ->
+ input.copy(model = input.model.copy(progressPercent = percent))
+ }
+ }
+ awaitActivity(scenario) { activity ->
+ val model = displayItems(activity).singleOrNull() as? TaskListDisplayItem.Setup
+ model?.task?.progress?.percent == percent
+ }
+ }
+ scenario.onActivity { activity ->
+ val rowAfter = activity.findViewById(R.id.taskList)
+ .layoutManager!!.findViewByPosition(0)!!
+ val actionAfter = rowAfter.findViewById(R.id.taskActions).getChildAt(0)
+ assertSame(rowBefore, rowAfter)
+ assertSame(actionBefore, actionAfter)
+ }
}
}
private fun clearActivityState() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
- context.getSharedPreferences(DocumentSelectionStore.PREFERENCES_NAME, Context.MODE_PRIVATE)
- .edit()
- .clear()
- .commit()
+ WorkManager.getInstance(context).cancelUniqueWork(TranscriptionWorker.UNIQUE_WORK_NAME).result.get(30, TimeUnit.SECONDS)
+ WorkManager.getInstance(context).cancelUniqueWork(SpeechModelInstallationWork.UNIQUE_WORK_NAME).result.get(30, TimeUnit.SECONDS)
+ WorkManager.getInstance(context).pruneWork().result.get(30, TimeUnit.SECONDS)
+ 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.deleteDatabase(AndroidSqlDelightAudioCatalogFactory.DATABASE_NAME)
+ java.io.File(context.filesDir, AndroidAudioImportConstants.DIRECTORY_NAME).deleteRecursively()
+ }
+
+ private fun seedCatalogEntry(
+ source: Uri,
+ displayName: String,
+ state: AudioFileState,
+ modifiedMillis: Long,
+ error: String? = null,
+ transcript: String? = null,
+ ) {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val repository = AndroidSqlDelightAudioCatalogFactory(context).create()
+ try {
+ repository.upsertImportedFile(
+ folderUri = source.toString(),
+ documentUri = DocumentsContract.buildDocumentUri(TestAudioDocumentsProvider.AUTHORITY, displayName).toString(),
+ displayName = displayName,
+ mimeType = "audio/ogg",
+ sizeBytes = 100,
+ importedAtMillis = modifiedMillis,
+ state = state,
+ lastError = error,
+ processedAtMillis = modifiedMillis.takeIf { state == AudioFileState.PROCESSED || state == AudioFileState.FAILED },
+ transcriptText = transcript,
+ durationUs = null,
+ )
+ } finally {
+ repository.close()
+ }
+ }
+
+ private fun activeModelRow(activity: MainActivity): Boolean =
+ displayItems(activity)
+ .filterIsInstance()
+ .any { it.task.stableId == "setup:model" && it.task.progress != null }
+
+ private fun selectedFilter(activity: MainActivity): TaskListFilter =
+ stateHost(activity).state.value.taskList.filter
+
+ private fun refreshAction(activity: MainActivity): android.view.View =
+ activity.findViewById(R.id.toolbar)
+ .menu.findItem(R.id.menuRefreshFolder).actionView!!
+
+ private fun stateHost(activity: MainActivity): AndroidMainScreenStateHost =
+ MainActivity::class.java.getDeclaredMethod("getTaskStateHost")
+ .apply { isAccessible = true }
+ .invoke(activity) as AndroidMainScreenStateHost
+
+ private fun displayItems(activity: MainActivity): List =
+ (activity.findViewById(R.id.taskList).adapter as TaskListAdapter).currentList
+
+ private fun audioRows(activity: MainActivity): List =
+ displayItems(activity).filterIsInstance()
+
+ private fun audioTitles(activity: MainActivity): List = audioRows(activity).map { it.task.title }
+
+ private fun descendants(root: android.view.ViewGroup): Sequence = sequence {
+ for (index in 0 until root.childCount) {
+ val child = root.getChildAt(index)
+ yield(child)
+ if (child is android.view.ViewGroup) yieldAll(descendants(child))
+ }
}
- private fun rowState(entry: AudioCatalogEntry): MainScreenRowState =
- MainScreenStateController.rowState(
- row = MainScreenRowInput(
- entryId = entry.id,
- state = entry.state,
- hasTranscriptText = !entry.transcriptText.isNullOrBlank(),
- ),
- activePreviewEntryId = null,
- previewState = PreviewPlaybackState.IDLE,
- transcriptionState = TranscriptionObservationState.IDLE,
- busy = false,
- retryEnabled = true,
- )
+ private fun awaitActivity(
+ scenario: ActivityScenario,
+ condition: (MainActivity) -> Boolean,
+ ) {
+ repeat(200) {
+ var matches = false
+ scenario.onActivity { activity -> matches = condition(activity) }
+ if (matches) return
+ Thread.sleep(25)
+ }
+ throw AssertionError("Activity did not reach expected state")
+ }
+
+ private fun setField(activity: MainActivity, name: String, value: Any) {
+ MainActivity::class.java.getDeclaredField(name).apply { isAccessible = true }.set(activity, value)
+ }
+
+ private fun invoke(activity: MainActivity, name: String) {
+ MainActivity::class.java.getDeclaredMethod(name).apply { isAccessible = true }.invoke(activity)
+ }
}
diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/SettingsActivityInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/SettingsActivityInstrumentedTest.kt
new file mode 100644
index 0000000..fdd0a41
--- /dev/null
+++ b/app/src/androidTest/java/me/maxistar/voiceinbox/SettingsActivityInstrumentedTest.kt
@@ -0,0 +1,66 @@
+package me.maxistar.voiceinbox
+
+import android.content.Context
+import androidx.test.core.app.ActivityScenario
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.action.ViewActions.scrollTo
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.matcher.ViewMatchers.isChecked
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.espresso.matcher.ViewMatchers.withText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import me.maxistar.voiceinbox.core.StartupProcessingPolicy
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SettingsActivityInstrumentedTest {
+ @Test
+ fun startupPolicyIsDisplayedAndPersistsWithoutChangingNightlySettings() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ clearSettings(context)
+ val scheduledStore = ScheduledTranscriptionSettingsStore(
+ context.getSharedPreferences(
+ ScheduledTranscriptionSettingsStore.PREFERENCES_NAME,
+ Context.MODE_PRIVATE,
+ ),
+ )
+ scheduledStore.setEnabled(true)
+ scheduledStore.setTime(4, 30)
+
+ ActivityScenario.launch(SettingsActivity::class.java).use {
+ onView(withText(R.string.settings_storage_title)).check(matches(isDisplayed()))
+ onView(withText(R.string.settings_startup_processing_title))
+ .perform(scrollTo())
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.startupProcessingAsk)).check(matches(isChecked()))
+ onView(withId(R.id.startupProcessingAutomatic)).perform(scrollTo(), click())
+ onView(withId(R.id.scheduledSwitch)).perform(scrollTo()).check(matches(isChecked()))
+ onView(withText(R.string.settings_about_title)).perform(scrollTo()).check(matches(isDisplayed()))
+ }
+
+ val startupStore = StartupProcessingPolicyStore(
+ context.getSharedPreferences(StartupProcessingPolicyStore.PREFERENCES_NAME, Context.MODE_PRIVATE),
+ )
+ assertEquals(StartupProcessingPolicy.AUTOMATIC, startupStore.load())
+ assertTrue(scheduledStore.load().enabled)
+ assertEquals(4, scheduledStore.load().hour)
+ assertEquals(30, scheduledStore.load().minute)
+ }
+
+ private fun clearSettings(context: Context) {
+ context.getSharedPreferences(StartupProcessingPolicyStore.PREFERENCES_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .clear()
+ .commit()
+ context.getSharedPreferences(ScheduledTranscriptionSettingsStore.PREFERENCES_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .clear()
+ .commit()
+ }
+}
diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/SpeechModelLocalImporterInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/SpeechModelLocalImporterInstrumentedTest.kt
new file mode 100644
index 0000000..3050909
--- /dev/null
+++ b/app/src/androidTest/java/me/maxistar/voiceinbox/SpeechModelLocalImporterInstrumentedTest.kt
@@ -0,0 +1,90 @@
+package me.maxistar.voiceinbox
+
+import android.net.Uri
+import android.provider.DocumentsContract
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.runBlocking
+import me.maxistar.voiceinbox.core.SpeechModelFile
+import me.maxistar.voiceinbox.core.SpeechModelManifest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import java.security.MessageDigest
+
+@RunWith(AndroidJUnit4::class)
+class SpeechModelLocalImporterInstrumentedTest {
+ @Test
+ fun providerDirectoryIsCopiedIntoIndependentAppOwnedInstallation() {
+ runBlocking {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val root = File(context.cacheDir, "model-import-test").apply { deleteRecursively() }
+ val repository = SpeechModelRepository(root, manifest, usableSpace = { Long.MAX_VALUE })
+ val tree = DocumentsContract.buildTreeDocumentUri(
+ TestAudioDocumentsProvider.AUTHORITY,
+ TestAudioDocumentsProvider.ROOT_ID,
+ )
+ val progress = mutableListOf()
+
+ val installed = SpeechModelLocalImporter(context.contentResolver, repository)
+ .import(tree.toString()) { progress += it }
+ .getOrThrow()
+
+ assertTrue(installed.canonicalPath.startsWith(root.canonicalPath))
+ assertEquals("test-audio-wav", installed.resolve("recording.wav").readText())
+ assertEquals("test-audio-m4a", installed.resolve("recording.m4a").readText())
+ assertEquals(manifest.totalSizeBytes, progress.last().bytesCopied)
+ assertTrue(repository.inspect() is InstalledSpeechModelState.Ready)
+
+ File(context.cacheDir, "shared-wav.bin").delete()
+ File(context.cacheDir, "shared-m4a.bin").delete()
+ assertTrue(repository.inspect() is InstalledSpeechModelState.Ready)
+ root.deleteRecursively()
+ }
+ }
+
+ @Test
+ fun importPermissionIsRetainedForConfiguredAudioFolder() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val tree = Uri.parse("content://${TestAudioDocumentsProvider.AUTHORITY}/tree/root")
+ SpeechModelImportPermission.recordOwned(context, tree)
+ DocumentSelectionStore(
+ context.getSharedPreferences(DocumentSelectionStore.PREFERENCES_NAME, 0),
+ ).saveFolderUri(tree.toString())
+
+ SpeechModelImportPermission.releaseOwnedIfUnused(context)
+
+ assertFalse(
+ context.getSharedPreferences("speech_model_import", 0)
+ .getString("owned_uri", null).isNullOrBlank(),
+ )
+ DocumentSelectionStore(
+ context.getSharedPreferences(DocumentSelectionStore.PREFERENCES_NAME, 0),
+ ).clearFolderUri()
+ SpeechModelImportPermission.releaseOwnedIfUnused(context)
+ }
+
+ companion object {
+ private val files = linkedMapOf(
+ "recording.wav" to "test-audio-wav".toByteArray(),
+ "recording.m4a" to "test-audio-m4a".toByteArray(),
+ )
+ private val manifest = SpeechModelManifest(
+ modelId = "test/model",
+ version = "instrumented",
+ repositoryRevision = "revision",
+ files = files.map { (name, contents) ->
+ SpeechModelFile(
+ name,
+ contents.size.toLong(),
+ MessageDigest.getInstance("SHA-256").digest(contents)
+ .joinToString("") { "%02x".format(it) },
+ )
+ },
+ safetyMarginBytes = 0,
+ )
+ }
+}
diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/TestAudioDocumentsProvider.java b/app/src/androidTest/java/me/maxistar/voiceinbox/TestAudioDocumentsProvider.java
index 1fcc788..ff1f188 100644
--- a/app/src/androidTest/java/me/maxistar/voiceinbox/TestAudioDocumentsProvider.java
+++ b/app/src/androidTest/java/me/maxistar/voiceinbox/TestAudioDocumentsProvider.java
@@ -6,6 +6,12 @@
import android.database.MatrixCursor;
import android.net.Uri;
import android.provider.DocumentsContract;
+import android.os.ParcelFileDescriptor;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
public class TestAudioDocumentsProvider extends ContentProvider {
public static final String AUTHORITY = "me.maxistar.voiceinbox.test.documents";
@@ -13,8 +19,11 @@ public class TestAudioDocumentsProvider extends ContentProvider {
private static final String WAV_ID = "wav";
private static final String M4A_ID = "m4a";
+ private static final String OGG_ID = "ogg";
+ private static final String OPUS_ID = "opus";
private static final String HIDDEN_AUDIO_ID = "hidden";
private static final String TEXT_ID = "text";
+ private static final String OUTPUT_ID = "output";
private static final String NESTED_ID = "nested";
private static final String NESTED_AUDIO_ID = "nested/audio";
@@ -57,8 +66,14 @@ private Cursor queryDocument(String documentId, String[] projection) {
addDocument(cursor, WAV_ID, "recording.wav", "audio/wav", 100L, 10L);
} else if (M4A_ID.equals(documentId)) {
addDocument(cursor, M4A_ID, "recording.m4a", "audio/mp4", 200L, 20L);
+ } else if (OGG_ID.equals(documentId)) {
+ addDocument(cursor, OGG_ID, "voice.ogg", "audio/ogg", 210L, 21L);
+ } else if (OPUS_ID.equals(documentId)) {
+ addDocument(cursor, OPUS_ID, "voice.opus", "audio/opus", 220L, 22L);
} else if (TEXT_ID.equals(documentId)) {
addDocument(cursor, TEXT_ID, "notes.txt", "text/plain", 20L, 30L);
+ } else if (OUTPUT_ID.equals(documentId)) {
+ addDocument(cursor, OUTPUT_ID, "transcripts.txt", "text/plain", 0L, 31L);
} else if (NESTED_ID.equals(documentId)) {
addDocument(cursor, NESTED_ID, "nested", DocumentsContract.Document.MIME_TYPE_DIR, null, null);
} else if (NESTED_AUDIO_ID.equals(documentId)) {
@@ -116,9 +131,36 @@ private static void addDocument(
@Override
public String getType(Uri uri) {
+ String documentId = documentIdAfter(uri, "document");
+ if (WAV_ID.equals(documentId) || NESTED_AUDIO_ID.equals(documentId)) return "audio/wav";
+ if (M4A_ID.equals(documentId)) return "audio/mp4";
+ if (OGG_ID.equals(documentId)) return "audio/ogg";
+ if (OPUS_ID.equals(documentId)) return "audio/opus";
+ if (TEXT_ID.equals(documentId) || OUTPUT_ID.equals(documentId)) return "text/plain";
return null;
}
+ @Override
+ public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
+ String documentId = documentIdAfter(uri, "document");
+ if (documentId == null || ROOT_ID.equals(documentId) || NESTED_ID.equals(documentId)) {
+ throw new FileNotFoundException(uri.toString());
+ }
+ File file = new File(getContext().getCacheDir(), "shared-" + documentId.replace('/', '-') + ".bin");
+ if (OUTPUT_ID.equals(documentId)) {
+ return ParcelFileDescriptor.open(
+ file,
+ ParcelFileDescriptor.MODE_CREATE | ParcelFileDescriptor.MODE_READ_WRITE
+ );
+ }
+ try (FileOutputStream output = new FileOutputStream(file)) {
+ output.write(("test-audio-" + documentId).getBytes());
+ } catch (IOException error) {
+ throw new FileNotFoundException(error.getMessage());
+ }
+ return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
+ }
+
@Override
public Uri insert(Uri uri, ContentValues values) {
return null;
diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/TranscriptionWorkerInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/TranscriptionWorkerInstrumentedTest.kt
index aa4cf38..8e0902a 100644
--- a/app/src/androidTest/java/me/maxistar/voiceinbox/TranscriptionWorkerInstrumentedTest.kt
+++ b/app/src/androidTest/java/me/maxistar/voiceinbox/TranscriptionWorkerInstrumentedTest.kt
@@ -7,6 +7,7 @@ import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
+import androidx.core.content.FileProvider
import androidx.work.WorkInfo
import androidx.work.WorkManager
import me.maxistar.voiceinbox.test.R as TestR
@@ -28,6 +29,7 @@ class TranscriptionWorkerInstrumentedTest {
@Before
fun cleanCatalog() {
targetContext.deleteDatabase(AndroidSqlDelightAudioCatalogFactory.DATABASE_NAME)
+ File(targetContext.filesDir, AndroidAudioImportConstants.DIRECTORY_NAME).deleteRecursively()
}
@Test
@@ -78,6 +80,55 @@ class TranscriptionWorkerInstrumentedTest {
)
}
+ @Test
+ fun importOnlyEntryCanRunAndRetryWithoutSelectedFolder() {
+ requireModel()
+ val importDirectory = File(targetContext.filesDir, AndroidAudioImportConstants.DIRECTORY_NAME)
+ .apply { mkdirs() }
+ val audio = File(importDirectory, "invalid-import.wav").apply {
+ writeText("not audio")
+ }
+ val audioUri = FileProvider.getUriForFile(
+ targetContext,
+ "${targetContext.packageName}.files",
+ audio,
+ )
+ val output = File(targetContext.cacheDir, "import-only-output.txt").apply {
+ writeText("unchanged")
+ }
+ val entryId = withRepository { repository ->
+ repository.upsertImportedFile(
+ folderUri = AndroidAudioImportConstants.SOURCE_ID,
+ documentUri = audioUri.toString(),
+ displayName = "invalid-import.wav",
+ mimeType = "audio/wav",
+ sizeBytes = audio.length(),
+ importedAtMillis = 10,
+ state = AudioFileState.PENDING,
+ lastError = null,
+ processedAtMillis = null,
+ transcriptText = null,
+ durationUs = null,
+ ).id
+ }
+ val manager = WorkManager.getInstance(targetContext)
+ manager.cancelUniqueWork(TranscriptionWorker.UNIQUE_WORK_NAME)
+ .result.get(30, TimeUnit.SECONDS)
+
+ val first = TranscriptionWorker.enqueueAll(targetContext, null, Uri.fromFile(output))
+ assertEquals(WorkInfo.State.SUCCEEDED, waitForFinished(manager, first).state)
+ withRepository { repository ->
+ assertEquals(
+ AudioFileState.FAILED,
+ repository.processedEntries(AndroidAudioImportConstants.SOURCE_ID).single().state,
+ )
+ }
+
+ val retry = TranscriptionWorker.enqueueRetry(targetContext, null, Uri.fromFile(output), entryId)
+ assertEquals(WorkInfo.State.SUCCEEDED, waitForFinished(manager, retry).state)
+ assertEquals("unchanged", output.readText())
+ }
+
@Test
fun failedFileDoesNotBlockLaterFileAcrossActivityRecreationAndBackgrounding() {
requireModel()
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 608a935..f5060d2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -19,17 +19,36 @@
tools:targetApi="31">
+ android:exported="true"
+ android:launchMode="singleTop">
+
+
+
+
+
+
+
+
+
+
+
+
= emptyList(),
+ val preview: PreviewTaskSnapshot = PreviewTaskSnapshot(),
+ val transcription: TranscriptionTaskSnapshot = TranscriptionTaskSnapshot(),
+ val transcriptionEligible: Boolean = false,
+ val previewEligible: Boolean = true,
+ val importEnabled: Boolean = true,
+ val hydration: AndroidMainScreenHydration = AndroidMainScreenHydration(),
+ val folderSync: AndroidFolderSyncPresentation = AndroidFolderSyncPresentation(),
+)
+
+data class AndroidMainScreenHydration(
+ val modelKnown: Boolean = false,
+ val outputKnown: Boolean = false,
+ val folderKnown: Boolean = false,
+ val catalogKnown: Boolean = false,
+)
+
+data class AndroidFolderSyncPresentation(
+ val visible: Boolean = false,
+ val active: Boolean = false,
+ val enabled: Boolean = false,
+ val accessibilityLabel: String = ACCESSIBILITY_REFRESH,
+) {
+ companion object {
+ const val ACCESSIBILITY_REFRESH = "Refresh audio folder"
+ const val ACCESSIBILITY_REFRESHING = "Refreshing audio folder"
+ }
+}
+
+internal fun androidModelTaskDetail(
+ state: ModelSetupSnapshotState,
+ message: String,
+): String? = message.takeUnless {
+ state == ModelSetupSnapshotState.READY || state == ModelSetupSnapshotState.INSTALLING
+}
+
+data class AndroidMainScreenState(
+ val taskList: TaskListState,
+ val entriesById: Map,
+ val importEnabled: Boolean,
+ val folderSync: AndroidFolderSyncPresentation,
+) {
+ val refreshFolderVisible: Boolean get() = folderSync.visible
+ val refreshFolderEnabled: Boolean get() = folderSync.enabled
+}
+
+object AndroidTaskListSnapshotMapper {
+ fun state(input: AndroidMainScreenInput): AndroidMainScreenState {
+ val audio = input.entries.map { entry ->
+ AudioTaskSnapshot(
+ entryId = entry.id,
+ title = entry.displayName,
+ detail = entry.fingerprint.sizeBytes?.let(::formatSize),
+ state = entry.state,
+ importedAtMillis = entry.fingerprint.modifiedMillis ?: entry.id,
+ terminalAtMillis = entry.processedAtMillis,
+ lastError = entry.lastError,
+ hasTranscriptText = !entry.transcriptText.isNullOrBlank(),
+ noSpeech = isNoSpeech(entry.lastError),
+ eligibleForTranscription = input.transcriptionEligible,
+ eligibleForPreview = input.previewEligible,
+ )
+ }
+ val taskList = TaskListPresentationController.state(
+ TaskListInput(
+ filter = input.filter,
+ model = input.model.takeIf { input.hydration.modelKnown }
+ ?: ModelSetupSnapshot(ModelSetupSnapshotState.READY),
+ output = input.output.takeIf { input.hydration.outputKnown }
+ ?: OutputSetupSnapshot(OutputSetupSnapshotState.READY),
+ folder = input.folder.takeIf { input.hydration.folderKnown }
+ ?: FolderSetupSnapshot(FolderSetupSnapshotState.READY),
+ audio = audio,
+ preview = input.preview,
+ transcription = input.transcription,
+ ),
+ ).let { composed ->
+ if (input.hydration.catalogKnown) {
+ composed
+ } else {
+ composed.copy(emptyMessage = null, emptyActions = emptyList())
+ }
+ }
+ return AndroidMainScreenState(
+ taskList = taskList,
+ entriesById = input.entries.associateBy(AudioCatalogEntry::id),
+ importEnabled = input.importEnabled,
+ folderSync = input.folderSync,
+ )
+ }
+
+ private fun isNoSpeech(message: String?): Boolean =
+ message?.contains("no text", ignoreCase = true) == true ||
+ message?.contains("no speech", ignoreCase = true) == true
+
+ private fun formatSize(bytes: Long): String =
+ if (bytes < 1024 * 1024) {
+ "${bytes / 1024} KiB"
+ } else {
+ "${bytes / (1024 * 1024)} MiB"
+ }
+}
+
+class AndroidMainScreenStateHost(
+ private val savedStateHandle: SavedStateHandle,
+) : ViewModel() {
+ private var input = AndroidMainScreenInput(filter = restoredFilter())
+ private val mutableState = MutableStateFlow(AndroidTaskListSnapshotMapper.state(input))
+ val folderSyncCoordinator = AndroidFolderSyncCoordinator()
+
+ val state: StateFlow = mutableState.asStateFlow()
+ val currentInput: AndroidMainScreenInput
+ get() = input
+
+ fun update(transform: (AndroidMainScreenInput) -> AndroidMainScreenInput) {
+ input = transform(input)
+ savedStateHandle[KEY_FILTER] = input.filter.name
+ mutableState.value = AndroidTaskListSnapshotMapper.state(input)
+ }
+
+ fun replace(input: AndroidMainScreenInput) {
+ update { input }
+ }
+
+ fun selectFilter(filter: TaskListFilter) {
+ update { current -> current.copy(filter = filter) }
+ }
+
+ private fun restoredFilter(): TaskListFilter =
+ savedStateHandle.get(KEY_FILTER)
+ ?.let { stored -> TaskListFilter.entries.firstOrNull { it.name == stored } }
+ ?: TaskListFilter.NEW
+
+ companion object {
+ private const val KEY_FILTER = "android-task-list-filter"
+ }
+}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/AndroidTaskActionRouter.kt b/app/src/main/java/me/maxistar/voiceinbox/AndroidTaskActionRouter.kt
new file mode 100644
index 0000000..367a020
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/AndroidTaskActionRouter.kt
@@ -0,0 +1,59 @@
+package me.maxistar.voiceinbox
+
+import me.maxistar.voiceinbox.core.AudioCatalogEntry
+import me.maxistar.voiceinbox.core.AudioTaskPresentation
+import me.maxistar.voiceinbox.core.TaskActionKind
+
+data class AndroidTaskActionRequest(
+ val stableId: String,
+ val entryId: Long?,
+ val kind: TaskActionKind,
+)
+
+fun interface AndroidTaskActionGateway {
+ fun perform(kind: TaskActionKind, entry: AudioCatalogEntry?)
+}
+
+class AndroidTaskActionRouter(
+ private val currentState: () -> AndroidMainScreenState,
+ private val gateway: AndroidTaskActionGateway,
+) {
+ fun route(request: AndroidTaskActionRequest): Boolean {
+ val state = currentState()
+ val authorized = when {
+ request.kind == TaskActionKind.TRANSCRIBE_ALL ->
+ request.stableId == TaskListDisplayItem.BatchAction.STABLE_KEY &&
+ state.taskList.batchAction.visible &&
+ state.taskList.batchAction.enabled
+ request.stableId == TaskListDisplayItem.Empty.STABLE_KEY ->
+ state.taskList.emptyActions.any { it.kind == request.kind && it.enabled }
+ else -> state.taskList.tasks
+ .firstOrNull { it.stableId == request.stableId }
+ ?.actions
+ ?.any { it.kind == request.kind && it.enabled } == true
+ }
+ if (!authorized) return false
+
+ val entry = request.entryId?.let(state.entriesById::get)
+ val taskRequiresEntry = request.kind in ENTRY_ACTIONS
+ if (taskRequiresEntry && entry == null) return false
+ val presentedEntryId = state.taskList.tasks
+ .filterIsInstance()
+ .firstOrNull { it.stableId == request.stableId }
+ ?.entryId
+ if (taskRequiresEntry && presentedEntryId != entry?.id) return false
+
+ gateway.perform(request.kind, entry)
+ return true
+ }
+
+ companion object {
+ private val ENTRY_ACTIONS = setOf(
+ TaskActionKind.TRANSCRIBE,
+ TaskActionKind.RETRY_TRANSCRIPTION,
+ TaskActionKind.PLAY,
+ TaskActionKind.STOP,
+ TaskActionKind.SHOW_TEXT,
+ )
+ }
+}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/AudioImportCoordinator.kt b/app/src/main/java/me/maxistar/voiceinbox/AudioImportCoordinator.kt
new file mode 100644
index 0000000..ea8b828
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/AudioImportCoordinator.kt
@@ -0,0 +1,367 @@
+package me.maxistar.voiceinbox
+
+import android.content.ContentResolver
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.provider.DocumentsContract
+import android.provider.OpenableColumns
+import androidx.core.content.FileProvider
+import me.maxistar.voiceinbox.core.AudioFileState
+import me.maxistar.voiceinbox.core.SqlDelightAudioCatalogRepository
+import java.io.File
+import java.io.FileOutputStream
+import java.security.MessageDigest
+import java.util.UUID
+
+object AndroidAudioImportConstants {
+ const val SOURCE_ID = "android-imported-audio"
+ const val DIRECTORY_NAME = "imported-audio"
+}
+
+data class AudioImportMetadata(
+ val sourceId: String,
+ val displayName: String?,
+ val mimeType: String?,
+ val sizeBytes: Long?,
+ val modifiedMillis: Long?,
+)
+
+data class AudioImportDestination(
+ val documentId: String,
+ val existed: Boolean,
+)
+
+sealed interface AudioImportItemOutcome {
+ val displayName: String
+
+ data class Imported(override val displayName: String) : AudioImportItemOutcome
+ data class Duplicate(override val displayName: String) : AudioImportItemOutcome
+ data class Rejected(
+ override val displayName: String,
+ val reason: String,
+ ) : AudioImportItemOutcome
+}
+
+data class AudioImportSummary(
+ val outcomes: List,
+) {
+ val importedCount: Int = outcomes.count { it is AudioImportItemOutcome.Imported }
+ val duplicateCount: Int = outcomes.count { it is AudioImportItemOutcome.Duplicate }
+ val rejectedCount: Int = outcomes.count { it is AudioImportItemOutcome.Rejected }
+
+ fun message(): String = buildString {
+ append("Imported $importedCount")
+ if (duplicateCount > 0) append(" • $duplicateCount already added")
+ if (rejectedCount == 1) {
+ val rejection = outcomes.filterIsInstance().single()
+ append(" • rejected: ${rejection.reason}")
+ } else if (rejectedCount > 1) {
+ append(" • $rejectedCount rejected")
+ }
+ if (outcomes.isEmpty()) append(" • no audio received")
+ }
+}
+
+interface AudioImportStorage {
+ fun metadata(sourceId: String): AudioImportMetadata
+
+ fun destination(receiptKey: String, displayName: String): AudioImportDestination
+
+ fun copy(sourceId: String, destination: AudioImportDestination): Long
+
+ fun delete(destination: AudioImportDestination)
+}
+
+interface AudioImportCatalog {
+ fun contains(documentId: String): Boolean
+
+ fun insertPending(
+ documentId: String,
+ displayName: String,
+ mimeType: String?,
+ sizeBytes: Long,
+ importedAtMillis: Long,
+ )
+}
+
+object AudioImportRules {
+ private val supportedExtensions = setOf(
+ "wav",
+ "m4a",
+ "mp3",
+ "aac",
+ "flac",
+ "ogg",
+ "opus",
+ "3gp",
+ )
+
+ fun rejectionReason(metadata: AudioImportMetadata): String? {
+ if (metadata.sizeBytes == 0L) return "Audio file is empty"
+ val mime = metadata.mimeType?.lowercase()?.substringBefore(';')?.trim()
+ val extension = metadata.displayName
+ ?.substringAfterLast('.', missingDelimiterValue = "")
+ ?.lowercase()
+ val supportedMime = mime?.startsWith("audio/") == true || mime == "application/ogg"
+ if (!supportedMime && extension !in supportedExtensions) {
+ return "Unsupported audio format"
+ }
+ return null
+ }
+
+ fun displayName(metadata: AudioImportMetadata): String =
+ metadata.displayName?.trim()?.takeIf(String::isNotEmpty) ?: "shared-audio"
+
+ fun hasStableReceipt(metadata: AudioImportMetadata): Boolean =
+ metadata.sizeBytes != null || metadata.modifiedMillis != null
+
+ fun receiptKey(metadata: AudioImportMetadata, unstableToken: String): String {
+ val identity = buildString {
+ append(metadata.sourceId)
+ append('\u0000')
+ append(metadata.displayName.orEmpty())
+ append('\u0000')
+ append(metadata.mimeType.orEmpty())
+ append('\u0000')
+ append(metadata.sizeBytes?.toString().orEmpty())
+ append('\u0000')
+ append(metadata.modifiedMillis?.toString().orEmpty())
+ if (!hasStableReceipt(metadata)) {
+ append('\u0000')
+ append(unstableToken)
+ }
+ }
+ return MessageDigest.getInstance("SHA-256")
+ .digest(identity.toByteArray(Charsets.UTF_8))
+ .joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
+ }
+
+ fun safeFilename(displayName: String): String {
+ val cleaned = displayName
+ .replace(Regex("[^A-Za-z0-9._-]+"), "-")
+ .trim('.', '-', '_')
+ .take(96)
+ return cleaned.ifBlank { "shared-audio" }
+ }
+}
+
+class AudioImportUseCase(
+ private val storage: AudioImportStorage,
+ private val catalog: AudioImportCatalog,
+ private val clockMillis: () -> Long = System::currentTimeMillis,
+ private val unstableToken: () -> String = { UUID.randomUUID().toString() },
+) {
+ fun ingest(sourceIds: List): AudioImportSummary {
+ val outcomes = sourceIds.distinct().map { sourceId -> ingestOne(sourceId) }
+ return AudioImportSummary(outcomes)
+ }
+
+ private fun ingestOne(sourceId: String): AudioImportItemOutcome {
+ var destination: AudioImportDestination? = null
+ var createdCopy = false
+ var displayName = "shared-audio"
+ return try {
+ val metadata = storage.metadata(sourceId)
+ displayName = AudioImportRules.displayName(metadata)
+ AudioImportRules.rejectionReason(metadata)?.let { reason ->
+ return AudioImportItemOutcome.Rejected(displayName, reason)
+ }
+ val receiptKey = AudioImportRules.receiptKey(metadata, unstableToken())
+ destination = storage.destination(receiptKey, displayName)
+ if (destination.existed && catalog.contains(destination.documentId)) {
+ return AudioImportItemOutcome.Duplicate(displayName)
+ }
+ if (destination.existed) storage.delete(destination)
+ val copiedBytes = storage.copy(sourceId, destination)
+ createdCopy = true
+ if (copiedBytes <= 0L) {
+ storage.delete(destination)
+ createdCopy = false
+ return AudioImportItemOutcome.Rejected(displayName, "Audio file is empty")
+ }
+ catalog.insertPending(
+ documentId = destination.documentId,
+ displayName = displayName,
+ mimeType = metadata.mimeType,
+ sizeBytes = copiedBytes,
+ importedAtMillis = clockMillis(),
+ )
+ AudioImportItemOutcome.Imported(displayName)
+ } catch (error: Throwable) {
+ if (createdCopy) destination?.let(storage::delete)
+ AudioImportItemOutcome.Rejected(
+ displayName = displayName,
+ reason = error.message?.takeIf(String::isNotBlank) ?: "Audio import failed",
+ )
+ }
+ }
+}
+
+class AndroidAudioImportStorage(
+ private val context: Context,
+) : AudioImportStorage {
+ private val resolver: ContentResolver = context.contentResolver
+ private val directory = File(context.filesDir, AndroidAudioImportConstants.DIRECTORY_NAME)
+
+ override fun metadata(sourceId: String): AudioImportMetadata {
+ val uri = Uri.parse(sourceId)
+ var displayName: String? = null
+ var sizeBytes: Long? = null
+ resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)
+ ?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ displayName = cursor.stringOrNull(OpenableColumns.DISPLAY_NAME)
+ sizeBytes = cursor.longOrNull(OpenableColumns.SIZE)
+ }
+ }
+ val modifiedMillis = runCatching {
+ resolver.query(
+ uri,
+ arrayOf(DocumentsContract.Document.COLUMN_LAST_MODIFIED),
+ null,
+ null,
+ null,
+ )?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ cursor.longOrNull(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
+ } else {
+ null
+ }
+ }
+ }.getOrNull()
+ return AudioImportMetadata(
+ sourceId = sourceId,
+ displayName = displayName,
+ mimeType = resolver.getType(uri),
+ sizeBytes = sizeBytes,
+ modifiedMillis = modifiedMillis,
+ )
+ }
+
+ override fun destination(receiptKey: String, displayName: String): AudioImportDestination {
+ check(directory.exists() || directory.mkdirs()) { "Unable to create imported-audio storage" }
+ val file = File(directory, "${receiptKey.take(24)}-${AudioImportRules.safeFilename(displayName)}")
+ return AudioImportDestination(
+ documentId = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.files",
+ file,
+ ).toString(),
+ existed = file.exists(),
+ )
+ }
+
+ override fun copy(sourceId: String, destination: AudioImportDestination): Long {
+ check(directory.exists() || directory.mkdirs()) { "Unable to create imported-audio storage" }
+ val finalFile = fileFor(destination)
+ val temporary = File(directory, "${finalFile.name}.${UUID.randomUUID()}.part")
+ try {
+ val bytes = resolver.openInputStream(Uri.parse(sourceId))?.use { input ->
+ FileOutputStream(temporary).use { output ->
+ val copied = input.copyTo(output)
+ output.fd.sync()
+ copied
+ }
+ } ?: error("Shared audio is unreadable")
+ if (bytes <= 0L) {
+ temporary.delete()
+ return bytes
+ }
+ check(temporary.renameTo(finalFile)) { "Unable to finalize imported audio" }
+ return bytes
+ } catch (error: Throwable) {
+ temporary.delete()
+ throw error
+ }
+ }
+
+ override fun delete(destination: AudioImportDestination) {
+ fileFor(destination).delete()
+ }
+
+ fun cleanupOrphans(catalog: SqlDelightAudioCatalogRepository) {
+ if (!directory.exists()) return
+ directory.listFiles()?.filter { it.name.endsWith(".part") }?.forEach(File::delete)
+ val catalogUris = catalog.importedFiles(AndroidAudioImportConstants.SOURCE_ID)
+ .mapTo(mutableSetOf()) { it.documentUri }
+ directory.listFiles()
+ ?.filter(File::isFile)
+ ?.forEach { file ->
+ val uri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.files",
+ file,
+ ).toString()
+ if (uri !in catalogUris) file.delete()
+ }
+ }
+
+ private fun fileFor(destination: AudioImportDestination): File {
+ val uri = Uri.parse(destination.documentId)
+ val filename = uri.lastPathSegment?.substringAfterLast('/')
+ ?: error("Invalid imported-audio destination")
+ return File(directory, filename)
+ }
+}
+
+class SqlDelightAudioImportCatalog(
+ private val catalog: SqlDelightAudioCatalogRepository,
+) : AudioImportCatalog {
+ override fun contains(documentId: String): Boolean =
+ catalog.catalogFile(AndroidAudioImportConstants.SOURCE_ID, documentId) != null
+
+ override fun insertPending(
+ documentId: String,
+ displayName: String,
+ mimeType: String?,
+ sizeBytes: Long,
+ importedAtMillis: Long,
+ ) {
+ catalog.upsertImportedFile(
+ folderUri = AndroidAudioImportConstants.SOURCE_ID,
+ documentUri = documentId,
+ displayName = displayName,
+ mimeType = mimeType,
+ sizeBytes = sizeBytes,
+ importedAtMillis = importedAtMillis,
+ state = AudioFileState.PENDING,
+ lastError = null,
+ processedAtMillis = null,
+ transcriptText = null,
+ durationUs = null,
+ )
+ }
+}
+
+private fun android.database.Cursor.stringOrNull(columnName: String): String? {
+ val index = getColumnIndex(columnName)
+ return if (index >= 0 && !isNull(index)) getString(index) else null
+}
+
+private fun android.database.Cursor.longOrNull(columnName: String): Long? {
+ val index = getColumnIndex(columnName)
+ return if (index >= 0 && !isNull(index)) getLong(index) else null
+}
+
+object AudioShareIntentParser {
+ fun isShareIntent(intent: Intent): Boolean =
+ intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE
+
+ @Suppress("DEPRECATION")
+ fun streamUris(intent: Intent): List {
+ if (!isShareIntent(intent)) return emptyList()
+ val uris = mutableListOf()
+ intent.clipData?.let { clip ->
+ for (index in 0 until clip.itemCount) {
+ clip.getItemAt(index).uri?.let(uris::add)
+ }
+ }
+ if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
+ intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.let(uris::addAll)
+ } else {
+ intent.getParcelableExtra(Intent.EXTRA_STREAM)?.let(uris::add)
+ }
+ return uris.distinctBy(Uri::toString)
+ }
+}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/CatalogRefreshPolicy.kt b/app/src/main/java/me/maxistar/voiceinbox/CatalogRefreshPolicy.kt
new file mode 100644
index 0000000..8ad91e0
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/CatalogRefreshPolicy.kt
@@ -0,0 +1,31 @@
+package me.maxistar.voiceinbox
+
+import me.maxistar.voiceinbox.core.AudioCatalogSourceScope
+
+data class CatalogRefreshToken(
+ val generation: Long,
+ val sourceScope: AudioCatalogSourceScope,
+)
+
+data class CatalogWorkRefreshKey(
+ val workId: String,
+ val workState: String,
+ val filename: String?,
+ val completedFiles: Int,
+ val failedFiles: Int,
+)
+
+object CatalogRefreshPolicy {
+ fun isCurrent(
+ request: CatalogRefreshToken,
+ currentGeneration: Long,
+ currentSourceScope: AudioCatalogSourceScope,
+ ): Boolean =
+ request.generation == currentGeneration &&
+ request.sourceScope == currentSourceScope
+
+ fun shouldRefreshForWork(
+ previous: CatalogWorkRefreshKey?,
+ current: CatalogWorkRefreshKey,
+ ): Boolean = previous != current
+}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
index c78beab..ea1bd12 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
@@ -3,73 +3,86 @@ package me.maxistar.voiceinbox
import me.maxistar.voiceinbox.core.*
import android.content.Intent
-import android.graphics.Typeface
import android.media.MediaPlayer
import android.net.Uri
import android.os.Bundle
-import android.view.Gravity
+import android.os.Handler
+import android.os.Looper
import android.view.Menu
import android.view.MenuItem
import android.view.View
-import android.widget.Button
-import android.widget.LinearLayout
-import android.widget.PopupMenu
-import android.widget.ProgressBar
-import android.widget.TextView
+import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.work.WorkInfo
import androidx.work.WorkManager
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.button.MaterialButtonToggleGroup
+import com.google.android.material.floatingactionbutton.FloatingActionButton
+import kotlinx.coroutines.launch
import java.util.concurrent.Executors
import java.util.UUID
-class MainActivity : AppCompatActivity() {
- private lateinit var statusTitle: TextView
- private lateinit var statusDetail: TextView
- private lateinit var statusProgress: ProgressBar
- private lateinit var statusMeta: TextView
- private lateinit var downloadModel: Button
- private lateinit var transcribeAll: Button
- private lateinit var selectOutput: Button
- private lateinit var selectFolder: Button
- private lateinit var outputName: TextView
- private lateinit var folderName: TextView
- private lateinit var newTab: Button
- private lateinit var processedTab: Button
- private lateinit var fileList: LinearLayout
- private lateinit var emptyList: TextView
+class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listener {
+ private lateinit var importAudio: FloatingActionButton
+ private lateinit var newTab: MaterialButton
+ private lateinit var processedTab: MaterialButton
+ private lateinit var allTab: MaterialButton
+ private lateinit var taskFilters: MaterialButtonToggleGroup
+ private lateinit var taskList: RecyclerView
+ private lateinit var taskAdapter: TaskListAdapter
+ private lateinit var taskActionRouter: AndroidTaskActionRouter
+ private val taskStateHost: AndroidMainScreenStateHost by viewModels()
private lateinit var selectionStore: DocumentSelectionStore
+ private lateinit var selectionAccess: PersistedSelectionAccess
private lateinit var documentAccess: DocumentAccess
private lateinit var folderScanner: AudioFolderScanner
private lateinit var catalog: SqlDelightAudioCatalogRepository
private lateinit var modelReadiness: SpeechModelReadinessManager
+ private lateinit var startupPolicyStore: StartupProcessingPolicyStore
+ private lateinit var startupCoordinator: StartupProcessingCoordinator
private val folderExecutor = Executors.newSingleThreadExecutor()
+ private val importExecutor = Executors.newSingleThreadExecutor()
private var modelReady = false
+ private var modelSetupState = ModelSetupSnapshotState.REQUIRED
private var outputUri: Uri? = null
private var folderUri: Uri? = null
+ private var outputAccessReady = false
+ private var folderAccessReady = false
+ private var outputDisplayName: String? = null
+ private var folderDisplayName: String? = null
+ private var outputAccessError: String? = null
+ private var folderAccessError: String? = null
private var transcriptionState = TranscriptionObservationState.UNKNOWN
private var folderChecking = false
private var folderScanQueued = false
private var scanning = false
private var pendingCount = 0
- private var selectedTab = MainScreenCatalogTab.NEW
- private var lastCatalogWorkState: String? = null
+ private var lastCatalogWorkState: CatalogWorkRefreshKey? = null
private var currentSessionTranscriptionWorkId: UUID? = null
private var currentSessionObservedActiveTranscription = false
private var modelMessage = "Checking speech model"
- private var modelLoading = true
private var modelDownloadAvailable = false
private var modelDownloadProgress: Int? = null
+ private var modelInstallCanCancel = false
private var scanMessage: String? = null
private var transcriptionFinished = false
private var transcriptionPhase: String? = null
private var transcriptionFilename: String? = null
+ private var transcriptionEntryId: Long? = null
private var transcriptionIndeterminate = true
private var transcriptionProgressValue = 0
private var processedUs = -1L
@@ -77,11 +90,33 @@ class MainActivity : AppCompatActivity() {
private var completedFiles = 0
private var totalFiles = 0
private var failedFiles = 0
- private var statusError: String? = null
private var previewPlayer: MediaPlayer? = null
private var previewEntryId: Long? = null
private var previewState = PreviewPlaybackState.IDLE
private var activityDestroyed = false
+ private var scanGeneration = 0L
+ private var outputValidationGeneration = 0L
+ private var folderValidationGeneration = 0L
+ private var catalogRefreshGeneration = 0L
+ private var pendingStartupCatalogGeneration: Long? = null
+ private var pendingFolderScanOrigin: FolderScanOrigin? = null
+ private var pendingFolderSyncGeneration: Long? = null
+ private var hasStarted = false
+ private var ingestionActive = false
+ private var currentEntries: List = emptyList()
+ private var renderingFilter = false
+ private var modelPresentationKnown = false
+ private var outputPresentationKnown = false
+ private var folderPresentationKnown = false
+ private var catalogPresentationKnown = false
+ private var refreshActionView: View? = null
+ private val refreshIndicatorHandler = Handler(Looper.getMainLooper())
+ private var refreshIndicatorActive = false
+ private var refreshIndicatorStartedAt = 0L
+ private var refreshIndicatorAnimator: android.animation.ObjectAnimator? = null
+ private val startRefreshIndicator = Runnable(::startRefreshIndicatorAnimation)
+ private val stopRefreshIndicator = Runnable(::stopRefreshIndicatorAnimation)
+ private val queuedImportUris = mutableListOf()
private val outputPicker = registerForActivityResult(
ActivityResultContracts.OpenDocument(),
@@ -95,6 +130,18 @@ class MainActivity : AppCompatActivity() {
if (uri != null) acceptFolder(uri)
}
+ private val importPicker = registerForActivityResult(
+ ActivityResultContracts.OpenMultipleDocuments(),
+ ) { uris ->
+ if (uris.isNotEmpty()) ingestAudioUris(uris)
+ }
+
+ private val modelFolderPicker = registerForActivityResult(
+ ActivityResultContracts.OpenDocumentTree(),
+ ) { uri ->
+ if (uri != null) acceptModelFolder(uri)
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -110,10 +157,23 @@ class MainActivity : AppCompatActivity() {
selectionStore = DocumentSelectionStore(
getSharedPreferences(DocumentSelectionStore.PREFERENCES_NAME, MODE_PRIVATE),
)
+ selectionAccess = PersistedSelectionAccess(contentResolver)
documentAccess = DocumentAccess(contentResolver)
folderScanner = AudioFolderScanner(contentResolver)
catalog = AndroidSqlDelightAudioCatalogFactory(this).create()
modelReadiness = getSharedModelReadiness(SpeechModelRepository(noBackupFilesDir.resolve("models")))
+ startupPolicyStore = StartupProcessingPolicyStore(
+ getSharedPreferences(StartupProcessingPolicyStore.PREFERENCES_NAME, MODE_PRIVATE),
+ )
+ startupCoordinator = StartupProcessingCoordinator.restore(
+ savedInstanceState?.getString(STATE_STARTUP_PROCESSING_STAGE),
+ )
+ restoreRetainedPresentation()
+ taskActionRouter = AndroidTaskActionRouter(
+ currentState = { taskStateHost.state.value },
+ gateway = AndroidTaskActionGateway(::performTaskAction),
+ )
+ bootstrapSelectionIdentities()
ScheduledTranscriptionScheduler.sync(
this,
ScheduledTranscriptionSettingsStore(
@@ -121,27 +181,62 @@ class MainActivity : AppCompatActivity() {
).load(),
)
- downloadModel.setOnClickListener { SpeechModelDownloadWorker.enqueue(this) }
- transcribeAll.setOnClickListener { startBatchTranscription() }
- selectOutput.setOnClickListener { launchOutputPickerIfEnabled() }
- selectFolder.setOnClickListener { launchFolderPickerIfEnabled() }
- newTab.setOnClickListener {
- selectedTab = MainScreenCatalogTab.NEW
- refreshCatalog()
+ importAudio.setOnClickListener {
+ if (!ingestionActive) importPicker.launch(arrayOf("audio/*", "application/ogg"))
}
- processedTab.setOnClickListener {
- selectedTab = MainScreenCatalogTab.PROCESSED
- refreshCatalog()
+ taskFilters.addOnButtonCheckedListener { _, checkedId, isChecked ->
+ if (!isChecked || renderingFilter) return@addOnButtonCheckedListener
+ val filter = when (checkedId) {
+ R.id.processedTab -> TaskListFilter.PROCESSED
+ R.id.allTab -> TaskListFilter.ALL
+ else -> TaskListFilter.NEW
+ }
+ taskStateHost.selectFilter(filter)
+ }
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ taskStateHost.state.collect(::renderTaskState)
+ }
}
observeModelInstallation()
observeTranscription()
- restoreSelections()
+ restoreSelections(scanFolderOnSuccess = true)
+ refreshCatalog()
+ cleanupImportedAudio()
+ handleShareIntent(intent)
refreshModel()
}
+ override fun onStart() {
+ super.onStart()
+ if (hasStarted) {
+ restoreSelections(scanFolderOnSuccess = false)
+ refreshCatalog()
+ } else {
+ hasStarted = true
+ }
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ handleShareIntent(intent)
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ outState.putString(STATE_STARTUP_PROCESSING_STAGE, startupCoordinator.savedStage())
+ super.onSaveInstanceState(outState)
+ }
+
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.main_options, menu)
+ refreshIndicatorHandler.removeCallbacks(startRefreshIndicator)
+ refreshIndicatorHandler.removeCallbacks(stopRefreshIndicator)
+ refreshIndicatorAnimator?.cancel()
+ refreshIndicatorAnimator = null
+ refreshIndicatorActive = false
+ refreshActionView = menu.findItem(R.id.menuRefreshFolder)?.actionView
+ refreshActionView?.setOnClickListener { dispatchManualFolderRefresh() }
updateMenu(menu)
return true
}
@@ -154,9 +249,7 @@ class MainActivity : AppCompatActivity() {
override fun onOptionsItemSelected(item: MenuItem): Boolean =
when (item.itemId) {
R.id.menuRefreshFolder -> {
- if (currentControls().refreshEnabled) {
- scanFolder()
- }
+ dispatchManualFolderRefresh()
true
}
R.id.menuSettings -> {
@@ -168,80 +261,242 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() {
activityDestroyed = true
+ refreshIndicatorHandler.removeCallbacksAndMessages(null)
+ refreshIndicatorAnimator?.cancel()
+ refreshIndicatorAnimator = null
stopPreviewPlayback(render = false)
folderExecutor.shutdown()
+ importExecutor.shutdown()
super.onDestroy()
}
private fun bindViews() {
- statusTitle = findViewById(R.id.statusTitle)
- statusDetail = findViewById(R.id.statusDetail)
- statusProgress = findViewById(R.id.statusProgress)
- statusMeta = findViewById(R.id.statusMeta)
- downloadModel = findViewById(R.id.downloadModel)
- transcribeAll = findViewById(R.id.transcribeAll)
- selectOutput = findViewById(R.id.selectOutput)
- selectFolder = findViewById(R.id.selectFolder)
- outputName = findViewById(R.id.outputName)
- folderName = findViewById(R.id.folderName)
+ importAudio = findViewById(R.id.importAudio)
newTab = findViewById(R.id.newTab)
processedTab = findViewById(R.id.processedTab)
- fileList = findViewById(R.id.fileList)
- emptyList = findViewById(R.id.emptyList)
+ allTab = findViewById(R.id.allTab)
+ taskFilters = findViewById(R.id.taskFilters)
+ taskList = findViewById(R.id.taskList)
+ taskAdapter = TaskListAdapter(::handleTaskAction)
+ taskList.layoutManager = LinearLayoutManager(this)
+ taskList.adapter = taskAdapter
+ (taskList.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false
}
- private fun restoreSelections() {
- selectionStore.loadOutputUri()?.let(Uri::parse)?.let { stored ->
+ private fun restoreRetainedPresentation() {
+ val retained = taskStateHost.currentInput
+ modelSetupState = retained.model.state
+ modelReady = retained.model.state == ModelSetupSnapshotState.READY
+ modelMessage = retained.model.detail ?: modelMessage
+ modelDownloadAvailable = retained.model.downloadAvailable
+ modelDownloadProgress = retained.model.progressPercent
+ modelInstallCanCancel = retained.model.canCancel
+ currentEntries = retained.entries
+ modelPresentationKnown = retained.hydration.modelKnown
+ outputPresentationKnown = retained.hydration.outputKnown
+ folderPresentationKnown = retained.hydration.folderKnown
+ catalogPresentationKnown = retained.hydration.catalogKnown
+ }
+
+ private fun bootstrapSelectionIdentities() {
+ setStoredOutputIdentity(selectionStore.loadOutputUri()?.let(Uri::parse))
+ setStoredFolderIdentity(selectionStore.loadFolderUri()?.let(Uri::parse))
+ }
+
+ private fun setStoredOutputIdentity(stored: Uri?) {
+ outputUri = stored
+ outputAccessReady = false
+ outputDisplayName = null
+ outputPresentationKnown = stored == null
+ startupCoordinator.setOutputReady(false)
+ publishTaskState()
+ }
+
+ private fun setStoredFolderIdentity(stored: Uri?) {
+ folderUri = stored
+ folderAccessReady = false
+ folderDisplayName = null
+ folderPresentationKnown = stored == null
+ if (stored == null) {
+ pendingFolderSyncGeneration = null
+ taskStateHost.folderSyncCoordinator.reset()
+ }
+ startupCoordinator.setFolderReady(false)
+ publishTaskState()
+ }
+
+ private fun restoreSelections(scanFolderOnSuccess: Boolean) {
+ val storedOutput = selectionStore.loadOutputUri()?.let(Uri::parse)
+ if (storedOutput != outputUri) setStoredOutputIdentity(storedOutput)
+ val outputGeneration = ++outputValidationGeneration
+ if (storedOutput == null) {
+ outputAccessError = null
+ outputAccessReady = false
+ outputPresentationKnown = true
+ startupCoordinator.setOutputReady(false)
+ publishTaskState()
+ } else {
+ outputAccessReady = false
+ outputPresentationKnown = false
+ startupCoordinator.setOutputReady(false)
+ publishTaskState()
folderExecutor.execute {
- runCatching {
- documentAccess.requireAppendable(stored)
- documentAccess.metadata(stored)
- }.onSuccess { metadata ->
- runOnUiThread {
- if (activityDestroyed) return@runOnUiThread
- outputUri = stored
- updateOutputSummary(metadata.displayName)
- updateControls()
+ val result = selectionAccess.validate(
+ uri = storedOutput,
+ requiredAccess = RequiredDocumentAccess.WRITE,
+ ) {
+ documentAccess.requireAppendable(storedOutput)
+ documentAccess.metadata(storedOutput)
+ }
+ runOnUiThread {
+ if (
+ activityDestroyed ||
+ outputGeneration != outputValidationGeneration ||
+ outputUri != storedOutput
+ ) return@runOnUiThread
+ outputPresentationKnown = true
+ when (result.state) {
+ PersistedSelectionAccessState.VALID -> {
+ outputAccessReady = true
+ outputAccessError = null
+ startupCoordinator.setOutputReady(true)
+ updateOutputSummary(result.value?.displayName)
+ }
+ PersistedSelectionAccessState.TEMPORARILY_UNAVAILABLE -> {
+ outputAccessReady = false
+ outputAccessError = result.error?.message
+ ?: "Output file is temporarily unavailable"
+ startupCoordinator.setOutputReady(false)
+ outputDisplayName = null
+ }
+ PersistedSelectionAccessState.PERMISSION_REVOKED -> {
+ selectionStore.clearOutputUri()
+ outputUri = null
+ outputAccessReady = false
+ outputDisplayName = null
+ outputAccessError = result.error?.message
+ ?: "Output file access was revoked; select it again"
+ startupCoordinator.setOutputReady(false)
+ }
}
- }.onFailure {
- selectionStore.clearOutputUri()
+ publishTaskState()
+ updateControls()
+ evaluateStartupProcessing()
}
}
}
- selectionStore.loadFolderUri()?.let(Uri::parse)?.let { stored ->
- folderExecutor.execute {
- runOnUiThread {
- if (activityDestroyed) return@runOnUiThread
- folderChecking = true
- renderStatusBlock()
- updateControls()
+
+ val storedFolder = selectionStore.loadFolderUri()?.let(Uri::parse)
+ if (storedFolder != folderUri) setStoredFolderIdentity(storedFolder)
+ val folderGeneration = ++folderValidationGeneration
+ if (storedFolder == null) {
+ folderAccessError = null
+ folderAccessReady = false
+ folderChecking = false
+ folderPresentationKnown = true
+ pendingFolderSyncGeneration = null
+ taskStateHost.folderSyncCoordinator.reset()
+ startupCoordinator.setFolderReady(false)
+ publishTaskState()
+ refreshCatalog()
+ return
+ }
+ val folderSyncGeneration = taskStateHost.folderSyncCoordinator.begin()
+ pendingFolderSyncGeneration = null
+ folderAccessReady = false
+ folderChecking = true
+ folderPresentationKnown = false
+ startupCoordinator.setFolderReady(false)
+ folderDisplayName = null
+ publishTaskState()
+ updateControls()
+ folderExecutor.execute {
+ val result = selectionAccess.validate(
+ uri = storedFolder,
+ requiredAccess = RequiredDocumentAccess.READ,
+ ) {
+ folderScanner.requireReadable(storedFolder)
+ folderScanner.folderName(storedFolder)
+ }
+ runOnUiThread {
+ if (
+ activityDestroyed ||
+ folderGeneration != folderValidationGeneration ||
+ folderUri != storedFolder
+ ) return@runOnUiThread
+ if (!taskStateHost.folderSyncCoordinator.isCurrent(folderSyncGeneration)) {
+ return@runOnUiThread
}
- runCatching {
- folderScanner.requireReadable(stored)
- folderScanner.folderName(stored)
- }.onSuccess { name ->
- runOnUiThread {
- if (activityDestroyed) return@runOnUiThread
- folderChecking = false
- folderUri = stored
- updateFolderSummary(name)
- updateControls()
- refreshCatalog()
- scanFolder()
+ folderChecking = false
+ folderPresentationKnown = true
+ when (result.state) {
+ PersistedSelectionAccessState.VALID -> {
+ folderAccessReady = true
+ folderAccessError = null
+ startupCoordinator.setFolderReady(true)
+ updateFolderSummary(result.value)
+ if (scanFolderOnSuccess) {
+ pendingFolderScanOrigin = FolderScanOrigin.STARTUP
+ pendingFolderSyncGeneration = folderSyncGeneration
+ taskStateHost.folderSyncCoordinator.transition(
+ folderSyncGeneration,
+ AndroidFolderSyncPhase.QUEUED,
+ )
+ } else {
+ taskStateHost.folderSyncCoordinator.complete(folderSyncGeneration)
+ }
}
- }.onFailure {
- selectionStore.clearFolderUri()
- runOnUiThread {
- if (activityDestroyed) return@runOnUiThread
- folderChecking = false
+ PersistedSelectionAccessState.TEMPORARILY_UNAVAILABLE -> {
+ folderAccessReady = false
+ folderAccessError = result.error?.message
+ ?: "Audio folder is temporarily unavailable"
+ startupCoordinator.setFolderReady(false)
+ folderDisplayName = null
+ taskStateHost.folderSyncCoordinator.fail(folderSyncGeneration)
}
- showError(it.message ?: "Audio folder is not readable")
+ PersistedSelectionAccessState.PERMISSION_REVOKED -> {
+ selectionStore.clearFolderUri()
+ folderUri = null
+ folderAccessReady = false
+ folderDisplayName = null
+ folderAccessError = result.error?.message
+ ?: "Audio folder access was revoked; select it again"
+ pendingFolderSyncGeneration = null
+ startupCoordinator.setFolderReady(false)
+ taskStateHost.folderSyncCoordinator.fail(folderSyncGeneration)
+ }
+ }
+ publishTaskState()
+ updateControls()
+ if (result.state != PersistedSelectionAccessState.VALID || !scanFolderOnSuccess) {
+ refreshCatalog()
}
+ maybeStartPendingFolderScan()
+ evaluateStartupProcessing()
}
}
}
+ private fun maybeStartPendingFolderScan() {
+ val origin = pendingFolderScanOrigin ?: return
+ if (
+ !folderAccessReady ||
+ transcriptionState == TranscriptionObservationState.UNKNOWN
+ ) return
+ if (transcriptionActive()) {
+ pendingFolderSyncGeneration?.let(taskStateHost.folderSyncCoordinator::complete)
+ pendingFolderSyncGeneration = null
+ publishTaskState()
+ return
+ }
+ val folderSyncGeneration = pendingFolderSyncGeneration
+ pendingFolderScanOrigin = null
+ pendingFolderSyncGeneration = null
+ scanFolder(origin, folderSyncGeneration)
+ }
+
private fun acceptOutput(uri: Uri) {
+ val validationGeneration = ++outputValidationGeneration
runCatching {
contentResolver.takePersistableUriPermission(
uri,
@@ -253,59 +508,178 @@ class MainActivity : AppCompatActivity() {
documentAccess.requireAppendable(uri)
documentAccess.metadata(uri)
}.onSuccess { metadata ->
- selectionStore.saveOutputUri(uri.toString())
runOnUiThread {
- if (activityDestroyed) return@runOnUiThread
+ if (
+ activityDestroyed ||
+ validationGeneration != outputValidationGeneration
+ ) return@runOnUiThread
+ selectionStore.saveOutputUri(uri.toString())
outputUri = uri
+ outputAccessReady = true
+ outputAccessError = null
+ outputPresentationKnown = true
+ startupCoordinator.setOutputReady(true)
updateOutputSummary(metadata.displayName)
- statusError = null
- renderStatusBlock()
+ outputAccessError = null
+ publishTaskState()
updateControls()
+ evaluateStartupProcessing()
}
- }.onFailure { showError(it.message ?: "Output file is not writable") }
+ }.onFailure {
+ runOnUiThread {
+ if (
+ activityDestroyed ||
+ validationGeneration != outputValidationGeneration
+ ) return@runOnUiThread
+ outputAccessError = it.message ?: "Output file is not writable"
+ outputPresentationKnown = true
+ publishTaskState()
+ updateControls()
+ evaluateStartupProcessing()
+ }
+ }
}
}
private fun acceptFolder(uri: Uri) {
+ val validationGeneration = ++folderValidationGeneration
+ val folderSyncGeneration = taskStateHost.folderSyncCoordinator.begin()
runCatching {
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
folderExecutor.execute {
runOnUiThread {
- if (activityDestroyed) return@runOnUiThread
+ if (
+ activityDestroyed ||
+ validationGeneration != folderValidationGeneration
+ ) return@runOnUiThread
folderChecking = true
- renderStatusBlock()
+ folderPresentationKnown = false
+ publishTaskState()
updateControls()
}
runCatching {
folderScanner.requireReadable(uri)
folderScanner.folderName(uri)
}.onSuccess { name ->
- selectionStore.saveFolderUri(uri.toString())
runOnUiThread {
- if (activityDestroyed) return@runOnUiThread
+ if (
+ activityDestroyed ||
+ validationGeneration != folderValidationGeneration
+ ) return@runOnUiThread
+ selectionStore.saveFolderUri(uri.toString())
folderChecking = false
folderUri = uri
+ folderAccessReady = true
+ folderPresentationKnown = true
+ folderAccessError = null
+ startupCoordinator.setFolderReady(true)
updateFolderSummary(name)
- statusError = null
- renderStatusBlock()
+ folderAccessError = null
+ publishTaskState()
updateControls()
- refreshCatalog()
- scanFolder()
+ pendingFolderScanOrigin = FolderScanOrigin.USER
+ pendingFolderSyncGeneration = folderSyncGeneration
+ taskStateHost.folderSyncCoordinator.transition(
+ folderSyncGeneration,
+ AndroidFolderSyncPhase.QUEUED,
+ )
+ maybeStartPendingFolderScan()
}
}.onFailure {
runOnUiThread {
- if (activityDestroyed) return@runOnUiThread
+ if (
+ activityDestroyed ||
+ validationGeneration != folderValidationGeneration
+ ) return@runOnUiThread
folderChecking = false
+ folderPresentationKnown = true
+ folderAccessError = it.message ?: "Audio folder is not readable"
+ taskStateHost.folderSyncCoordinator.fail(folderSyncGeneration)
+ publishTaskState()
+ updateControls()
+ evaluateStartupProcessing()
}
- showError(it.message ?: "Audio folder is not readable")
}
}
}
- private fun scanFolder() {
+ private fun acceptModelFolder(uri: Uri) {
+ val alreadyPersisted = contentResolver.persistedUriPermissions.any {
+ it.uri == uri && it.isReadPermission
+ }
+ val permission = runCatching {
+ contentResolver.takePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION,
+ )
+ }
+ if (permission.isFailure) {
+ modelPresentationKnown = true
+ modelReady = false
+ modelSetupState = ModelSetupSnapshotState.INVALID
+ setModelUi(
+ "The selected model folder cannot be accessed after the picker closes",
+ canDownload = true,
+ )
+ updateControls()
+ return
+ }
+ if (!alreadyPersisted) SpeechModelImportPermission.recordOwned(this, uri)
+ modelPresentationKnown = true
+ modelReady = false
+ modelSetupState = ModelSetupSnapshotState.INSTALLING
+ setModelUi("Checking local speech model", canDownload = false)
+ updateControls()
+ importExecutor.execute {
+ runCatching {
+ SpeechModelDirectoryReader(contentResolver).requiredDocuments(
+ uri,
+ EmbeddedSpeechModel.manifest,
+ )
+ }.onSuccess {
+ runOnUiThread {
+ if (activityDestroyed) return@runOnUiThread
+ SpeechModelImportWorker.enqueue(this, uri)
+ }
+ }.onFailure { error ->
+ SpeechModelImportPermission.releaseOwnedIfUnused(this)
+ runOnUiThread {
+ if (activityDestroyed) return@runOnUiThread
+ modelPresentationKnown = true
+ modelReady = false
+ modelSetupState = ModelSetupSnapshotState.INVALID
+ setModelUi(
+ error.message ?: "The selected model folder is not readable",
+ canDownload = true,
+ )
+ updateControls()
+ }
+ }
+ }
+ }
+
+ private fun scanFolder(
+ origin: FolderScanOrigin = FolderScanOrigin.USER,
+ existingSyncGeneration: Long? = null,
+ ) {
val folder = folderUri ?: return
- if (folderChecking || folderScanQueued || scanning || transcriptionActive()) return
+ if (
+ !folderAccessReady ||
+ folderChecking ||
+ folderScanQueued ||
+ scanning ||
+ (existingSyncGeneration == null && taskStateHost.folderSyncCoordinator.state.active) ||
+ transcriptionState == TranscriptionObservationState.UNKNOWN ||
+ transcriptionActive()
+ ) return
+ val folderSyncGeneration = existingSyncGeneration
+ ?.takeIf(taskStateHost.folderSyncCoordinator::isCurrent)
+ ?: taskStateHost.folderSyncCoordinator.begin(AndroidFolderSyncPhase.QUEUED)
+ val generation = ++scanGeneration
+ val startupGeneration = generation.takeIf {
+ origin == FolderScanOrigin.STARTUP && startupCoordinator.beginStartupScan(generation)
+ }
folderScanQueued = true
transcriptionFinished = false
transcriptionPhase = null
@@ -316,8 +690,12 @@ class MainActivity : AppCompatActivity() {
completedFiles = 0
totalFiles = 0
failedFiles = 0
- statusError = null
- renderStatusBlock()
+ folderAccessError = null
+ taskStateHost.folderSyncCoordinator.transition(
+ folderSyncGeneration,
+ AndroidFolderSyncPhase.QUEUED,
+ )
+ publishTaskState()
updateControls()
folderExecutor.execute {
runOnUiThread {
@@ -325,7 +703,11 @@ class MainActivity : AppCompatActivity() {
folderScanQueued = false
scanning = true
scanMessage = "Scanning folder"
- renderStatusBlock()
+ taskStateHost.folderSyncCoordinator.transition(
+ folderSyncGeneration,
+ AndroidFolderSyncPhase.SCANNING,
+ )
+ publishTaskState()
updateControls()
}
runCatching {
@@ -337,174 +719,111 @@ class MainActivity : AppCompatActivity() {
if (activityDestroyed) return@runOnUiThread
folderScanQueued = false
scanning = false
+ folderAccessError = null
scanMessage = "Scan complete: $count audio files"
- refreshCatalog()
+ taskStateHost.folderSyncCoordinator.transition(
+ folderSyncGeneration,
+ AndroidFolderSyncPhase.REFRESHING_CATALOG,
+ )
+ refreshCatalog(startupGeneration, folderSyncGeneration)
}
}.onFailure { error ->
runOnUiThread {
if (activityDestroyed) return@runOnUiThread
folderScanQueued = false
scanning = false
- showError(error.message ?: "Folder scan failed")
+ folderAccessError = error.message ?: "Folder scan failed"
+ folderPresentationKnown = true
+ taskStateHost.folderSyncCoordinator.fail(folderSyncGeneration)
+ startupGeneration?.let(startupCoordinator::onStartupScanFailed)
+ evaluateStartupProcessing()
+ updateControls()
+ publishTaskState()
}
}
}
}
- private fun refreshCatalog() {
+ private fun refreshCatalog(
+ startupGeneration: Long? = null,
+ folderSyncGeneration: Long? = null,
+ ) {
if (activityDestroyed) return
- val folder = folderUri?.toString()
- if (folder == null) {
- pendingCount = 0
- renderEntries(emptyList())
- renderStatusBlock()
- updateControls()
- return
+ if (startupGeneration != null) {
+ pendingStartupCatalogGeneration = startupGeneration
}
+ val token = CatalogRefreshToken(
+ generation = ++catalogRefreshGeneration,
+ sourceScope = activeSourceScope(),
+ )
+ val startupGenerationForRequest = pendingStartupCatalogGeneration
folderExecutor.execute {
- val newEntries = catalog.newEntries(folder)
- val entries = if (selectedTab == MainScreenCatalogTab.NEW) {
- newEntries
- } else {
- catalog.processedEntries(folder)
- }
- runOnUiThread {
- if (activityDestroyed) return@runOnUiThread
- pendingCount = newEntries.count { it.state == AudioFileState.PENDING }
- renderEntries(entries)
- renderStatusBlock()
- updateControls()
- }
- }
- }
-
- private fun renderEntries(entries: List) {
- val state = currentMainScreenState(entries)
- fileList.removeAllViews()
- emptyList.visibility = if (state.list.emptyVisible) View.VISIBLE else View.GONE
- emptyList.text = state.list.emptyMessage
- newTab.isSelected = state.tabs.newSelected
- processedTab.isSelected = state.tabs.processedSelected
- newTab.isEnabled = state.tabs.newEnabled
- processedTab.isEnabled = state.tabs.processedEnabled
- entries.zip(state.rows).forEach { (entry, rowState) ->
- fileList.addView(createEntryView(entry, rowState))
- }
- }
-
- private fun createEntryView(entry: AudioCatalogEntry, rowState: MainScreenRowState): View =
- LinearLayout(this).apply {
- orientation = LinearLayout.HORIZONTAL
- gravity = Gravity.CENTER_VERTICAL
- setPadding(0, dp(12), 0, dp(12))
- contentDescription = entry.displayName
- isLongClickable = true
- setOnLongClickListener { anchor ->
- showEntryContextMenu(anchor, entry)
- }
-
- addView(LinearLayout(context).apply {
- orientation = LinearLayout.VERTICAL
- layoutParams = LinearLayout.LayoutParams(
- 0,
- LinearLayout.LayoutParams.WRAP_CONTENT,
- 1f,
+ runCatching {
+ val newEntries = catalog.newEntries(token.sourceScope)
+ val processedEntries = catalog.processedEntries(token.sourceScope)
+ Triple(
+ newEntries,
+ processedEntries,
+ (newEntries + processedEntries).distinctBy(AudioCatalogEntry::id),
)
- addView(TextView(context).apply {
- text = entry.displayName
- setTypeface(typeface, Typeface.BOLD)
- })
- addView(TextView(context).apply {
- text = entryDescription(entry)
- })
- })
- val actions = LinearLayout(context).apply {
- orientation = LinearLayout.VERTICAL
- gravity = Gravity.CENTER
- layoutParams = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.WRAP_CONTENT,
- LinearLayout.LayoutParams.WRAP_CONTENT,
- ).apply {
- marginStart = dp(12)
- }
- }
- actions.addView(Button(context).apply {
- tag = PreviewButtonTag(entry.id)
- text = rowState.preview.label
- isEnabled = rowState.preview.enabled
- setOnClickListener {
- if (entry.id == previewEntryId && previewState != PreviewPlaybackState.IDLE) {
- stopPreviewPlayback(render = true)
- } else {
- startPreviewPlayback(entry)
- }
- }
- })
- if (entry.state == AudioFileState.FAILED) {
- actions.addView(Button(context).apply {
- tag = RetryButtonTag(entry.id)
- text = "Retry"
- isEnabled = rowState.retryEnabled
- setOnClickListener {
- retryEntry(entry)
+ }.onSuccess { (newEntries, processedEntries, entries) ->
+ runOnUiThread {
+ if (activityDestroyed) return@runOnUiThread
+ if (
+ !CatalogRefreshPolicy.isCurrent(
+ request = token,
+ currentGeneration = catalogRefreshGeneration,
+ currentSourceScope = activeSourceScope(),
+ )
+ ) {
+ folderSyncGeneration?.let(taskStateHost.folderSyncCoordinator::complete)
+ publishTaskState()
+ return@runOnUiThread
}
- })
- }
- addView(actions)
- }
-
- private fun showEntryContextMenu(anchor: View, entry: AudioCatalogEntry): Boolean {
- val popup = PopupMenu(this, anchor)
- val row = MainScreenStateController.rowState(
- row = entry.toMainScreenRowInput(),
- activePreviewEntryId = previewEntryId,
- previewState = previewState,
- transcriptionState = transcriptionState,
- busy = folderBusy(),
- retryEnabled = currentControls().retryEnabled,
- )
- if (row.preview.enabled) {
- popup.menu.add(Menu.NONE, MENU_ENTRY_PLAY, Menu.NONE, row.preview.label)
- }
- if (row.retryVisible && row.retryEnabled) {
- popup.menu.add(Menu.NONE, MENU_ENTRY_RETRY, Menu.NONE, "Retry")
- }
- if (row.showTextVisible) {
- popup.menu.add(Menu.NONE, MENU_ENTRY_SHOW_TEXT, Menu.NONE, "Show text")
- }
- if (popup.menu.size() == 0) return false
- popup.setOnMenuItemClickListener { item ->
- when (item.itemId) {
- MENU_ENTRY_PLAY -> {
- if (entry.id == previewEntryId && previewState != PreviewPlaybackState.IDLE) {
- stopPreviewPlayback(render = true)
+ pendingCount = newEntries.count { it.state == AudioFileState.PENDING }
+ val catalogFailedCount = processedEntries.count { it.state == AudioFileState.FAILED }
+ currentEntries = entries
+ catalogPresentationKnown = true
+ folderSyncGeneration?.let(taskStateHost.folderSyncCoordinator::complete)
+ publishTaskState()
+ updateControls()
+ if (startupGenerationForRequest != null) {
+ if (pendingStartupCatalogGeneration == startupGenerationForRequest) {
+ pendingStartupCatalogGeneration = null
+ }
+ startupCoordinator.onStartupCatalogReady(
+ startupGenerationForRequest,
+ pendingCount,
+ catalogFailedCount,
+ )
} else {
- startPreviewPlayback(entry)
+ startupCoordinator.onCatalogRefreshed(pendingCount, catalogFailedCount)
}
- true
- }
- MENU_ENTRY_RETRY -> {
- retryEntry(entry)
- true
+ evaluateStartupProcessing()
}
- MENU_ENTRY_SHOW_TEXT -> {
- showTranscriptText(entry)
- true
+ }.onFailure { error ->
+ runOnUiThread {
+ if (activityDestroyed) return@runOnUiThread
+ if (folderSyncGeneration != null &&
+ taskStateHost.folderSyncCoordinator.fail(folderSyncGeneration)
+ ) {
+ folderAccessError = error.message ?: "Audio catalog refresh failed"
+ folderPresentationKnown = true
+ startupGeneration?.let(startupCoordinator::onStartupScanFailed)
+ publishTaskState()
+ updateControls()
+ }
}
- else -> false
}
}
- popup.show()
- return true
}
private fun retryEntry(entry: AudioCatalogEntry) {
- val folder = folderUri ?: return
val output = outputUri ?: return
stopPreviewPlayback(render = true)
currentSessionTranscriptionWorkId = TranscriptionWorker.enqueueRetry(
this,
- folder,
+ folderUri,
output,
entry.id,
)
@@ -519,20 +838,6 @@ class MainActivity : AppCompatActivity() {
.show()
}
- private fun entryDescription(entry: AudioCatalogEntry): String = buildString {
- append(
- when (entry.state) {
- AudioFileState.PENDING -> "New"
- AudioFileState.PROCESSING -> "Processing"
- AudioFileState.PROCESSED -> "Processed"
- AudioFileState.FAILED -> "Failed"
- AudioFileState.MISSING -> "Missing"
- },
- )
- entry.fingerprint.sizeBytes?.let { append(" • ${formatSize(it)}") }
- entry.lastError?.let { append("\n$it") }
- }
-
private fun refreshModel() {
modelReadiness.refresh { state ->
runOnUiThread {
@@ -547,77 +852,108 @@ class MainActivity : AppCompatActivity() {
when (state) {
SpeechModelReadinessState.Checking -> {
modelReady = false
- setModelUi("Checking speech model", loading = true, canDownload = false)
+ modelPresentationKnown = false
+ setModelUi("Checking speech model", canDownload = false)
}
is SpeechModelReadinessState.Ready -> {
+ modelPresentationKnown = true
modelReady = true
- setModelUi("Speech model ready", loading = false, canDownload = false)
+ modelSetupState = ModelSetupSnapshotState.READY
+ setModelUi("Speech model ready", canDownload = false)
}
SpeechModelReadinessState.Missing -> {
+ modelPresentationKnown = true
modelReady = false
- setModelUi("Speech model is not installed", loading = false, canDownload = true)
+ modelSetupState = ModelSetupSnapshotState.REQUIRED
+ setModelUi("Speech model is not installed", canDownload = true)
}
is SpeechModelReadinessState.Invalid -> {
+ modelPresentationKnown = true
modelReady = false
- setModelUi("Invalid speech model: ${state.reason}", loading = false, canDownload = true)
+ modelSetupState = ModelSetupSnapshotState.INVALID
+ setModelUi("Invalid speech model: ${state.reason}", canDownload = true)
}
is SpeechModelReadinessState.Failed -> {
+ modelPresentationKnown = true
modelReady = false
- setModelUi(state.message, loading = false, canDownload = true)
+ modelSetupState = ModelSetupSnapshotState.INVALID
+ setModelUi(state.message, canDownload = true)
}
}
+ startupCoordinator.setModelReady(modelReady)
+ evaluateStartupProcessing()
}
- private fun setModelUi(message: String, loading: Boolean, canDownload: Boolean) {
+ private fun setModelUi(message: String, canDownload: Boolean) {
modelMessage = message
- modelLoading = loading
modelDownloadAvailable = canDownload
modelDownloadProgress = null
- renderStatusBlock()
+ modelInstallCanCancel = false
+ publishTaskState()
}
private fun observeModelInstallation() {
WorkManager.getInstance(this)
- .getWorkInfosForUniqueWorkLiveData(SpeechModelDownloadWorker.UNIQUE_WORK_NAME)
+ .getWorkInfosForUniqueWorkLiveData(SpeechModelInstallationWork.UNIQUE_WORK_NAME)
.observe(this) { infos ->
- val info = infos.firstOrNull() ?: return@observe
+ val info = infos.firstOrNull { it.state in ACTIVE_WORK_STATES } ?: infos.lastOrNull()
+ if (info == null) {
+ SpeechModelImportPermission.releaseOwnedIfUnused(this)
+ return@observe
+ }
when (info.state) {
WorkInfo.State.ENQUEUED,
WorkInfo.State.BLOCKED,
WorkInfo.State.RUNNING,
-> {
+ modelPresentationKnown = true
modelReady = false
- val bytes = info.progress.getLong(SpeechModelDownloadWorker.KEY_BYTES_DOWNLOADED, 0)
+ modelSetupState = ModelSetupSnapshotState.INSTALLING
+ startupCoordinator.setModelReady(false)
+ val bytes = info.progress.getLong(SpeechModelInstallationWork.KEY_BYTES_DOWNLOADED, 0)
val total = info.progress.getLong(
- SpeechModelDownloadWorker.KEY_TOTAL_BYTES,
+ SpeechModelInstallationWork.KEY_TOTAL_BYTES,
EmbeddedSpeechModel.manifest.totalSizeBytes,
)
modelMessage =
- info.progress.getString(SpeechModelDownloadWorker.KEY_MESSAGE)
- ?: "Downloading speech model"
- modelLoading = true
+ info.progress.getString(SpeechModelInstallationWork.KEY_MESSAGE)
+ ?: "Installing speech model"
modelDownloadAvailable = false
+ modelInstallCanCancel = info.tags.any { tag ->
+ tag.endsWith(SpeechModelDownloadWorker::class.java.simpleName)
+ }
modelDownloadProgress = ((bytes * 100) / total.coerceAtLeast(1)).toInt()
- renderStatusBlock()
+ publishTaskState()
updateControls()
+ evaluateStartupProcessing()
}
WorkInfo.State.SUCCEEDED -> {
+ SpeechModelImportPermission.releaseOwnedIfUnused(this)
if (shouldHandleModelInstallSuccess(info.id.toString())) {
modelReadiness.invalidate()
+ SpeechModelPreparation.invalidate(NativeTranscriptionBridge::reset)
}
refreshModel()
}
WorkInfo.State.FAILED -> {
+ SpeechModelImportPermission.releaseOwnedIfUnused(this)
+ modelPresentationKnown = true
modelReady = false
+ modelSetupState = ModelSetupSnapshotState.INVALID
+ startupCoordinator.setModelReady(false)
setModelUi(
- info.outputData.getString(SpeechModelDownloadWorker.KEY_ERROR)
- ?: "Speech model download failed",
- false,
- true,
+ info.outputData.getString(SpeechModelInstallationWork.KEY_ERROR)
+ ?: "Speech model installation failed",
+ canDownload = true,
)
updateControls()
+ evaluateStartupProcessing()
+ }
+ WorkInfo.State.CANCELLED -> {
+ SpeechModelImportPermission.releaseOwnedIfUnused(this)
+ modelReadiness.invalidate()
+ refreshModel()
}
- else -> Unit
}
}
}
@@ -627,9 +963,15 @@ class MainActivity : AppCompatActivity() {
.getWorkInfosForUniqueWorkLiveData(TranscriptionWorker.UNIQUE_WORK_NAME)
.observe(this) { infos ->
transcriptionState = classifyTranscriptionState(infos)
+ startupCoordinator.setTranscriptionState(
+ known = true,
+ active = transcriptionActive(),
+ )
+ maybeStartPendingFolderScan()
+ evaluateStartupProcessing()
if (infos.isEmpty()) {
transcriptionFinished = false
- renderStatusBlock()
+ publishTaskState()
updateControls()
return@observe
}
@@ -641,7 +983,7 @@ class MainActivity : AppCompatActivity() {
null
} ?: run {
clearTranscriptionProgress()
- renderStatusBlock()
+ publishTaskState()
updateControls()
return@observe
}
@@ -664,6 +1006,9 @@ class MainActivity : AppCompatActivity() {
else -> info.state.name
}
transcriptionFilename = data.getString(TranscriptionWorker.KEY_FILENAME)
+ transcriptionEntryId = data
+ .getLong(TranscriptionWorker.KEY_ACTIVE_ENTRY_ID, TranscriptionWorker.NO_ENTRY_ID)
+ .takeIf { it != TranscriptionWorker.NO_ENTRY_ID }
transcriptionIndeterminate =
transcriptionActive() && data.getBoolean(TranscriptionWorker.KEY_INDETERMINATE, true)
transcriptionProgressValue = data.getInt(TranscriptionWorker.KEY_PROGRESS, 0)
@@ -672,13 +1017,22 @@ class MainActivity : AppCompatActivity() {
completedFiles = data.getInt(TranscriptionWorker.KEY_COMPLETED_FILES, 0)
totalFiles = data.getInt(TranscriptionWorker.KEY_TOTAL_FILES, 0)
failedFiles = data.getInt(TranscriptionWorker.KEY_FAILED_FILES, 0)
+ if (info.state == WorkInfo.State.FAILED && shouldRefreshModelAfterFailure(info.id.toString())) {
+ modelReadiness.invalidate()
+ refreshModel()
+ }
if (transcriptionActive() || transcriptionFinished) {
scanMessage = null
- statusError = null
}
- renderStatusBlock()
- val catalogState = "${info.id}:${info.state}:$completedFiles:$failedFiles"
- if (catalogState != lastCatalogWorkState) {
+ publishTaskState()
+ val catalogState = CatalogWorkRefreshKey(
+ workId = info.id.toString(),
+ workState = info.state.name,
+ filename = transcriptionFilename,
+ completedFiles = completedFiles,
+ failedFiles = failedFiles,
+ )
+ if (CatalogRefreshPolicy.shouldRefreshForWork(lastCatalogWorkState, catalogState)) {
lastCatalogWorkState = catalogState
refreshCatalog()
} else {
@@ -687,28 +1041,77 @@ class MainActivity : AppCompatActivity() {
}
}
- private fun currentControls(): CatalogControlState =
- currentMainScreenState().controls
+ private fun selectionControls(): CatalogControlState =
+ TranscriptionUiRules.catalogControls(
+ modelInstallationState = if (modelSetupState == ModelSetupSnapshotState.READY) {
+ SpeechModelInstallationState.INSTALLED
+ } else {
+ SpeechModelInstallationState.NOT_INSTALLED
+ },
+ outputSelected = outputAccessReady,
+ folderSelected = folderAccessReady,
+ pendingCount = pendingCount,
+ transcriptionState = transcriptionState,
+ scanning = folderBusy(),
+ audioInputAvailable = folderUri?.let { folderAccessReady } ?: currentEntries.any {
+ it.folderUri == AndroidAudioImportConstants.SOURCE_ID
+ },
+ )
- private fun folderBusy(): Boolean = folderChecking || folderScanQueued || scanning
+ private fun folderBusy(): Boolean =
+ folderChecking || folderScanQueued || scanning || taskStateHost.folderSyncCoordinator.state.active
private fun launchOutputPickerIfEnabled() {
- if (currentControls().outputEnabled) {
+ if (selectionControls().outputEnabled) {
outputPicker.launch(FileSelectionRules.outputMimeTypes)
}
}
private fun launchFolderPickerIfEnabled() {
- if (currentControls().folderEnabled) {
+ if (selectionControls().folderEnabled) {
folderPicker.launch(folderUri)
}
}
private fun startBatchTranscription() {
- val folder = folderUri ?: return
val output = outputUri ?: return
+ if (!outputAccessReady || (folderUri != null && !folderAccessReady)) return
stopPreviewPlayback(render = true)
- currentSessionTranscriptionWorkId = TranscriptionWorker.enqueueAll(this, folder, output)
+ currentSessionTranscriptionWorkId = TranscriptionWorker.enqueueAll(this, folderUri, output)
+ }
+
+ private fun evaluateStartupProcessing() {
+ when (val decision = startupCoordinator.evaluate(startupPolicyStore.load())) {
+ is StartupProcessingDecision.Prompt -> {
+ if (
+ !supportFragmentManager.isStateSaved &&
+ supportFragmentManager.findFragmentByTag(StartupProcessingDialogFragment.TAG) == null
+ ) {
+ StartupProcessingDialogFragment.newInstance(decision.pendingCount)
+ .show(supportFragmentManager, StartupProcessingDialogFragment.TAG)
+ }
+ }
+ is StartupProcessingDecision.Start -> startBatchTranscription()
+ is StartupProcessingDecision.Finish,
+ StartupProcessingDecision.Wait,
+ -> Unit
+ }
+ }
+
+ override fun onStartupProcessingConfirmed(remember: Boolean) {
+ if (!startupCoordinator.confirmPrompt()) return
+ if (remember) {
+ startupPolicyStore.save(StartupProcessingPolicy.AUTOMATIC)
+ }
+ evaluateStartupProcessing()
+ }
+
+ override fun onStartupProcessingDeclined(remember: Boolean) {
+ if (!startupCoordinator.declinePrompt()) return
+ if (remember) {
+ startupPolicyStore.save(StartupProcessingPolicy.LEAVE_QUEUED)
+ }
+ updateControls()
}
private fun transcriptionActive(): Boolean = transcriptionState == TranscriptionObservationState.ACTIVE
@@ -731,6 +1134,7 @@ class MainActivity : AppCompatActivity() {
transcriptionFinished = false
transcriptionPhase = null
transcriptionFilename = null
+ transcriptionEntryId = null
transcriptionIndeterminate = true
transcriptionProgressValue = 0
processedUs = -1L
@@ -741,56 +1145,8 @@ class MainActivity : AppCompatActivity() {
}
private fun updateControls() {
- val state = currentMainScreenState()
- val controls = state.controls
- selectOutput.visibility = if (controls.outputSetupVisible) View.VISIBLE else View.GONE
- selectOutput.isEnabled = controls.outputEnabled
- selectFolder.visibility = if (controls.folderSetupVisible) View.VISIBLE else View.GONE
- selectFolder.isEnabled = controls.folderEnabled
- transcribeAll.visibility = if (state.list.transcribeAllVisible) View.VISIBLE else View.GONE
- transcribeAll.isEnabled = state.list.transcribeAllEnabled
- for (index in 0 until fileList.childCount) {
- val row = fileList.getChildAt(index) as? LinearLayout ?: continue
- for (childIndex in 0 until row.childCount) {
- val actions = row.getChildAt(childIndex) as? LinearLayout ?: continue
- for (buttonIndex in 0 until actions.childCount) {
- val button = actions.getChildAt(buttonIndex) as? Button ?: continue
- when (val tag = button.tag) {
- is RetryButtonTag -> {
- val retry = MainScreenStateController.rowState(
- row = MainScreenRowInput(
- entryId = tag.entryId,
- state = AudioFileState.FAILED,
- hasTranscriptText = false,
- ),
- activePreviewEntryId = previewEntryId,
- previewState = previewState,
- transcriptionState = transcriptionState,
- busy = folderBusy(),
- retryEnabled = controls.retryEnabled,
- )
- button.isEnabled = retry.retryEnabled
- }
- is PreviewButtonTag -> {
- val preview = MainScreenStateController.rowState(
- row = MainScreenRowInput(
- entryId = tag.entryId,
- state = AudioFileState.PENDING,
- hasTranscriptText = false,
- ),
- activePreviewEntryId = previewEntryId,
- previewState = previewState,
- transcriptionState = transcriptionState,
- busy = folderBusy(),
- retryEnabled = controls.retryEnabled,
- ).preview
- button.text = preview.label
- button.isEnabled = preview.enabled
- }
- }
- }
- }
- }
+ importAudio.isEnabled = !ingestionActive
+ publishTaskState()
invalidateOptionsMenu()
}
@@ -845,107 +1201,312 @@ class MainActivity : AppCompatActivity() {
if (render) refreshCatalog()
}
- private fun renderStatusBlock() {
- val state = currentMainScreenState().status
- statusTitle.text = state.title
- setOptionalText(statusDetail, state.detail)
- setOptionalText(statusMeta, state.meta)
- statusProgress.visibility = if (state.progressVisible) View.VISIBLE else View.GONE
- statusProgress.isIndeterminate = state.progressIndeterminate
- statusProgress.progress = state.progress
- downloadModel.visibility = if (state.downloadVisible) View.VISIBLE else View.GONE
+ private fun publishTaskState() {
+ if (!::importAudio.isInitialized) return
+ val folderState = when {
+ folderAccessError != null -> FolderSetupSnapshotState.ERROR
+ folderUri == null -> FolderSetupSnapshotState.UNSELECTED
+ folderAccessReady -> FolderSetupSnapshotState.READY
+ else -> FolderSetupSnapshotState.READY
+ }
+ val outputState = when {
+ outputAccessReady -> OutputSetupSnapshotState.READY
+ outputAccessError != null -> OutputSetupSnapshotState.INVALID
+ outputUri == null -> OutputSetupSnapshotState.REQUIRED
+ else -> OutputSetupSnapshotState.READY
+ }
+ val audioInputAvailable = folderUri?.let { folderAccessReady } ?: currentEntries.any {
+ it.folderUri == AndroidAudioImportConstants.SOURCE_ID
+ }
+ val eligible = modelReady &&
+ outputAccessReady &&
+ audioInputAvailable &&
+ !folderBusy() &&
+ !transcriptionActive()
+ val transcription = TranscriptionTaskSnapshot(
+ active = transcriptionActive(),
+ activeEntryId = transcriptionEntryId,
+ preparationOwnerEntryId = null,
+ phase = transcriptionPhase,
+ percent = transcriptionProgressValue.takeUnless { transcriptionIndeterminate },
+ processedUs = processedUs.takeIf { it >= 0 },
+ durationUs = durationUs.takeIf { it > 0 },
+ completedFiles = completedFiles.takeIf { totalFiles > 0 },
+ totalFiles = totalFiles.takeIf { it > 0 },
+ failedFiles = failedFiles.takeIf { it > 0 },
+ prerequisiteError = null,
+ )
+ val controls = selectionControls()
+ val folderSyncState = taskStateHost.folderSyncCoordinator.state
+ taskStateHost.update { current ->
+ current.copy(
+ model = ModelSetupSnapshot(
+ state = modelSetupState,
+ detail = androidModelTaskDetail(modelSetupState, modelMessage),
+ installationPhase = modelMessage.takeIf {
+ modelSetupState == ModelSetupSnapshotState.INSTALLING
+ },
+ progressPercent = modelDownloadProgress,
+ downloadAvailable = modelDownloadAvailable,
+ canCancel = modelInstallCanCancel,
+ ),
+ output = OutputSetupSnapshot(
+ state = outputState,
+ detail = outputAccessError ?: outputDisplayName,
+ ),
+ folder = FolderSetupSnapshot(
+ state = folderState,
+ detail = folderAccessError ?: scanMessage ?: folderDisplayName,
+ ),
+ entries = currentEntries,
+ preview = PreviewTaskSnapshot(
+ activeEntryId = previewEntryId,
+ state = previewState,
+ ),
+ transcription = transcription,
+ transcriptionEligible = eligible,
+ previewEligible = !folderBusy() && !transcriptionActive(),
+ importEnabled = !ingestionActive,
+ hydration = AndroidMainScreenHydration(
+ modelKnown = modelPresentationKnown,
+ outputKnown = outputPresentationKnown,
+ folderKnown = folderPresentationKnown,
+ catalogKnown = catalogPresentationKnown,
+ ),
+ folderSync = AndroidFolderSyncPresentation(
+ visible = folderUri != null || folderSyncState.active,
+ active = folderSyncState.active,
+ enabled = folderUri != null && !folderSyncState.active && controls.refreshEnabled,
+ accessibilityLabel = if (folderSyncState.active) {
+ AndroidFolderSyncPresentation.ACCESSIBILITY_REFRESHING
+ } else {
+ AndroidFolderSyncPresentation.ACCESSIBILITY_REFRESH
+ },
+ ),
+ )
+ }
+ }
+
+ private fun renderTaskState(state: AndroidMainScreenState) {
+ renderingFilter = true
+ taskFilters.check(
+ when (state.taskList.filter) {
+ TaskListFilter.NEW -> R.id.newTab
+ TaskListFilter.PROCESSED -> R.id.processedTab
+ TaskListFilter.ALL -> R.id.allTab
+ },
+ )
+ renderingFilter = false
+ importAudio.isEnabled = state.importEnabled
+ taskAdapter.submitList(TaskListDisplayItems.from(state.taskList))
+ invalidateOptionsMenu()
}
- private fun setOptionalText(view: TextView, value: String?) {
- view.text = value.orEmpty()
- view.visibility = if (value.isNullOrBlank()) View.GONE else View.VISIBLE
+ private fun handleTaskAction(request: AndroidTaskActionRequest) {
+ taskActionRouter.route(request)
+ }
+
+ private fun performTaskAction(kind: TaskActionKind, entry: AudioCatalogEntry?) {
+ when (kind) {
+ TaskActionKind.DOWNLOAD_MODEL,
+ TaskActionKind.RETRY_MODEL_DOWNLOAD,
+ -> SpeechModelDownloadWorker.enqueue(this)
+ TaskActionKind.IMPORT_MODEL -> modelFolderPicker.launch(null)
+ TaskActionKind.CANCEL_MODEL_DOWNLOAD -> SpeechModelDownloadWorker.cancel(this)
+ TaskActionKind.SELECT_OUTPUT -> launchOutputPickerIfEnabled()
+ TaskActionKind.SELECT_FOLDER -> launchFolderPickerIfEnabled()
+ TaskActionKind.REFRESH_FOLDER -> dispatchManualFolderRefresh()
+ TaskActionKind.IMPORT_AUDIO -> if (!ingestionActive) {
+ importPicker.launch(arrayOf("audio/*", "application/ogg"))
+ }
+ TaskActionKind.TRANSCRIBE_ALL -> startBatchTranscription()
+ TaskActionKind.TRANSCRIBE -> entry?.let(::transcribeEntry)
+ TaskActionKind.RETRY_TRANSCRIPTION -> entry?.let(::retryEntry)
+ TaskActionKind.PLAY -> entry?.let(::startPreviewPlayback)
+ TaskActionKind.STOP -> stopPreviewPlayback(render = true)
+ TaskActionKind.SHOW_TEXT -> entry?.let(::showTranscriptText)
+ }
+ }
+
+ private fun transcribeEntry(entry: AudioCatalogEntry) {
+ val output = outputUri ?: return
+ if (entry.state != AudioFileState.PENDING || !outputAccessReady) return
+ stopPreviewPlayback(render = true)
+ currentSessionTranscriptionWorkId = TranscriptionWorker.enqueueEntry(
+ this,
+ folderUri,
+ output,
+ entry.id,
+ )
}
private fun updateMenu(menu: Menu) {
- val controls = currentMainScreenState().controls
- menu.findItem(R.id.menuRefreshFolder)?.isEnabled = controls.refreshEnabled
+ val state = taskStateHost.state.value
+ menu.findItem(R.id.menuRefreshFolder)?.apply {
+ isVisible = state.folderSync.visible
+ isEnabled = state.folderSync.enabled
+ }
+ renderRefreshIndicator(state.folderSync)
}
- private fun updateOutputSummary(displayName: String?) {
- outputName.text = if (displayName.isNullOrBlank()) {
- getString(R.string.output_not_selected)
- } else {
- getString(R.string.output_selected, displayName)
+ private fun dispatchManualFolderRefresh() {
+ val sync = taskStateHost.state.value.folderSync
+ if (!sync.enabled || sync.active || !selectionControls().refreshEnabled) return
+ scanFolder()
+ }
+
+ private fun renderRefreshIndicator(sync: AndroidFolderSyncPresentation) {
+ val view = refreshActionView ?: return
+ view.visibility = if (sync.visible) View.VISIBLE else View.GONE
+ view.isEnabled = sync.enabled
+ view.contentDescription = sync.accessibilityLabel
+ ViewCompat.setStateDescription(
+ view,
+ if (sync.active) getString(R.string.refresh_state_refreshing) else getString(R.string.refresh_state_idle),
+ )
+ if (sync.active == refreshIndicatorActive) return
+ refreshIndicatorActive = sync.active
+ refreshIndicatorHandler.removeCallbacks(startRefreshIndicator)
+ refreshIndicatorHandler.removeCallbacks(stopRefreshIndicator)
+ if (sync.active) {
+ refreshIndicatorHandler.postDelayed(startRefreshIndicator, REFRESH_SHOW_DELAY_MS)
+ } else if (refreshIndicatorAnimator != null) {
+ val elapsed = android.os.SystemClock.uptimeMillis() - refreshIndicatorStartedAt
+ refreshIndicatorHandler.postDelayed(
+ stopRefreshIndicator,
+ (REFRESH_MIN_VISIBLE_MS - elapsed).coerceAtLeast(0L),
+ )
}
}
- private fun updateFolderSummary(displayName: String?) {
- folderName.text = if (displayName.isNullOrBlank()) {
- getString(R.string.folder_not_selected)
- } else {
- getString(R.string.folder_selected, displayName)
+ private fun startRefreshIndicatorAnimation() {
+ if (!refreshIndicatorActive || activityDestroyed) return
+ val view = refreshActionView ?: return
+ refreshIndicatorStartedAt = android.os.SystemClock.uptimeMillis()
+ refreshIndicatorAnimator?.cancel()
+ refreshIndicatorAnimator = android.animation.ObjectAnimator.ofFloat(view, View.ROTATION, 0f, 360f).apply {
+ duration = REFRESH_ROTATION_MS
+ repeatCount = android.animation.ValueAnimator.INFINITE
+ interpolator = android.view.animation.LinearInterpolator()
+ start()
}
}
+ private fun stopRefreshIndicatorAnimation() {
+ if (refreshIndicatorActive) return
+ refreshIndicatorAnimator?.cancel()
+ refreshIndicatorAnimator = null
+ refreshActionView?.rotation = 0f
+ }
+
+ private fun updateOutputSummary(displayName: String?) {
+ outputDisplayName = displayName
+ publishTaskState()
+ }
+
+ private fun updateFolderSummary(displayName: String?) {
+ folderDisplayName = displayName
+ publishTaskState()
+ }
+
private fun showError(message: String) {
runOnUiThread {
if (activityDestroyed) return@runOnUiThread
- statusError = message
- renderStatusBlock()
- updateControls()
+ Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
}
- private fun formatSize(bytes: Long): String =
- if (bytes < 1024 * 1024) "${bytes / 1024} KiB" else "${bytes / (1024 * 1024)} MiB"
-
- private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
-
- private fun currentMainScreenState(
- entries: List = emptyList(),
- ): MainScreenState =
- MainScreenStateController.state(
- MainScreenInput(
- modelMessage = modelMessage,
- modelLoading = modelLoading,
- modelDownloadAvailable = modelDownloadAvailable,
- modelDownloadProgress = modelDownloadProgress,
- modelReady = modelReady,
- outputSelected = outputUri != null,
- folderSelected = folderUri != null,
- pendingCount = pendingCount,
- folderChecking = folderChecking,
- folderScanQueued = folderScanQueued,
- scanning = scanning,
- scanMessage = scanMessage,
- transcriptionState = transcriptionState,
- transcriptionPhase = transcriptionPhase,
- transcriptionFilename = transcriptionFilename,
- transcriptionIndeterminate = transcriptionIndeterminate,
- transcriptionProgress = transcriptionProgressValue,
- processedUs = processedUs,
- durationUs = durationUs,
- completedFiles = completedFiles,
- totalFiles = totalFiles,
- failedFiles = failedFiles,
- errorMessage = statusError,
- selectedTab = selectedTab,
- displayedRowCount = entries.size,
- activePreviewEntryId = previewEntryId,
- previewState = previewState,
- rows = entries.map { it.toMainScreenRowInput() },
+ private fun activeSourceScope(): AudioCatalogSourceScope =
+ AudioCatalogSourceScope.of(
+ listOfNotNull(
+ AndroidAudioImportConstants.SOURCE_ID,
+ folderUri?.toString(),
),
)
- private fun AudioCatalogEntry.toMainScreenRowInput(): MainScreenRowInput =
- MainScreenRowInput(
- entryId = id,
- state = state,
- hasTranscriptText = !transcriptText.isNullOrBlank(),
- )
+ private fun cleanupImportedAudio() {
+ importExecutor.execute {
+ val cleanupCatalog = AndroidSqlDelightAudioCatalogFactory(this).create()
+ try {
+ AndroidAudioImportStorage(this).cleanupOrphans(cleanupCatalog)
+ } finally {
+ cleanupCatalog.close()
+ }
+ }
+ }
+
+ private fun handleShareIntent(sharedIntent: Intent) {
+ if (!AudioShareIntentParser.isShareIntent(sharedIntent)) return
+ val uris = AudioShareIntentParser.streamUris(sharedIntent)
+ sharedIntent.action = Intent.ACTION_MAIN
+ sharedIntent.clipData = null
+ sharedIntent.replaceExtras(Bundle())
+ if (uris.isEmpty()) {
+ Toast.makeText(this, R.string.no_shared_audio, Toast.LENGTH_LONG).show()
+ return
+ }
+ ingestAudioUris(uris)
+ }
+
+ private fun ingestAudioUris(uris: List) {
+ if (uris.isEmpty()) return
+ if (ingestionActive) {
+ queuedImportUris += uris.filter { candidate ->
+ queuedImportUris.none { it.toString() == candidate.toString() }
+ }
+ return
+ }
+ ingestionActive = true
+ updateControls()
+ val sourceIds = uris.map(Uri::toString)
+ importExecutor.execute {
+ val importCatalog = AndroidSqlDelightAudioCatalogFactory(this).create()
+ val summary = try {
+ runCatching {
+ val storage = AndroidAudioImportStorage(this)
+ storage.cleanupOrphans(importCatalog)
+ AudioImportUseCase(
+ storage = storage,
+ catalog = SqlDelightAudioImportCatalog(importCatalog),
+ ).ingest(sourceIds)
+ }.getOrElse { error ->
+ AudioImportSummary(
+ listOf(
+ AudioImportItemOutcome.Rejected(
+ displayName = "shared audio",
+ reason = error.message ?: "Audio import failed",
+ ),
+ ),
+ )
+ }
+ } finally {
+ importCatalog.close()
+ }
+ runOnUiThread {
+ if (activityDestroyed) return@runOnUiThread
+ ingestionActive = false
+ Toast.makeText(this, summary.message(), Toast.LENGTH_LONG).show()
+ refreshCatalog()
+ if (queuedImportUris.isNotEmpty()) {
+ val queued = queuedImportUris.toList()
+ queuedImportUris.clear()
+ ingestAudioUris(queued)
+ }
+ }
+ }
+ }
- private data class PreviewButtonTag(val entryId: Long)
- private data class RetryButtonTag(val entryId: Long)
+
+ private enum class FolderScanOrigin {
+ STARTUP,
+ USER,
+ }
private companion object {
- const val MENU_ENTRY_PLAY = 1
- const val MENU_ENTRY_RETRY = 2
- const val MENU_ENTRY_SHOW_TEXT = 3
+ const val STATE_STARTUP_PROCESSING_STAGE = "startup-processing-stage"
+ const val REFRESH_SHOW_DELAY_MS = 180L
+ const val REFRESH_MIN_VISIBLE_MS = 450L
+ const val REFRESH_ROTATION_MS = 800L
val ACTIVE_WORK_STATES = setOf(
WorkInfo.State.ENQUEUED,
WorkInfo.State.BLOCKED,
@@ -955,19 +1516,20 @@ class MainActivity : AppCompatActivity() {
@Volatile
private var sharedModelReadiness: SpeechModelReadinessManager? = null
private val handledModelInstallSuccessIds = mutableSetOf()
+ private val handledTranscriptionFailureIds = mutableSetOf()
fun getSharedModelReadiness(repository: SpeechModelRepository): SpeechModelReadinessManager =
sharedModelReadiness ?: synchronized(this) {
sharedModelReadiness ?: SpeechModelReadinessManager(
repository = repository,
- initializeModel = { directory ->
- NativeTranscriptionBridge.initialize(directory.absolutePath)
- },
executor = Executors.newSingleThreadExecutor(),
).also { sharedModelReadiness = it }
}
fun shouldHandleModelInstallSuccess(id: String): Boolean =
synchronized(this) { handledModelInstallSuccessIds.add(id) }
+
+ fun shouldRefreshModelAfterFailure(id: String): Boolean =
+ synchronized(this) { handledTranscriptionFailureIds.add(id) }
}
}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/NativeTranscriptionBridge.kt b/app/src/main/java/me/maxistar/voiceinbox/NativeTranscriptionBridge.kt
index d185283..94a920e 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/NativeTranscriptionBridge.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/NativeTranscriptionBridge.kt
@@ -22,6 +22,8 @@ object NativeTranscriptionBridge {
external fun initialize(modelDirectory: String): Boolean
+ external fun reset()
+
private external fun transcribeChunkJson(samples: FloatArray): String?
fun transcribeChunk(samples: FloatArray): NativeChunkResult? {
diff --git a/app/src/main/java/me/maxistar/voiceinbox/PersistedSelectionAccess.kt b/app/src/main/java/me/maxistar/voiceinbox/PersistedSelectionAccess.kt
new file mode 100644
index 0000000..a06ee42
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/PersistedSelectionAccess.kt
@@ -0,0 +1,68 @@
+package me.maxistar.voiceinbox
+
+import android.content.ContentResolver
+import android.net.Uri
+
+enum class RequiredDocumentAccess {
+ READ,
+ WRITE,
+}
+
+enum class PersistedSelectionAccessState {
+ VALID,
+ TEMPORARILY_UNAVAILABLE,
+ PERMISSION_REVOKED,
+}
+
+data class PersistedSelectionValidation(
+ val state: PersistedSelectionAccessState,
+ val value: T? = null,
+ val error: Throwable? = null,
+)
+
+object PersistedSelectionAccessPolicy {
+ fun classify(
+ validationSucceeded: Boolean,
+ hasRequiredPersistedPermission: Boolean,
+ ): PersistedSelectionAccessState = when {
+ validationSucceeded -> PersistedSelectionAccessState.VALID
+ hasRequiredPersistedPermission -> PersistedSelectionAccessState.TEMPORARILY_UNAVAILABLE
+ else -> PersistedSelectionAccessState.PERMISSION_REVOKED
+ }
+
+ fun shouldClearSelection(state: PersistedSelectionAccessState): Boolean =
+ state == PersistedSelectionAccessState.PERMISSION_REVOKED
+}
+
+open class PersistedSelectionAccess(
+ private val resolver: ContentResolver,
+) {
+ open fun validate(
+ uri: Uri,
+ requiredAccess: RequiredDocumentAccess,
+ validation: () -> T,
+ ): PersistedSelectionValidation {
+ val result = runCatching(validation)
+ val state = PersistedSelectionAccessPolicy.classify(
+ validationSucceeded = result.isSuccess,
+ hasRequiredPersistedPermission = hasRequiredPersistedPermission(uri, requiredAccess),
+ )
+ return PersistedSelectionValidation(
+ state = state,
+ value = result.getOrNull(),
+ error = result.exceptionOrNull(),
+ )
+ }
+
+ private fun hasRequiredPersistedPermission(
+ uri: Uri,
+ requiredAccess: RequiredDocumentAccess,
+ ): Boolean = runCatching {
+ resolver.persistedUriPermissions.any { permission ->
+ permission.uri == uri && when (requiredAccess) {
+ RequiredDocumentAccess.READ -> permission.isReadPermission
+ RequiredDocumentAccess.WRITE -> permission.isWritePermission
+ }
+ }
+ }.getOrDefault(false)
+}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/ScheduledTranscriptionWorker.kt b/app/src/main/java/me/maxistar/voiceinbox/ScheduledTranscriptionWorker.kt
index a8ce7f4..837580a 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/ScheduledTranscriptionWorker.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/ScheduledTranscriptionWorker.kt
@@ -51,15 +51,18 @@ class ScheduledTranscriptionWorker(
documentAccess.requireAppendable(output)
folderScanner.requireReadable(folder)
- val model = SpeechModelRepository(
+ if (SpeechModelRepository(
applicationContext.noBackupFilesDir.resolve("models"),
- ).inspect() as? InstalledSpeechModelState.Ready ?: return
- if (!NativeTranscriptionBridge.initialize(model.directory.absolutePath)) return
+ ).inspectLightweight() !is InstalledSpeechModelState.Ready) return
val catalog = AndroidSqlDelightAudioCatalogFactory(applicationContext).create()
val files = folderScanner.scan(folder)
catalog.reconcile(folder.toString(), files)
- val pending = catalog.pendingCount(folder.toString())
+ val pending = catalog.pendingCount(
+ AudioCatalogSourceScope.of(
+ listOf(AndroidAudioImportConstants.SOURCE_ID, folder.toString()),
+ ),
+ )
if (ScheduledTranscriptionRules.shouldStartTranscription(pending, transcriptionActive())) {
TranscriptionWorker.enqueueAll(applicationContext, folder, output)
}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/SettingsActivity.kt b/app/src/main/java/me/maxistar/voiceinbox/SettingsActivity.kt
index 26623d0..eb02b7a 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/SettingsActivity.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/SettingsActivity.kt
@@ -9,6 +9,7 @@ import android.net.Uri
import android.os.Bundle
import android.view.MenuItem
import android.view.View
+import android.widget.RadioGroup
import android.widget.TextView
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
@@ -21,6 +22,7 @@ import java.util.concurrent.Executors
class SettingsActivity : AppCompatActivity() {
private lateinit var settingsStore: ScheduledTranscriptionSettingsStore
+ private lateinit var startupPolicyStore: StartupProcessingPolicyStore
private lateinit var selectionStore: DocumentSelectionStore
private lateinit var documentAccess: DocumentAccess
private lateinit var folderScanner: AudioFolderScanner
@@ -63,6 +65,9 @@ class SettingsActivity : AppCompatActivity() {
settingsStore = ScheduledTranscriptionSettingsStore(
getSharedPreferences(ScheduledTranscriptionSettingsStore.PREFERENCES_NAME, MODE_PRIVATE),
)
+ startupPolicyStore = StartupProcessingPolicyStore(
+ getSharedPreferences(StartupProcessingPolicyStore.PREFERENCES_NAME, MODE_PRIVATE),
+ )
selectionStore = DocumentSelectionStore(
getSharedPreferences(DocumentSelectionStore.PREFERENCES_NAME, MODE_PRIVATE),
)
@@ -90,6 +95,22 @@ class SettingsActivity : AppCompatActivity() {
findViewById(R.id.settingsLegalRow).setOnClickListener {
openExternalUrl(LEGAL_URL)
}
+ val startupPolicyGroup = findViewById(R.id.startupProcessingPolicy)
+ startupPolicyGroup.check(
+ when (startupPolicyStore.load()) {
+ StartupProcessingPolicy.ASK -> R.id.startupProcessingAsk
+ StartupProcessingPolicy.AUTOMATIC -> R.id.startupProcessingAutomatic
+ StartupProcessingPolicy.LEAVE_QUEUED -> R.id.startupProcessingLeaveQueued
+ },
+ )
+ startupPolicyGroup.setOnCheckedChangeListener { _, checkedId ->
+ val policy = when (checkedId) {
+ R.id.startupProcessingAutomatic -> StartupProcessingPolicy.AUTOMATIC
+ R.id.startupProcessingLeaveQueued -> StartupProcessingPolicy.LEAVE_QUEUED
+ else -> StartupProcessingPolicy.ASK
+ }
+ startupPolicyStore.save(policy)
+ }
scheduledSwitch.isChecked = settings.enabled
scheduledSwitch.setOnCheckedChangeListener { _, enabled ->
settings = settingsStore.setEnabled(enabled)
diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDirectoryReader.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDirectoryReader.kt
new file mode 100644
index 0000000..7a38d3d
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDirectoryReader.kt
@@ -0,0 +1,74 @@
+package me.maxistar.voiceinbox
+
+import android.content.ContentResolver
+import android.net.Uri
+import android.provider.DocumentsContract
+import me.maxistar.voiceinbox.core.SpeechModelManifest
+import java.io.IOException
+
+data class SpeechModelSourceDocument(
+ val name: String,
+ val uri: String,
+ val mimeType: String?,
+)
+
+class SpeechModelDirectoryReader(
+ private val resolver: ContentResolver,
+) {
+ fun requiredDocuments(
+ treeUri: Uri,
+ manifest: SpeechModelManifest,
+ ): Map {
+ val treeId = runCatching { DocumentsContract.getTreeDocumentId(treeUri) }
+ .getOrElse { throw IOException("The selected model folder is not readable", it) }
+ val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, treeId)
+ val cursor = resolver.query(
+ childrenUri,
+ arrayOf(
+ DocumentsContract.Document.COLUMN_DOCUMENT_ID,
+ DocumentsContract.Document.COLUMN_DISPLAY_NAME,
+ DocumentsContract.Document.COLUMN_MIME_TYPE,
+ ),
+ null,
+ null,
+ null,
+ ) ?: throw IOException("The selected model folder cannot be enumerated")
+ val documents = cursor.use {
+ val id = it.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
+ val name = it.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
+ val mime = it.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE)
+ buildList {
+ while (it.moveToNext()) {
+ val displayName = it.getString(name) ?: continue
+ add(
+ SpeechModelSourceDocument(
+ name = displayName,
+ uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, it.getString(id)).toString(),
+ mimeType = if (mime >= 0 && !it.isNull(mime)) it.getString(mime) else null,
+ ),
+ )
+ }
+ }
+ }
+ return matchRequiredDocuments(documents, manifest)
+ }
+
+ companion object {
+ fun matchRequiredDocuments(
+ documents: List,
+ manifest: SpeechModelManifest,
+ ): Map {
+ val regularFiles = documents.filter {
+ it.mimeType != DocumentsContract.Document.MIME_TYPE_DIR
+ }
+ return manifest.files.associate { entry ->
+ val matches = regularFiles.filter { it.name == entry.name }
+ when (matches.size) {
+ 0 -> throw IOException("${entry.name} is missing from the selected model folder")
+ 1 -> entry.name to matches.single().uri
+ else -> throw IOException("Multiple files named ${entry.name} were found")
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt
index 0183b63..2e298ea 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt
@@ -2,14 +2,9 @@ package me.maxistar.voiceinbox
import me.maxistar.voiceinbox.core.*
-import android.app.NotificationChannel
-import android.app.NotificationManager
import android.content.Context
-import android.os.Build
-import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
-import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
@@ -36,7 +31,7 @@ class SpeechModelDownloadWorker(
.build()
override suspend fun doWork(): Result {
- setForeground(createForegroundInfo(0, "Preparing model download"))
+ setForeground(SpeechModelInstallationWork.foregroundInfo(applicationContext, 0, "Preparing model download"))
repository.prepareForInstall().getOrElse {
return failure(it.message ?: "Model installation preflight failed")
}
@@ -85,6 +80,7 @@ class SpeechModelDownloadWorker(
val installedDirectory = repository.activate().getOrElse {
return failure(it.message ?: "Failed to activate speech model")
}
+ SpeechModelPreparation.invalidate(NativeTranscriptionBridge::reset)
return Result.success(workDataOf(KEY_MODEL_PATH to installedDirectory.absolutePath))
}
@@ -133,49 +129,23 @@ class SpeechModelDownloadWorker(
KEY_MESSAGE to message,
),
)
- setForeground(createForegroundInfo(percent, message))
- }
-
- private fun createForegroundInfo(progress: Int, message: String): ForegroundInfo {
- val manager =
- applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- manager.createNotificationChannel(
- NotificationChannel(
- NOTIFICATION_CHANNEL,
- "Speech model download",
- NotificationManager.IMPORTANCE_LOW,
- ),
- )
- }
- val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL)
- .setSmallIcon(android.R.drawable.stat_sys_download)
- .setContentTitle("Voice Inbox")
- .setContentText(message)
- .setOnlyAlertOnce(true)
- .setOngoing(true)
- .setProgress(100, progress, false)
- .build()
- return ForegroundInfo(NOTIFICATION_ID, notification)
+ setForeground(SpeechModelInstallationWork.foregroundInfo(applicationContext, percent, message))
}
private fun failure(message: String): Result =
Result.failure(workDataOf(KEY_ERROR to message))
companion object {
- const val UNIQUE_WORK_NAME = "speech-model-installation"
- const val KEY_BYTES_DOWNLOADED = "bytes-downloaded"
- const val KEY_TOTAL_BYTES = "total-bytes"
- const val KEY_MESSAGE = "message"
- const val KEY_ERROR = "error"
- const val KEY_MODEL_PATH = "model-path"
+ const val UNIQUE_WORK_NAME = SpeechModelInstallationWork.UNIQUE_WORK_NAME
+ const val KEY_BYTES_DOWNLOADED = SpeechModelInstallationWork.KEY_BYTES_DOWNLOADED
+ const val KEY_TOTAL_BYTES = SpeechModelInstallationWork.KEY_TOTAL_BYTES
+ const val KEY_MESSAGE = SpeechModelInstallationWork.KEY_MESSAGE
+ const val KEY_ERROR = SpeechModelInstallationWork.KEY_ERROR
+ const val KEY_MODEL_PATH = SpeechModelInstallationWork.KEY_MODEL_PATH
private const val MAX_ATTEMPTS = 3
private const val RETRY_DELAY_MS = 2_000L
private const val PROGRESS_STEP_BYTES = 2L * 1024L * 1024L
- private const val NOTIFICATION_CHANNEL = "speech-model-download"
- private const val NOTIFICATION_ID = 1907
-
fun enqueue(context: Context) {
val request = OneTimeWorkRequestBuilder().build()
WorkManager.getInstance(context).enqueueUniqueWork(
diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportPermission.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportPermission.kt
new file mode 100644
index 0000000..68877a6
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportPermission.kt
@@ -0,0 +1,31 @@
+package me.maxistar.voiceinbox
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+
+object SpeechModelImportPermission {
+ private const val PREFERENCES_NAME = "speech_model_import"
+ private const val KEY_OWNED_URI = "owned_uri"
+
+ fun recordOwned(context: Context, uri: Uri) {
+ context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
+ .edit().putString(KEY_OWNED_URI, uri.toString()).apply()
+ }
+
+ fun releaseOwnedIfUnused(context: Context) {
+ val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
+ val value = preferences.getString(KEY_OWNED_URI, null) ?: return
+ val selectedFolder = DocumentSelectionStore(
+ context.getSharedPreferences(DocumentSelectionStore.PREFERENCES_NAME, Context.MODE_PRIVATE),
+ ).loadFolderUri()
+ if (selectedFolder == value) return
+ runCatching {
+ context.contentResolver.releasePersistableUriPermission(
+ Uri.parse(value),
+ Intent.FLAG_GRANT_READ_URI_PERMISSION,
+ )
+ }
+ preferences.edit().remove(KEY_OWNED_URI).apply()
+ }
+}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt
new file mode 100644
index 0000000..b28af2c
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt
@@ -0,0 +1,83 @@
+package me.maxistar.voiceinbox
+
+import android.content.Context
+import android.net.Uri
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import androidx.work.workDataOf
+
+class SpeechModelImportWorker(
+ appContext: Context,
+ params: WorkerParameters,
+) : CoroutineWorker(appContext, params) {
+ private val repository = SpeechModelRepository(
+ root = applicationContext.noBackupFilesDir.resolve("models"),
+ )
+
+ override suspend fun doWork(): Result {
+ val treeUri = inputData.getString(KEY_TREE_URI)?.let(Uri::parse)
+ ?: return failure("No model folder was selected")
+ setForeground(
+ SpeechModelInstallationWork.foregroundInfo(
+ applicationContext,
+ 0,
+ "Preparing local speech model",
+ ),
+ )
+ return try {
+ val installed = SpeechModelLocalImporter(
+ resolver = applicationContext.contentResolver,
+ repository = repository,
+ ).import(treeUri.toString()) { progress -> publishProgress(progress) }.getOrElse {
+ return failure(it.message ?: "Could not import speech model")
+ }
+ SpeechModelPreparation.invalidate(NativeTranscriptionBridge::reset)
+ Result.success(
+ workDataOf(SpeechModelInstallationWork.KEY_MODEL_PATH to installed.absolutePath),
+ )
+ } finally {
+ SpeechModelImportPermission.releaseOwnedIfUnused(applicationContext)
+ }
+ }
+
+ private suspend fun publishProgress(progress: SpeechModelImportProgress) {
+ val total = repository.manifest.totalSizeBytes
+ val percent = ((progress.bytesCopied.coerceIn(0, total) * 100) / total).toInt()
+ setProgress(
+ workDataOf(
+ SpeechModelInstallationWork.KEY_BYTES_DOWNLOADED to progress.bytesCopied,
+ SpeechModelInstallationWork.KEY_TOTAL_BYTES to total,
+ SpeechModelInstallationWork.KEY_MESSAGE to progress.message,
+ ),
+ )
+ setForeground(
+ SpeechModelInstallationWork.foregroundInfo(
+ applicationContext,
+ percent,
+ progress.message,
+ ),
+ )
+ }
+
+ private fun failure(message: String): Result = Result.failure(
+ workDataOf(SpeechModelInstallationWork.KEY_ERROR to message),
+ )
+
+ companion object {
+ const val KEY_TREE_URI = "tree-uri"
+
+ fun enqueue(context: Context, treeUri: Uri) {
+ val request = OneTimeWorkRequestBuilder()
+ .setInputData(workDataOf(KEY_TREE_URI to treeUri.toString()))
+ .build()
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ SpeechModelInstallationWork.UNIQUE_WORK_NAME,
+ ExistingWorkPolicy.KEEP,
+ request,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt
new file mode 100644
index 0000000..7ed8975
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt
@@ -0,0 +1,42 @@
+package me.maxistar.voiceinbox
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.work.ForegroundInfo
+
+object SpeechModelInstallationWork {
+ const val UNIQUE_WORK_NAME = "speech-model-installation"
+ const val KEY_BYTES_DOWNLOADED = "bytes-downloaded"
+ const val KEY_TOTAL_BYTES = "total-bytes"
+ const val KEY_MESSAGE = "message"
+ const val KEY_ERROR = "error"
+ const val KEY_MODEL_PATH = "model-path"
+
+ private const val NOTIFICATION_CHANNEL = "speech-model-download"
+ private const val NOTIFICATION_ID = 1907
+
+ fun foregroundInfo(context: Context, progress: Int, message: String): ForegroundInfo {
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ manager.createNotificationChannel(
+ NotificationChannel(
+ NOTIFICATION_CHANNEL,
+ "Speech model installation",
+ NotificationManager.IMPORTANCE_LOW,
+ ),
+ )
+ }
+ val notification = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL)
+ .setSmallIcon(android.R.drawable.stat_sys_download)
+ .setContentTitle("Voice Inbox")
+ .setContentText(message)
+ .setOnlyAlertOnce(true)
+ .setOngoing(true)
+ .setProgress(100, progress, false)
+ .build()
+ return ForegroundInfo(NOTIFICATION_ID, notification)
+ }
+}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelLocalImporter.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelLocalImporter.kt
new file mode 100644
index 0000000..117a148
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelLocalImporter.kt
@@ -0,0 +1,93 @@
+package me.maxistar.voiceinbox
+
+import me.maxistar.voiceinbox.core.SpeechModelManifest
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.ensureActive
+import java.io.InputStream
+import java.io.IOException
+
+data class SpeechModelImportProgress(
+ val bytesCopied: Long,
+ val message: String,
+)
+
+class SpeechModelLocalImporter(
+ private val repository: SpeechModelRepository,
+ private val requiredDocuments: (String, SpeechModelManifest) -> Map,
+ private val openInputStream: (String) -> InputStream?,
+) {
+ constructor(
+ resolver: android.content.ContentResolver,
+ repository: SpeechModelRepository,
+ ) : this(
+ repository = repository,
+ requiredDocuments = { treeUri, manifest ->
+ SpeechModelDirectoryReader(resolver).requiredDocuments(android.net.Uri.parse(treeUri), manifest)
+ },
+ openInputStream = { resolver.openInputStream(android.net.Uri.parse(it)) },
+ )
+
+ suspend fun import(
+ treeUri: String,
+ progress: suspend (SpeechModelImportProgress) -> Unit,
+ ): Result {
+ return try {
+ repository.prepareFreshImport().getOrThrow()
+ val sources = requiredDocuments(treeUri, repository.manifest)
+ var completedBytes = 0L
+ progress(SpeechModelImportProgress(0, "Preparing local speech model"))
+
+ repository.manifest.files.forEach { entry ->
+ currentCoroutineContext().ensureActive()
+ repository.cleanupFailedCurrentFile(entry)
+ val source = sources.getValue(entry.name)
+ val temporary = repository.temporaryFile(entry)
+ try {
+ val input = openInputStream(source)
+ ?: throw IOException("Cannot read ${entry.name}")
+ input.buffered().use { stream ->
+ temporary.outputStream().buffered().use { output ->
+ val buffer = ByteArray(128 * 1024)
+ var fileBytes = 0L
+ while (true) {
+ currentCoroutineContext().ensureActive()
+ val read = stream.read(buffer)
+ if (read < 0) break
+ output.write(buffer, 0, read)
+ fileBytes += read
+ check(fileBytes <= entry.sizeBytes) {
+ "${entry.name} is larger than expected"
+ }
+ progress(
+ SpeechModelImportProgress(
+ completedBytes + fileBytes,
+ "Copying ${entry.name}",
+ ),
+ )
+ }
+ }
+ }
+ repository.acceptTemporaryFile(entry).getOrThrow()
+ completedBytes += entry.sizeBytes
+ progress(SpeechModelImportProgress(completedBytes, "Verified ${entry.name}"))
+ } catch (error: Throwable) {
+ repository.cleanupFailedCurrentFile(entry)
+ throw error
+ }
+ }
+
+ progress(
+ SpeechModelImportProgress(
+ repository.manifest.totalSizeBytes,
+ "Activating speech model",
+ ),
+ )
+ Result.success(repository.activate().getOrThrow())
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (error: Throwable) {
+ Result.failure(error)
+ }
+ }
+}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelPreparation.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelPreparation.kt
new file mode 100644
index 0000000..87e7a71
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelPreparation.kt
@@ -0,0 +1,38 @@
+package me.maxistar.voiceinbox
+
+import java.io.File
+
+object SpeechModelPreparation {
+ private val lock = Any()
+ private var preparedDirectory: String? = null
+
+ fun prepare(
+ repository: SpeechModelRepository,
+ initializeModel: (File) -> Boolean,
+ ): Result = synchronized(lock) {
+ val expected = repository.installedDirectory.canonicalPath
+ if (preparedDirectory == expected) {
+ return@synchronized Result.success(repository.installedDirectory)
+ }
+ runCatching {
+ val installed = repository.inspect()
+ check(installed is InstalledSpeechModelState.Ready) {
+ when (installed) {
+ InstalledSpeechModelState.Missing -> "Speech model is not installed"
+ is InstalledSpeechModelState.Invalid -> installed.reason
+ is InstalledSpeechModelState.Ready -> error("unreachable")
+ }
+ }
+ check(initializeModel(installed.directory)) { "Speech model failed to load" }
+ preparedDirectory = installed.directory.canonicalPath
+ installed.directory
+ }
+ }
+
+ fun invalidate(resetNative: () -> Unit = {}) {
+ synchronized(lock) {
+ preparedDirectory = null
+ resetNative()
+ }
+ }
+}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelReadinessManager.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelReadinessManager.kt
index 4764992..a4a886d 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelReadinessManager.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelReadinessManager.kt
@@ -13,7 +13,6 @@ sealed interface SpeechModelReadinessState {
class SpeechModelReadinessManager(
private val repository: SpeechModelRepository,
- private val initializeModel: (File) -> Boolean,
private val executor: Executor,
) {
private val lock = Any()
@@ -53,19 +52,13 @@ class SpeechModelReadinessManager(
private fun checkModel() {
val state = runCatching {
repository.cleanupStaleState()
- when (val installed = repository.inspect()) {
- is InstalledSpeechModelState.Ready -> {
- if (initializeModel(installed.directory)) {
- SpeechModelReadinessState.Ready(installed.directory)
- } else {
- SpeechModelReadinessState.Failed("Speech model failed to load")
- }
- }
+ when (val installed = repository.inspectLightweight()) {
+ is InstalledSpeechModelState.Ready -> SpeechModelReadinessState.Ready(installed.directory)
InstalledSpeechModelState.Missing -> SpeechModelReadinessState.Missing
is InstalledSpeechModelState.Invalid -> SpeechModelReadinessState.Invalid(installed.reason)
}
}.getOrElse { error ->
- SpeechModelReadinessState.Failed(error.message ?: "Speech model failed to load")
+ SpeechModelReadinessState.Failed(error.message ?: "Speech model inspection failed")
}
val pending = synchronized(lock) {
diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelRepository.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelRepository.kt
index 562a616..294b21a 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelRepository.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelRepository.kt
@@ -10,7 +10,15 @@ import java.nio.file.StandardCopyOption
import java.util.UUID
sealed interface InstalledSpeechModelState {
- data class Ready(val directory: File) : InstalledSpeechModelState
+ data class Ready(
+ val directory: File,
+ val verification: Verification = Verification.VERIFIED,
+ ) : InstalledSpeechModelState {
+ enum class Verification {
+ VERIFIED,
+ LEGACY_UNVERIFIED,
+ }
+ }
data object Missing : InstalledSpeechModelState
data class Invalid(val reason: String) : InstalledSpeechModelState
}
@@ -19,10 +27,16 @@ class SpeechModelRepository(
private val root: File,
val manifest: SpeechModelManifest = EmbeddedSpeechModel.manifest,
private val usableSpace: (File) -> Long = { it.usableSpace },
+ private val moveDirectory: (File, File) -> Boolean = { source, destination ->
+ source.renameTo(destination)
+ },
) {
private val stagingRoot = File(root, "staging")
private val installedRoot = File(root, "installed")
private val activeVersionFile = File(root, "active-model")
+ private val invalidModelFile = File(root, "invalid-model")
+ private val backupDirectory = File(installedRoot, "${manifest.version}.backup")
+ private val activationMarker = File(root, "activation-model")
val stagingDirectory: File
get() = File(stagingRoot, manifest.version)
@@ -30,18 +44,41 @@ class SpeechModelRepository(
val installedDirectory: File
get() = File(installedRoot, manifest.version)
+ fun inspectLightweight(): InstalledSpeechModelState {
+ recoverInterruptedActivation()
+ invalidModelFile.takeIf(File::isFile)?.readText()?.trim()?.takeIf(String::isNotEmpty)?.let {
+ return InstalledSpeechModelState.Invalid(it)
+ }
+ if (!installedDirectory.isDirectory) return InstalledSpeechModelState.Missing
+ val missing = manifest.files.firstOrNull { !File(installedDirectory, it.name).isFile }
+ return if (missing == null) {
+ InstalledSpeechModelState.Ready(
+ directory = installedDirectory,
+ verification = if (activeVersionFile.takeIf(File::isFile)?.readText()?.trim() == manifest.version) {
+ InstalledSpeechModelState.Ready.Verification.VERIFIED
+ } else {
+ InstalledSpeechModelState.Ready.Verification.LEGACY_UNVERIFIED
+ },
+ )
+ } else {
+ InstalledSpeechModelState.Invalid("${missing.name} is missing")
+ }
+ }
+
fun inspect(): InstalledSpeechModelState {
+ recoverInterruptedActivation()
val activeVersion = activeVersionFile.takeIf(File::isFile)?.readText()?.trim()
if (activeVersion == manifest.version) {
- return validateDirectory(installedDirectory)
+ return recordValidation(validateDirectory(installedDirectory))
}
return when (val installed = validateDirectory(installedDirectory)) {
is InstalledSpeechModelState.Ready -> {
writeActiveVersion(manifest.version)
+ invalidModelFile.delete()
installed
}
- is InstalledSpeechModelState.Invalid -> installed
+ is InstalledSpeechModelState.Invalid -> installed.also { writeInvalidReason(it.reason) }
InstalledSpeechModelState.Missing -> InstalledSpeechModelState.Missing
}
}
@@ -66,6 +103,23 @@ class SpeechModelRepository(
}
}
+ fun prepareFreshImport(): Result = runCatching {
+ root.mkdirs()
+ stagingRoot.mkdirs()
+ installedRoot.mkdirs()
+ recoverInterruptedActivation()
+ cleanupTemporaryFiles(root)
+ stagingRoot.listFiles()?.forEach(File::deleteRecursively)
+ check(stagingDirectory.mkdirs() || stagingDirectory.isDirectory) {
+ "Failed to create model import staging directory"
+ }
+ val required = manifest.totalSizeBytes + manifest.safetyMarginBytes
+ val available = usableSpace(root)
+ check(available >= required) {
+ "Not enough storage: ${formatBytes(required)} required, ${formatBytes(available)} available"
+ }
+ }
+
fun stagingFile(entry: SpeechModelFile): File = File(stagingDirectory, entry.name)
fun temporaryFile(entry: SpeechModelFile): File =
@@ -85,20 +139,53 @@ class SpeechModelRepository(
}
}
+ fun acceptTemporaryFile(entry: SpeechModelFile): Result = runCatching {
+ val temporary = temporaryFile(entry)
+ verifyFile(temporary, entry).getOrThrow()
+ val destination = stagingFile(entry)
+ destination.delete()
+ check(temporary.renameTo(destination)) { "Failed to accept ${entry.name}" }
+ destination
+ }
+
fun activate(): Result = runCatching {
+ recoverInterruptedActivation()
val validation = validateDirectory(stagingDirectory)
check(validation is InstalledSpeechModelState.Ready) {
(validation as? InstalledSpeechModelState.Invalid)?.reason ?: "Staged model is incomplete"
}
- if (installedDirectory.exists()) {
- installedDirectory.deleteRecursively()
- }
installedRoot.mkdirs()
- check(stagingDirectory.renameTo(installedDirectory)) {
- "Failed to activate downloaded model"
+ backupDirectory.deleteRecursively()
+ val replacing = installedDirectory.exists()
+ activationMarker.writeText(if (replacing) MARKER_REPLACEMENT else MARKER_FRESH)
+ if (replacing) {
+ check(moveDirectory(installedDirectory, backupDirectory)) {
+ "Failed to back up installed model"
+ }
+ }
+ try {
+ check(moveDirectory(stagingDirectory, installedDirectory)) {
+ "Failed to activate staged model"
+ }
+ writeActiveVersion(manifest.version)
+ invalidModelFile.delete()
+ activationMarker.delete()
+ backupDirectory.deleteRecursively()
+ } catch (error: Throwable) {
+ installedDirectory.deleteRecursively()
+ if (replacing && backupDirectory.exists()) {
+ check(moveDirectory(backupDirectory, installedDirectory)) {
+ "Failed to restore previous speech model"
+ }
+ writeActiveVersion(manifest.version)
+ invalidModelFile.delete()
+ } else {
+ activeVersionFile.delete()
+ }
+ activationMarker.delete()
+ throw error
}
- writeActiveVersion(manifest.version)
installedRoot.listFiles()
?.filter { it.name != manifest.version }
@@ -116,12 +203,40 @@ class SpeechModelRepository(
fun cleanupStaleState() {
root.mkdirs()
+ recoverInterruptedActivation()
cleanupTemporaryFiles(root)
stagingRoot.listFiles()
?.filter { it.name != manifest.version }
?.forEach(File::deleteRecursively)
}
+ internal fun recoverInterruptedActivation() {
+ if (!activationMarker.isFile) {
+ if (backupDirectory.exists()) {
+ if (installedDirectory.exists()) {
+ backupDirectory.deleteRecursively()
+ } else if (moveDirectory(backupDirectory, installedDirectory)) {
+ writeActiveVersion(manifest.version)
+ }
+ }
+ return
+ }
+
+ val replacement = activationMarker.readText().trim() == MARKER_REPLACEMENT
+ if (replacement && backupDirectory.exists()) {
+ installedDirectory.deleteRecursively()
+ check(moveDirectory(backupDirectory, installedDirectory)) {
+ "Failed to recover previous speech model"
+ }
+ writeActiveVersion(manifest.version)
+ invalidModelFile.delete()
+ } else if (!replacement) {
+ installedDirectory.deleteRecursively()
+ activeVersionFile.delete()
+ }
+ activationMarker.delete()
+ }
+
private fun validateDirectory(directory: File): InstalledSpeechModelState {
if (!directory.isDirectory) {
return InstalledSpeechModelState.Missing
@@ -134,7 +249,10 @@ class SpeechModelRepository(
)
}
}
- return InstalledSpeechModelState.Ready(directory)
+ return InstalledSpeechModelState.Ready(
+ directory = directory,
+ verification = InstalledSpeechModelState.Ready.Verification.VERIFIED,
+ )
}
private fun isValidFile(file: File, entry: SpeechModelFile): Boolean =
@@ -157,6 +275,20 @@ class SpeechModelRepository(
}
}
+ private fun recordValidation(state: InstalledSpeechModelState): InstalledSpeechModelState {
+ when (state) {
+ is InstalledSpeechModelState.Ready -> invalidModelFile.delete()
+ is InstalledSpeechModelState.Invalid -> writeInvalidReason(state.reason)
+ InstalledSpeechModelState.Missing -> Unit
+ }
+ return state
+ }
+
+ private fun writeInvalidReason(reason: String) {
+ root.mkdirs()
+ invalidModelFile.writeText(reason)
+ }
+
private fun cleanupTemporaryFiles(directory: File) {
directory.listFiles()?.forEach { file ->
if (file.isDirectory) {
@@ -168,6 +300,8 @@ class SpeechModelRepository(
}
companion object {
+ private const val MARKER_REPLACEMENT = "replacement"
+ private const val MARKER_FRESH = "fresh"
fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
FileInputStream(file).use { input ->
diff --git a/app/src/main/java/me/maxistar/voiceinbox/StartupProcessingCoordinator.kt b/app/src/main/java/me/maxistar/voiceinbox/StartupProcessingCoordinator.kt
new file mode 100644
index 0000000..596710a
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/StartupProcessingCoordinator.kt
@@ -0,0 +1,148 @@
+package me.maxistar.voiceinbox
+
+import me.maxistar.voiceinbox.core.StartupProcessingDecision
+import me.maxistar.voiceinbox.core.StartupProcessingInput
+import me.maxistar.voiceinbox.core.StartupProcessingPolicy
+import me.maxistar.voiceinbox.core.StartupProcessingRules
+import me.maxistar.voiceinbox.core.SpeechModelInstallationState
+
+class StartupProcessingCoordinator private constructor(
+ private var stage: Stage,
+) {
+ private var startupScanGeneration: Long? = null
+ private var startupScanComplete = false
+ private var startupScanFailed = false
+ private var catalogReady = false
+ private var pendingCount = 0
+ private var failedCount = 0
+ private var folderReady = false
+ private var outputReady = false
+ private var modelReady = false
+ private var transcriptionStateKnown = false
+ private var transcriptionActive = false
+
+ constructor() : this(Stage.ACTIVE)
+
+ fun beginStartupScan(generation: Long): Boolean {
+ if (stage == Stage.COMPLETE) return false
+ startupScanGeneration = generation
+ startupScanComplete = false
+ startupScanFailed = false
+ catalogReady = false
+ pendingCount = 0
+ failedCount = 0
+ return true
+ }
+
+ fun onStartupCatalogReady(
+ generation: Long,
+ pendingCount: Int,
+ failedCount: Int,
+ ) {
+ if (generation != startupScanGeneration || stage == Stage.COMPLETE) return
+ startupScanComplete = true
+ startupScanFailed = false
+ catalogReady = true
+ this.pendingCount = pendingCount.coerceAtLeast(0)
+ this.failedCount = failedCount.coerceAtLeast(0)
+ }
+
+ fun onCatalogRefreshed(pendingCount: Int, failedCount: Int) {
+ if (!startupScanComplete || stage == Stage.COMPLETE) return
+ catalogReady = true
+ this.pendingCount = pendingCount.coerceAtLeast(0)
+ this.failedCount = failedCount.coerceAtLeast(0)
+ }
+
+ fun onStartupScanFailed(generation: Long) {
+ if (generation != startupScanGeneration || stage == Stage.COMPLETE) return
+ startupScanComplete = false
+ startupScanFailed = true
+ catalogReady = false
+ }
+
+ fun setFolderReady(ready: Boolean) {
+ folderReady = ready
+ }
+
+ fun setOutputReady(ready: Boolean) {
+ outputReady = ready
+ }
+
+ fun setModelReady(ready: Boolean) {
+ modelReady = ready
+ }
+
+ fun setTranscriptionState(known: Boolean, active: Boolean) {
+ if (transcriptionStateKnown && transcriptionActive && known && !active) {
+ catalogReady = false
+ }
+ transcriptionStateKnown = known
+ transcriptionActive = active
+ }
+
+ fun evaluate(policy: StartupProcessingPolicy): StartupProcessingDecision {
+ if (stage == Stage.COMPLETE || stage == Stage.PROMPTING) {
+ return StartupProcessingDecision.Wait
+ }
+ val decision = StartupProcessingRules.decide(
+ StartupProcessingInput(
+ policy = if (stage == Stage.CONFIRMED) StartupProcessingPolicy.AUTOMATIC else policy,
+ startupScanComplete = startupScanComplete,
+ startupScanFailed = startupScanFailed,
+ catalogReady = catalogReady,
+ pendingCount = pendingCount,
+ failedCount = failedCount,
+ folderReady = folderReady,
+ outputReady = outputReady,
+ modelInstallationState = if (modelReady) {
+ SpeechModelInstallationState.INSTALLED
+ } else {
+ SpeechModelInstallationState.NOT_INSTALLED
+ },
+ transcriptionStateKnown = transcriptionStateKnown,
+ transcriptionActive = transcriptionActive,
+ ),
+ )
+ when (decision) {
+ is StartupProcessingDecision.Prompt -> stage = Stage.PROMPTING
+ is StartupProcessingDecision.Start,
+ is StartupProcessingDecision.Finish,
+ -> stage = Stage.COMPLETE
+ StartupProcessingDecision.Wait -> Unit
+ }
+ return decision
+ }
+
+ fun confirmPrompt(): Boolean {
+ if (stage != Stage.PROMPTING) return false
+ stage = Stage.CONFIRMED
+ return true
+ }
+
+ fun declinePrompt(): Boolean {
+ if (stage != Stage.PROMPTING) return false
+ stage = Stage.COMPLETE
+ return true
+ }
+
+ fun savedStage(): String = stage.name
+
+ fun isComplete(): Boolean = stage == Stage.COMPLETE
+
+ companion object {
+ fun restore(savedStage: String?): StartupProcessingCoordinator =
+ StartupProcessingCoordinator(
+ savedStage
+ ?.let { stored -> Stage.entries.firstOrNull { it.name == stored } }
+ ?: Stage.ACTIVE,
+ )
+ }
+
+ private enum class Stage {
+ ACTIVE,
+ PROMPTING,
+ CONFIRMED,
+ COMPLETE,
+ }
+}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/StartupProcessingDialogFragment.kt b/app/src/main/java/me/maxistar/voiceinbox/StartupProcessingDialogFragment.kt
new file mode 100644
index 0000000..be337b1
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/StartupProcessingDialogFragment.kt
@@ -0,0 +1,71 @@
+package me.maxistar.voiceinbox
+
+import android.app.Dialog
+import android.content.Context
+import android.content.DialogInterface
+import android.os.Bundle
+import android.widget.CheckBox
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatDialogFragment
+
+class StartupProcessingDialogFragment : AppCompatDialogFragment() {
+ private lateinit var listener: Listener
+ private var callbackDelivered = false
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ listener = context as? Listener
+ ?: error("Host Activity must implement StartupProcessingDialogFragment.Listener")
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val pendingCount = requireArguments().getInt(ARG_PENDING_COUNT)
+ val rememberChoice = CheckBox(requireContext()).apply {
+ text = getString(R.string.startup_processing_remember_choice)
+ }
+ return AlertDialog.Builder(requireContext())
+ .setTitle(R.string.startup_processing_prompt_title)
+ .setMessage(
+ resources.getQuantityString(
+ R.plurals.startup_processing_prompt_message,
+ pendingCount,
+ pendingCount,
+ ),
+ )
+ .setView(rememberChoice)
+ .setPositiveButton(R.string.startup_processing_process_now) { _, _ ->
+ callbackDelivered = true
+ listener.onStartupProcessingConfirmed(rememberChoice.isChecked)
+ }
+ .setNegativeButton(R.string.startup_processing_leave_queued) { _, _ ->
+ callbackDelivered = true
+ listener.onStartupProcessingDeclined(rememberChoice.isChecked)
+ }
+ .create()
+ }
+
+ override fun onCancel(dialog: DialogInterface) {
+ super.onCancel(dialog)
+ if (!callbackDelivered) {
+ callbackDelivered = true
+ listener.onStartupProcessingDeclined(remember = false)
+ }
+ }
+
+ interface Listener {
+ fun onStartupProcessingConfirmed(remember: Boolean)
+ fun onStartupProcessingDeclined(remember: Boolean)
+ }
+
+ companion object {
+ const val TAG = "startup-processing-prompt"
+ private const val ARG_PENDING_COUNT = "pending-count"
+
+ fun newInstance(pendingCount: Int): StartupProcessingDialogFragment =
+ StartupProcessingDialogFragment().apply {
+ arguments = Bundle().apply {
+ putInt(ARG_PENDING_COUNT, pendingCount)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/StartupProcessingPolicyStore.kt b/app/src/main/java/me/maxistar/voiceinbox/StartupProcessingPolicyStore.kt
new file mode 100644
index 0000000..159bc51
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/StartupProcessingPolicyStore.kt
@@ -0,0 +1,56 @@
+package me.maxistar.voiceinbox
+
+import android.content.SharedPreferences
+import me.maxistar.voiceinbox.core.StartupProcessingPolicy
+
+interface StartupProcessingPolicyStorage {
+ fun loadRaw(): String?
+ fun saveRaw(value: String)
+}
+
+class StartupProcessingPolicyStore(
+ private val storage: StartupProcessingPolicyStorage,
+) {
+ constructor(preferences: SharedPreferences) : this(
+ SharedPreferencesStartupProcessingPolicyStorage(preferences),
+ )
+
+ fun load(): StartupProcessingPolicy =
+ when (storage.loadRaw()) {
+ VALUE_AUTOMATIC -> StartupProcessingPolicy.AUTOMATIC
+ VALUE_LEAVE_QUEUED -> StartupProcessingPolicy.LEAVE_QUEUED
+ VALUE_ASK -> StartupProcessingPolicy.ASK
+ else -> StartupProcessingPolicy.ASK
+ }
+
+ fun save(policy: StartupProcessingPolicy) {
+ storage.saveRaw(
+ when (policy) {
+ StartupProcessingPolicy.ASK -> VALUE_ASK
+ StartupProcessingPolicy.AUTOMATIC -> VALUE_AUTOMATIC
+ StartupProcessingPolicy.LEAVE_QUEUED -> VALUE_LEAVE_QUEUED
+ },
+ )
+ }
+
+ companion object {
+ const val PREFERENCES_NAME = "startup_processing"
+ private const val VALUE_ASK = "ask"
+ private const val VALUE_AUTOMATIC = "automatic"
+ private const val VALUE_LEAVE_QUEUED = "leave_queued"
+ }
+}
+
+private class SharedPreferencesStartupProcessingPolicyStorage(
+ private val preferences: SharedPreferences,
+) : StartupProcessingPolicyStorage {
+ override fun loadRaw(): String? = preferences.getString(KEY_POLICY, null)
+
+ override fun saveRaw(value: String) {
+ preferences.edit().putString(KEY_POLICY, value).apply()
+ }
+
+ companion object {
+ private const val KEY_POLICY = "startup_processing_policy"
+ }
+}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt b/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt
new file mode 100644
index 0000000..787eec7
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt
@@ -0,0 +1,351 @@
+package me.maxistar.voiceinbox
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.core.view.isVisible
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.button.MaterialButton
+import me.maxistar.voiceinbox.core.AudioTaskPresentation
+import me.maxistar.voiceinbox.core.SetupTaskPresentation
+import me.maxistar.voiceinbox.core.TaskActionKind
+import me.maxistar.voiceinbox.core.TaskActionPresentation
+import me.maxistar.voiceinbox.core.TaskListState
+import me.maxistar.voiceinbox.core.TaskPresentation
+import me.maxistar.voiceinbox.core.TaskProgressPresentation
+
+sealed class TaskListDisplayItem {
+ abstract val stableKey: String
+
+ data class Setup(
+ val task: SetupTaskPresentation,
+ ) : TaskListDisplayItem() {
+ override val stableKey: String = task.stableId
+ }
+
+ data class Audio(
+ val task: AudioTaskPresentation,
+ ) : TaskListDisplayItem() {
+ override val stableKey: String = task.stableId
+ }
+
+ data class BatchAction(
+ val eligibleCount: Int,
+ val enabled: Boolean,
+ ) : TaskListDisplayItem() {
+ override val stableKey: String = STABLE_KEY
+
+ companion object {
+ const val STABLE_KEY = "action:transcribe-all"
+ }
+ }
+
+ data class Empty(
+ val message: String,
+ val actions: List,
+ ) : TaskListDisplayItem() {
+ override val stableKey: String = STABLE_KEY
+
+ companion object {
+ const val STABLE_KEY = "state:empty"
+ }
+ }
+}
+
+object TaskListDisplayItems {
+ fun from(state: TaskListState): List {
+ val emptyMessage = state.emptyMessage
+ if (emptyMessage != null) {
+ return listOf(TaskListDisplayItem.Empty(emptyMessage, state.emptyActions))
+ }
+ return buildList {
+ state.tasks.filterIsInstance().forEach {
+ add(TaskListDisplayItem.Setup(it))
+ }
+ if (state.batchAction.visible) {
+ add(
+ TaskListDisplayItem.BatchAction(
+ eligibleCount = state.batchAction.eligibleCount,
+ enabled = state.batchAction.enabled,
+ ),
+ )
+ }
+ state.tasks.filterIsInstance().forEach {
+ add(TaskListDisplayItem.Audio(it))
+ }
+ }
+ }
+}
+
+object TaskListDisplayItemDiff : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: TaskListDisplayItem, newItem: TaskListDisplayItem): Boolean =
+ oldItem::class == newItem::class && oldItem.stableKey == newItem.stableKey
+
+ override fun areContentsTheSame(oldItem: TaskListDisplayItem, newItem: TaskListDisplayItem): Boolean =
+ oldItem == newItem
+
+ override fun getChangePayload(oldItem: TaskListDisplayItem, newItem: TaskListDisplayItem): Any? =
+ progressOnlyPayload(oldItem, newItem)
+
+ fun progressOnlyPayload(
+ oldItem: TaskListDisplayItem,
+ newItem: TaskListDisplayItem,
+ ): TaskListChangePayload.Progress? = when {
+ oldItem is TaskListDisplayItem.Setup && newItem is TaskListDisplayItem.Setup &&
+ oldItem.task.progress != newItem.task.progress &&
+ oldItem.task.copy(progress = null) == newItem.task.copy(progress = null) ->
+ TaskListChangePayload.Progress(newItem.task.progress)
+ oldItem is TaskListDisplayItem.Audio && newItem is TaskListDisplayItem.Audio &&
+ oldItem.task.progress != newItem.task.progress &&
+ oldItem.task.copy(progress = null) == newItem.task.copy(progress = null) ->
+ TaskListChangePayload.Progress(newItem.task.progress)
+ else -> null
+ }
+}
+
+sealed class TaskListChangePayload {
+ data class Progress(val value: TaskProgressPresentation?) : TaskListChangePayload()
+}
+
+class TaskListAdapter(
+ private val onAction: (AndroidTaskActionRequest) -> Unit,
+) : ListAdapter(TaskListDisplayItemDiff) {
+ init {
+ setHasStableIds(true)
+ }
+
+ override fun getItemId(position: Int): Long = stableLongId(getItem(position).stableKey)
+
+ override fun getItemViewType(position: Int): Int = when (getItem(position)) {
+ is TaskListDisplayItem.Setup -> VIEW_SETUP
+ is TaskListDisplayItem.Audio -> VIEW_AUDIO
+ is TaskListDisplayItem.BatchAction -> VIEW_BATCH
+ is TaskListDisplayItem.Empty -> VIEW_EMPTY
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ return when (viewType) {
+ VIEW_SETUP -> SetupTaskViewHolder(inflater.inflate(R.layout.row_task, parent, false), onAction)
+ VIEW_AUDIO -> AudioTaskViewHolder(inflater.inflate(R.layout.row_task, parent, false), onAction)
+ VIEW_BATCH -> BatchActionViewHolder(
+ inflater.inflate(R.layout.row_batch_action, parent, false),
+ onAction,
+ )
+ VIEW_EMPTY -> EmptyTaskViewHolder(
+ inflater.inflate(R.layout.row_empty_task, parent, false),
+ onAction,
+ )
+ else -> error("Unknown task-list view type: $viewType")
+ }
+ }
+
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+ when (val item = getItem(position)) {
+ is TaskListDisplayItem.Setup -> (holder as SetupTaskViewHolder).bind(item.task)
+ is TaskListDisplayItem.Audio -> (holder as AudioTaskViewHolder).bind(item.task)
+ is TaskListDisplayItem.BatchAction -> (holder as BatchActionViewHolder).bind(item)
+ is TaskListDisplayItem.Empty -> (holder as EmptyTaskViewHolder).bind(item)
+ }
+ }
+
+ override fun onBindViewHolder(
+ holder: RecyclerView.ViewHolder,
+ position: Int,
+ payloads: MutableList,
+ ) {
+ val progressPayload = payloads.filterIsInstance().lastOrNull()
+ if (progressPayload != null && payloads.all { it is TaskListChangePayload.Progress }) {
+ when (holder) {
+ is SetupTaskViewHolder -> holder.bindProgress(progressPayload.value)
+ is AudioTaskViewHolder -> holder.bindProgress(progressPayload.value)
+ else -> onBindViewHolder(holder, position)
+ }
+ } else {
+ onBindViewHolder(holder, position)
+ }
+ }
+
+ private class SetupTaskViewHolder(
+ itemView: View,
+ onAction: (AndroidTaskActionRequest) -> Unit,
+ ) : TaskViewHolder(itemView, onAction) {
+ fun bind(task: SetupTaskPresentation) = bindTask(task, entryId = null)
+ }
+
+ private class AudioTaskViewHolder(
+ itemView: View,
+ onAction: (AndroidTaskActionRequest) -> Unit,
+ ) : TaskViewHolder(itemView, onAction) {
+ fun bind(task: AudioTaskPresentation) = bindTask(task, entryId = task.entryId)
+ }
+
+ private open class TaskViewHolder(
+ itemView: View,
+ private val onAction: (AndroidTaskActionRequest) -> Unit,
+ ) : RecyclerView.ViewHolder(itemView) {
+ private val title: TextView = itemView.findViewById(R.id.taskTitle)
+ private val badge: TextView = itemView.findViewById(R.id.taskBadge)
+ private val detail: TextView = itemView.findViewById(R.id.taskDetail)
+ private val progressPhase: TextView = itemView.findViewById(R.id.taskProgressPhase)
+ private val progress: ProgressBar = itemView.findViewById(R.id.taskProgress)
+ private val progressMeta: TextView = itemView.findViewById(R.id.taskProgressMeta)
+ private val error: TextView = itemView.findViewById(R.id.taskError)
+ private val actions: LinearLayout = itemView.findViewById(R.id.taskActions)
+ private var boundActionOwner: Pair? = null
+ private var boundActions: List? = null
+
+ protected fun bindTask(task: TaskPresentation, entryId: Long?) {
+ itemView.contentDescription = task.title
+ title.text = task.title
+ badge.text = task.badge
+ bindOptional(detail, task.detail)
+ bindProgress(task.progress)
+ bindOptional(error, task.errorMessage)
+ bindActions(task, entryId)
+ }
+
+ fun bindProgress(value: TaskProgressPresentation?) {
+ progressPhase.isVisible = value != null
+ progress.isVisible = value != null
+ if (value == null) {
+ progressMeta.isVisible = false
+ return
+ }
+ progressPhase.text = value.phase
+ progress.isIndeterminate = value.percent == null
+ progress.progress = value.percent ?: 0
+ bindOptional(progressMeta, progressMetadata(value))
+ }
+
+ private fun bindActions(task: TaskPresentation, entryId: Long?) {
+ val owner = task.stableId to entryId
+ if (boundActionOwner == owner && boundActions == task.actions) return
+ boundActionOwner = owner
+ boundActions = task.actions
+ actions.removeAllViews()
+ task.actions.forEachIndexed { index, action ->
+ actions.addView(
+ MaterialButton(actions.context).apply {
+ text = action.label
+ isEnabled = action.enabled
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ).apply {
+ if (index > 0) marginStart = actionSpacing(actions)
+ }
+ setOnClickListener {
+ onAction(AndroidTaskActionRequest(task.stableId, entryId, action.kind))
+ }
+ },
+ )
+ }
+ actions.isVisible = task.actions.isNotEmpty()
+ }
+ }
+
+ private class BatchActionViewHolder(
+ itemView: View,
+ private val onAction: (AndroidTaskActionRequest) -> Unit,
+ ) : RecyclerView.ViewHolder(itemView) {
+ private val button: MaterialButton = itemView.findViewById(R.id.batchAction)
+
+ fun bind(item: TaskListDisplayItem.BatchAction) {
+ button.text = itemView.resources.getQuantityString(
+ R.plurals.transcribe_all_count,
+ item.eligibleCount,
+ item.eligibleCount,
+ )
+ button.isEnabled = item.enabled
+ button.setOnClickListener {
+ onAction(
+ AndroidTaskActionRequest(
+ stableId = item.stableKey,
+ entryId = null,
+ kind = TaskActionKind.TRANSCRIBE_ALL,
+ ),
+ )
+ }
+ }
+ }
+
+ private class EmptyTaskViewHolder(
+ itemView: View,
+ private val onAction: (AndroidTaskActionRequest) -> Unit,
+ ) : RecyclerView.ViewHolder(itemView) {
+ private val message: TextView = itemView.findViewById(R.id.emptyMessage)
+ private val actions: LinearLayout = itemView.findViewById(R.id.emptyActions)
+
+ fun bind(item: TaskListDisplayItem.Empty) {
+ message.text = item.message
+ actions.removeAllViews()
+ item.actions.forEachIndexed { index, action ->
+ actions.addView(
+ MaterialButton(actions.context).apply {
+ text = action.label
+ isEnabled = action.enabled
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ).apply {
+ if (index > 0) topMargin = actionSpacing(actions)
+ }
+ setOnClickListener {
+ onAction(AndroidTaskActionRequest(item.stableKey, null, action.kind))
+ }
+ },
+ )
+ }
+ actions.isVisible = item.actions.isNotEmpty()
+ }
+ }
+
+ companion object {
+ const val VIEW_SETUP = 1
+ const val VIEW_AUDIO = 2
+ const val VIEW_BATCH = 3
+ const val VIEW_EMPTY = 4
+
+ fun stableLongId(value: String): Long {
+ var hash = -0x340d631b8c46753bL
+ value.forEach { character ->
+ hash = hash xor character.code.toLong()
+ hash *= 0x100000001b3L
+ }
+ return hash
+ }
+
+ private fun bindOptional(view: TextView, value: String?) {
+ view.text = value.orEmpty()
+ view.isVisible = !value.isNullOrBlank()
+ }
+
+ private fun actionSpacing(view: View): Int =
+ (8 * view.resources.displayMetrics.density).toInt()
+
+ private fun progressMetadata(progress: TaskProgressPresentation): String? = buildList {
+ progress.percent?.let { add("$it%") }
+ val totalFiles = progress.totalFiles
+ if (totalFiles != null && totalFiles > 0) {
+ add("${progress.completedFiles ?: 0}/$totalFiles files")
+ }
+ progress.failedFiles?.takeIf { it > 0 }?.let { add("$it failed") }
+ val processedUs = progress.processedUs
+ val durationUs = progress.durationUs
+ if (processedUs != null && durationUs != null && durationUs > 0) {
+ add("${formatDuration(processedUs)}/${formatDuration(durationUs)}")
+ }
+ }.takeIf(List::isNotEmpty)?.joinToString(" • ")
+
+ private fun formatDuration(microseconds: Long): String {
+ val totalSeconds = microseconds.coerceAtLeast(0) / 1_000_000
+ return "%d:%02d".format(totalSeconds / 60, totalSeconds % 60)
+ }
+ }
+}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/TranscriptionWorker.kt b/app/src/main/java/me/maxistar/voiceinbox/TranscriptionWorker.kt
index a140343..8df6e49 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/TranscriptionWorker.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/TranscriptionWorker.kt
@@ -25,7 +25,6 @@ class TranscriptionWorker(
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val folderUri = inputData.getString(KEY_FOLDER_URI)
- ?: return@withContext failure("Audio folder is missing")
val outputUri = inputData.getString(KEY_OUTPUT_URI)?.let(Uri::parse)
?: return@withContext failure("Output file is missing")
val retryId = inputData.getLong(KEY_RETRY_ID, NO_RETRY_ID).takeIf { it != NO_RETRY_ID }
@@ -34,14 +33,13 @@ class TranscriptionWorker(
try {
setForeground(foreground("Preparing transcription", 0, true))
- val model = SpeechModelRepository(
+ val modelRepository = SpeechModelRepository(
applicationContext.noBackupFilesDir.resolve("models"),
- ).inspect() as? InstalledSpeechModelState.Ready
- ?: return@withContext failure("Speech model is not installed")
- publish("Loading model", null, 0, 0, null, null)
- if (!NativeTranscriptionBridge.initialize(model.directory.absolutePath)) {
- return@withContext failure("Speech model failed to load")
- }
+ )
+ publish("Preparing speech model", null, null, 0, 0, null, null)
+ SpeechModelPreparation.prepare(modelRepository) { directory ->
+ NativeTranscriptionBridge.initialize(directory.absolutePath)
+ }.getOrElse { return@withContext failure(it.message ?: "Speech model preparation failed") }
val batch = BatchTranscriptionUseCase(
catalog = catalog,
@@ -53,7 +51,9 @@ class TranscriptionWorker(
)
val result = batch.transcribe(
BatchTranscriptionInput(
- folderId = folderUri,
+ sourceScope = AudioCatalogSourceScope.of(
+ listOfNotNull(AndroidAudioImportConstants.SOURCE_ID, folderUri),
+ ),
outputId = outputUri.toString(),
runId = id.toString(),
retryEntryId = retryId,
@@ -81,6 +81,7 @@ class TranscriptionWorker(
private suspend fun publish(
phase: String,
+ activeEntryId: Long?,
filename: String?,
completed: Int,
total: Int,
@@ -90,6 +91,7 @@ class TranscriptionWorker(
val percent = BatchTranscriptionRules.percent(processedUs, durationUs)
val data = progressData(
phase,
+ activeEntryId,
filename,
completed,
total,
@@ -105,6 +107,7 @@ class TranscriptionWorker(
private fun publishAsync(progress: BatchTranscriptionProgress) {
publishAsync(
phase = progress.phase,
+ activeEntryId = progress.activeEntryId,
filename = progress.filename,
completed = progress.completed,
total = progress.total,
@@ -117,6 +120,7 @@ class TranscriptionWorker(
private fun publishAsync(
phase: String,
+ activeEntryId: Long?,
filename: String?,
completed: Int,
total: Int,
@@ -127,6 +131,7 @@ class TranscriptionWorker(
) {
val data = progressData(
phase,
+ activeEntryId,
filename,
completed,
total,
@@ -147,6 +152,7 @@ class TranscriptionWorker(
private fun progressData(
phase: String,
+ activeEntryId: Long?,
filename: String?,
completed: Int,
total: Int,
@@ -156,6 +162,7 @@ class TranscriptionWorker(
progress: Int?,
) = workDataOf(
KEY_PHASE to phase,
+ KEY_ACTIVE_ENTRY_ID to (activeEntryId ?: NO_ENTRY_ID),
KEY_FILENAME to filename,
KEY_COMPLETED_FILES to completed,
KEY_TOTAL_FILES to total,
@@ -238,6 +245,7 @@ class TranscriptionWorker(
const val KEY_OUTPUT_URI = "output-uri"
const val KEY_RETRY_ID = "retry-id"
const val KEY_PHASE = "phase"
+ const val KEY_ACTIVE_ENTRY_ID = "active-entry-id"
const val KEY_FILENAME = "filename"
const val KEY_COMPLETED_FILES = "completed-files"
const val KEY_TOTAL_FILES = "total-files"
@@ -249,33 +257,40 @@ class TranscriptionWorker(
const val KEY_ERROR = "error"
private const val NO_RETRY_ID = -1L
+ const val NO_ENTRY_ID = -1L
private const val NOTIFICATION_CHANNEL = "audio-transcription"
private const val NOTIFICATION_ID = 2109
- fun enqueueAll(context: Context, folderUri: Uri, outputUri: Uri): UUID =
+ fun enqueueAll(context: Context, folderUri: Uri?, outputUri: Uri): UUID =
enqueue(context, folderUri, outputUri, null)
fun enqueueRetry(
context: Context,
- folderUri: Uri,
+ folderUri: Uri?,
+ outputUri: Uri,
+ entryId: Long,
+ ): UUID = enqueue(context, folderUri, outputUri, entryId)
+
+ fun enqueueEntry(
+ context: Context,
+ folderUri: Uri?,
outputUri: Uri,
entryId: Long,
): UUID = enqueue(context, folderUri, outputUri, entryId)
private fun enqueue(
context: Context,
- folderUri: Uri,
+ folderUri: Uri?,
outputUri: Uri,
retryId: Long?,
): UUID {
+ val input = androidx.work.Data.Builder()
+ .putString(KEY_OUTPUT_URI, outputUri.toString())
+ .putLong(KEY_RETRY_ID, retryId ?: NO_RETRY_ID)
+ .apply { folderUri?.let { putString(KEY_FOLDER_URI, it.toString()) } }
+ .build()
val request = OneTimeWorkRequestBuilder()
- .setInputData(
- workDataOf(
- KEY_FOLDER_URI to folderUri.toString(),
- KEY_OUTPUT_URI to outputUri.toString(),
- KEY_RETRY_ID to (retryId ?: NO_RETRY_ID),
- ),
- )
+ .setInputData(input)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
UNIQUE_WORK_NAME,
diff --git a/app/src/main/res/drawable/ic_add_24.xml b/app/src/main/res/drawable/ic_add_24.xml
new file mode 100644
index 0000000..87a850f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_add_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/layout/action_refresh_folder.xml b/app/src/main/res/layout/action_refresh_folder.xml
new file mode 100644
index 0000000..31cc3d4
--- /dev/null
+++ b/app/src/main/res/layout/action_refresh_folder.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 02ac805..19fd10c 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,141 +1,77 @@
-
+ android:layout_height="match_parent">
-
+ android:layout_height="match_parent"
+ android:orientation="vertical">
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ android:layout_weight="1"
+ android:text="@string/new_label" />
-
+ android:layout_weight="1"
+ android:text="@string/processed" />
-
+ android:layout_weight="1"
+ android:text="@string/all_label" />
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml
index f9391d7..aae5355 100644
--- a/app/src/main/res/layout/activity_settings.xml
+++ b/app/src/main/res/layout/activity_settings.xml
@@ -80,6 +80,54 @@
android:text="@string/output_not_selected" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/row_empty_task.xml b/app/src/main/res/layout/row_empty_task.xml
new file mode 100644
index 0000000..bcd0316
--- /dev/null
+++ b/app/src/main/res/layout/row_empty_task.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/row_task.xml b/app/src/main/res/layout/row_task.xml
new file mode 100644
index 0000000..8d45d33
--- /dev/null
+++ b/app/src/main/res/layout/row_task.xml
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/main_options.xml b/app/src/main/res/menu/main_options.xml
index 4d40672..d394fcf 100644
--- a/app/src/main/res/menu/main_options.xml
+++ b/app/src/main/res/menu/main_options.xml
@@ -5,7 +5,8 @@
android:id="@+id/menuRefreshFolder"
android:icon="@drawable/ic_refresh_24"
android:title="@string/menu_refresh_folder"
- app:showAsAction="ifRoom" />
+ app:actionLayout="@layout/action_refresh_folder"
+ app:showAsAction="always" />
- Select audio folder
Change audio folder
Refresh
+ Audio folder refresh is idle
+ Refreshing audio folder
Settings
Settings
Storage
@@ -20,16 +22,43 @@
Scheduled time: %1$02d:%2$02d
Change scheduled time
Android may delay background work to save battery. The feature is off by default.
+ Startup processing
+ Choose what happens when Voice Inbox opens and finds queued notes.
+ Ask every time
+ Transcribe automatically
+ Leave files queued
+ Startup processing runs only when you open the app. Nightly transcription is a separate background option.
+ Process queued notes?
+
+ - %1$d file is ready to transcribe.
+ - %1$d files are ready to transcribe.
+
+ Always do this when Voice Inbox starts
+ Process now
+ Leave queued
Output: not selected
Output: %1$s
+ Output: restoring selection…
+ Output: temporarily unavailable
Select output file
Audio folder: not selected
Audio folder: %1$s
+ Audio folder: restoring selection…
+ Audio folder: temporarily unavailable
Select audio folder
+ Import audio
+ Importing audio…
+ No readable audio was shared.
Checking speech model
Download speech model
+ Install from folder
New
Processed
+ All
No new audio files
Transcribe all
+
+ - Transcribe all (%1$d file)
+ - Transcribe all (%1$d files)
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 4b3968d..99935dd 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -6,4 +6,9 @@
-
\ No newline at end of file
+
+
+
diff --git a/app/src/main/res/xml/imported_audio_paths.xml b/app/src/main/res/xml/imported_audio_paths.xml
new file mode 100644
index 0000000..34aceee
--- /dev/null
+++ b/app/src/main/res/xml/imported_audio_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidFolderSyncCoordinatorTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidFolderSyncCoordinatorTest.kt
new file mode 100644
index 0000000..a2538a7
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidFolderSyncCoordinatorTest.kt
@@ -0,0 +1,75 @@
+package me.maxistar.voiceinbox
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class AndroidFolderSyncCoordinatorTest {
+ @Test
+ fun oneGenerationTraversesTheCompletePipeline() {
+ val coordinator = AndroidFolderSyncCoordinator()
+
+ val generation = coordinator.begin()
+ assertEquals(AndroidFolderSyncPhase.VALIDATING, coordinator.state.phase)
+ assertTrue(coordinator.transition(generation, AndroidFolderSyncPhase.QUEUED))
+ assertTrue(coordinator.transition(generation, AndroidFolderSyncPhase.SCANNING))
+ assertTrue(coordinator.transition(generation, AndroidFolderSyncPhase.REFRESHING_CATALOG))
+ assertTrue(coordinator.complete(generation))
+ assertEquals(AndroidFolderSyncPhase.IDLE, coordinator.state.phase)
+ }
+
+ @Test
+ fun newerGenerationRejectsStaleCallbacks() {
+ val coordinator = AndroidFolderSyncCoordinator()
+ val stale = coordinator.begin()
+ val current = coordinator.begin(AndroidFolderSyncPhase.SCANNING)
+
+ assertFalse(coordinator.transition(stale, AndroidFolderSyncPhase.REFRESHING_CATALOG))
+ assertFalse(coordinator.complete(stale))
+ assertTrue(coordinator.isCurrent(current))
+ assertEquals(AndroidFolderSyncPhase.SCANNING, coordinator.state.phase)
+ }
+
+ @Test
+ fun failureAndResetEndActivityAndInvalidateCallbacks() {
+ val coordinator = AndroidFolderSyncCoordinator()
+ val failed = coordinator.begin(AndroidFolderSyncPhase.SCANNING)
+ assertTrue(coordinator.fail(failed))
+ assertFalse(coordinator.state.active)
+
+ val reset = coordinator.begin()
+ coordinator.reset()
+ assertFalse(coordinator.isCurrent(reset))
+ assertFalse(coordinator.state.active)
+ }
+
+ @Test
+ fun everyActiveStageCanFailAndFastSyncCanFinishBeforeRendering() {
+ AndroidFolderSyncPhase.entries
+ .filterNot { it == AndroidFolderSyncPhase.IDLE }
+ .forEach { phase ->
+ val coordinator = AndroidFolderSyncCoordinator()
+ val generation = coordinator.begin(phase)
+
+ assertTrue(coordinator.fail(generation))
+ assertEquals(AndroidFolderSyncPhase.IDLE, coordinator.state.phase)
+ }
+
+ val coordinator = AndroidFolderSyncCoordinator()
+ val fast = coordinator.begin()
+ assertTrue(coordinator.complete(fast))
+ assertFalse(coordinator.state.active)
+ }
+
+ @Test
+ fun duplicateRequestSupersedesPriorGenerationWithoutCompletingTheNewOne() {
+ val coordinator = AndroidFolderSyncCoordinator()
+ val first = coordinator.begin(AndroidFolderSyncPhase.QUEUED)
+ val duplicate = coordinator.begin(AndroidFolderSyncPhase.QUEUED)
+
+ assertFalse(coordinator.complete(first))
+ assertTrue(coordinator.isCurrent(duplicate))
+ assertEquals(AndroidFolderSyncPhase.QUEUED, coordinator.state.phase)
+ }
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt
new file mode 100644
index 0000000..1230776
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt
@@ -0,0 +1,284 @@
+package me.maxistar.voiceinbox
+
+import androidx.lifecycle.SavedStateHandle
+import me.maxistar.voiceinbox.core.AudioCatalogEntry
+import me.maxistar.voiceinbox.core.AudioFileFingerprint
+import me.maxistar.voiceinbox.core.AudioFileState
+import me.maxistar.voiceinbox.core.AudioTaskPresentation
+import me.maxistar.voiceinbox.core.AudioTaskState
+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.PreviewPlaybackState
+import me.maxistar.voiceinbox.core.PreviewTaskSnapshot
+import me.maxistar.voiceinbox.core.SetupTaskPresentation
+import me.maxistar.voiceinbox.core.TaskActionKind
+import me.maxistar.voiceinbox.core.TaskListFilter
+import me.maxistar.voiceinbox.core.TranscriptionTaskSnapshot
+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 AndroidMainScreenStateHostTest {
+ @Test
+ fun mapperPreservesSetupAndSourceAwareModelProgress() {
+ val state = AndroidTaskListSnapshotMapper.state(
+ AndroidMainScreenInput(
+ model = ModelSetupSnapshot(
+ state = ModelSetupSnapshotState.INSTALLING,
+ detail = "Installing from selected folder",
+ installationPhase = "Verifying local model",
+ progressPercent = 63,
+ ),
+ output = OutputSetupSnapshot(OutputSetupSnapshotState.INVALID, "Output unavailable"),
+ folder = FolderSetupSnapshot(FolderSetupSnapshotState.SCANNING, "Checking recordings"),
+ hydration = hydrated(),
+ ),
+ )
+
+ val tasks = state.taskList.tasks.filterIsInstance()
+ assertEquals(listOf("setup:model", "setup:output", "setup:folder"), tasks.map { it.stableId })
+ assertEquals("Verifying local model", tasks.first().progress?.phase)
+ assertEquals(63, tasks.first().progress?.percent)
+ assertEquals("Output unavailable", tasks[1].errorMessage)
+ assertEquals("Scanning audio folder", tasks[2].progress?.phase)
+ }
+
+ @Test
+ fun activeModelPhaseIsNotRepeatedAsTaskDetail() {
+ val message = "Downloading encoder-model.int8.onnx"
+ assertNull(androidModelTaskDetail(ModelSetupSnapshotState.INSTALLING, message))
+ assertEquals(message, androidModelTaskDetail(ModelSetupSnapshotState.REQUIRED, message))
+ assertEquals(message, androidModelTaskDetail(ModelSetupSnapshotState.INVALID, message))
+ val input = readyInput().copy(
+ model = ModelSetupSnapshot(
+ state = ModelSetupSnapshotState.INSTALLING,
+ detail = null,
+ installationPhase = message,
+ progressPercent = 42,
+ ),
+ )
+
+ val task = AndroidTaskListSnapshotMapper.state(input)
+ .taskList.tasks
+ .filterIsInstance()
+ .single { it.stableId == "setup:model" }
+
+ assertNull(task.detail)
+ assertEquals(message, task.progress?.phase)
+ }
+
+ @Test
+ fun mapperPreservesCatalogIdentitySourcesOrderingAndOutcomes() {
+ val entries = listOf(
+ entry(1, AudioFileState.PENDING, AndroidAudioImportConstants.SOURCE_ID, modified = 10),
+ entry(2, AudioFileState.PROCESSING, "content://folder", modified = 20),
+ entry(3, AudioFileState.PROCESSED, AndroidAudioImportConstants.SOURCE_ID, modified = 30, processed = 50, transcript = "hello"),
+ entry(4, AudioFileState.FAILED, "content://folder", modified = 40, processed = 60, error = "No speech detected"),
+ entry(5, AudioFileState.MISSING, "content://folder", modified = 70, error = "Missing"),
+ )
+ val state = AndroidTaskListSnapshotMapper.state(
+ readyInput(filter = TaskListFilter.ALL, entries = entries),
+ )
+
+ assertEquals(entries.associateBy { it.id }, state.entriesById)
+ val tasks = state.taskList.tasks.filterIsInstance()
+ assertEquals(listOf(5L, 4L, 3L, 2L, 1L), tasks.map { it.entryId })
+ assertEquals(AudioTaskState.NO_SPEECH, tasks.single { it.entryId == 4L }.state)
+ assertEquals(TaskActionKind.SHOW_TEXT, tasks.single { it.entryId == 3L }.actions.first().kind)
+ assertEquals("1 KiB", tasks.single { it.entryId == 1L }.detail)
+ }
+
+ @Test
+ fun mapperAssignsPreviewAndActiveTranscriptionToStableEntry() {
+ val state = AndroidTaskListSnapshotMapper.state(
+ readyInput(
+ entries = listOf(
+ entry(1, AudioFileState.PENDING, AndroidAudioImportConstants.SOURCE_ID, modified = 10),
+ entry(2, AudioFileState.PENDING, "content://folder", modified = 20),
+ ),
+ preview = PreviewTaskSnapshot(2, PreviewPlaybackState.PLAYING),
+ transcription = TranscriptionTaskSnapshot(
+ active = true,
+ activeEntryId = 1,
+ phase = "Transcribing",
+ percent = 25,
+ completedFiles = 0,
+ totalFiles = 2,
+ ),
+ ),
+ )
+
+ val tasks = state.taskList.tasks.filterIsInstance()
+ val active = tasks.single { it.entryId == 1L }
+ assertEquals(AudioTaskState.PROCESSING, active.state)
+ assertEquals("Transcribing", active.progress?.phase)
+ assertEquals(25, active.progress?.percent)
+ val preview = tasks.single { it.entryId == 2L }
+ assertEquals(TaskActionKind.STOP, preview.actions.last().kind)
+ assertTrue(preview.actions.last().enabled)
+ }
+
+ @Test
+ fun preClaimPreparationUsesDeterministicOldestEligibleEntry() {
+ val state = AndroidTaskListSnapshotMapper.state(
+ readyInput(
+ entries = listOf(
+ entry(1, AudioFileState.PENDING, AndroidAudioImportConstants.SOURCE_ID, modified = 100),
+ entry(2, AudioFileState.PENDING, "content://folder", modified = 50),
+ ),
+ transcription = TranscriptionTaskSnapshot(active = true, phase = "Preparing speech model"),
+ ),
+ )
+
+ val owner = state.taskList.tasks.filterIsInstance().single { it.progress != null }
+ assertEquals(2L, owner.entryId)
+ assertEquals(AudioTaskState.PROCESSING, owner.state)
+ }
+
+ @Test
+ fun stateHostPublishesImmutableUpdatesAndRestoresFilter() {
+ val savedState = SavedStateHandle()
+ val host = AndroidMainScreenStateHost(savedState)
+ assertEquals(TaskListFilter.NEW, host.state.value.taskList.filter)
+
+ host.replace(readyInput(filter = TaskListFilter.PROCESSED, entries = listOf(entry(1, AudioFileState.FAILED))))
+ val first = host.state.value
+ assertEquals(TaskListFilter.PROCESSED, first.taskList.filter)
+ assertEquals(listOf("audio:1"), first.taskList.tasks.map { it.stableId })
+
+ host.update { it.copy(entries = emptyList()) }
+ assertTrue(host.state.value.taskList.tasks.isEmpty())
+ assertEquals(listOf("audio:1"), first.taskList.tasks.map { it.stableId })
+
+ val recreated = AndroidMainScreenStateHost(savedState)
+ assertEquals(TaskListFilter.PROCESSED, recreated.state.value.taskList.filter)
+ }
+
+ @Test
+ fun completedSetupAndOptionalFolderAreHidden() {
+ val state = AndroidTaskListSnapshotMapper.state(readyInput())
+
+ assertTrue(state.taskList.tasks.isEmpty())
+ assertNull(state.taskList.tasks.filterIsInstance().firstOrNull())
+ assertTrue(state.taskList.emptyActions.any { it.kind == TaskActionKind.IMPORT_AUDIO })
+ }
+
+ @Test
+ fun pendingHydrationSuppressesFalseSetupAndFinalEmptyState() {
+ val state = AndroidTaskListSnapshotMapper.state(
+ AndroidMainScreenInput(
+ model = ModelSetupSnapshot(ModelSetupSnapshotState.REQUIRED),
+ output = OutputSetupSnapshot(OutputSetupSnapshotState.REQUIRED),
+ folder = FolderSetupSnapshot(FolderSetupSnapshotState.SCANNING),
+ hydration = AndroidMainScreenHydration(),
+ ),
+ )
+
+ assertTrue(state.taskList.tasks.isEmpty())
+ assertNull(state.taskList.emptyMessage)
+ assertTrue(state.taskList.emptyActions.isEmpty())
+ }
+
+ @Test
+ fun knownMissingAndInvalidSetupAppearBeforeCatalogHydration() {
+ val state = AndroidTaskListSnapshotMapper.state(
+ AndroidMainScreenInput(
+ model = ModelSetupSnapshot(ModelSetupSnapshotState.REQUIRED),
+ output = OutputSetupSnapshot(OutputSetupSnapshotState.INVALID, "Permission revoked"),
+ folder = FolderSetupSnapshot(FolderSetupSnapshotState.ERROR, "Folder unavailable"),
+ hydration = AndroidMainScreenHydration(
+ modelKnown = true,
+ outputKnown = true,
+ folderKnown = true,
+ ),
+ ),
+ )
+
+ assertEquals(
+ listOf("setup:model", "setup:output", "setup:folder"),
+ state.taskList.tasks.map { it.stableId },
+ )
+ assertNull(state.taskList.emptyMessage)
+ }
+
+ @Test
+ fun folderSyncPresentationIsRendererIndependentAndCopiedToState() {
+ val sync = AndroidFolderSyncPresentation(
+ visible = true,
+ active = true,
+ enabled = false,
+ accessibilityLabel = AndroidFolderSyncPresentation.ACCESSIBILITY_REFRESHING,
+ )
+ val state = AndroidTaskListSnapshotMapper.state(readyInput().copy(folderSync = sync))
+
+ assertEquals(sync, state.folderSync)
+ assertTrue(state.refreshFolderVisible)
+ assertFalse(state.refreshFolderEnabled)
+ }
+
+ @Test
+ fun hydratedCatalogPublishesEmptyStateAndRetainedEntriesRemainVisibleWhileRevalidating() {
+ val empty = AndroidTaskListSnapshotMapper.state(readyInput())
+ assertEquals("No new tasks", empty.taskList.emptyMessage)
+
+ val retained = AndroidTaskListSnapshotMapper.state(
+ readyInput(entries = listOf(entry(1, AudioFileState.PENDING))).copy(
+ hydration = AndroidMainScreenHydration(catalogKnown = true),
+ ),
+ )
+ assertEquals(listOf("audio:1"), retained.taskList.tasks.map { it.stableId })
+ assertNull(retained.taskList.emptyMessage)
+ }
+
+ private fun readyInput(
+ filter: TaskListFilter = TaskListFilter.NEW,
+ entries: List = emptyList(),
+ preview: PreviewTaskSnapshot = PreviewTaskSnapshot(),
+ transcription: TranscriptionTaskSnapshot = TranscriptionTaskSnapshot(),
+ ) = AndroidMainScreenInput(
+ filter = filter,
+ model = ModelSetupSnapshot(ModelSetupSnapshotState.READY),
+ output = OutputSetupSnapshot(OutputSetupSnapshotState.READY),
+ folder = FolderSetupSnapshot(FolderSetupSnapshotState.READY),
+ entries = entries,
+ preview = preview,
+ transcription = transcription,
+ transcriptionEligible = true,
+ hydration = hydrated(),
+ )
+
+ private fun hydrated() = AndroidMainScreenHydration(
+ modelKnown = true,
+ outputKnown = true,
+ folderKnown = true,
+ catalogKnown = true,
+ )
+
+ private fun entry(
+ id: Long,
+ state: AudioFileState,
+ source: String = AndroidAudioImportConstants.SOURCE_ID,
+ modified: Long = id,
+ processed: Long? = null,
+ error: String? = null,
+ transcript: String? = null,
+ ) = AudioCatalogEntry(
+ id = id,
+ folderUri = source,
+ documentUri = "content://audio/$id",
+ displayName = "$id.ogg",
+ mimeType = "audio/ogg",
+ fingerprint = AudioFileFingerprint(sizeBytes = 1024, modifiedMillis = modified),
+ state = state,
+ stateBeforeMissing = null,
+ lastError = error,
+ processedAtMillis = processed,
+ transcriptText = transcript,
+ )
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidTaskActionRouterTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidTaskActionRouterTest.kt
new file mode 100644
index 0000000..5ab31cb
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidTaskActionRouterTest.kt
@@ -0,0 +1,117 @@
+package me.maxistar.voiceinbox
+
+import me.maxistar.voiceinbox.core.AudioCatalogEntry
+import me.maxistar.voiceinbox.core.AudioFileFingerprint
+import me.maxistar.voiceinbox.core.AudioFileState
+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
+import me.maxistar.voiceinbox.core.TranscriptionTaskSnapshot
+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 AndroidTaskActionRouterTest {
+ @Test
+ fun routesEnabledSetupEmptyBatchAndResolvedEntryActions() {
+ var state = state(modelReady = false)
+ val calls = mutableListOf>()
+ val router = AndroidTaskActionRouter({ state }) { kind, entry -> calls += kind to entry }
+
+ assertTrue(router.route(request("setup:model", null, TaskActionKind.IMPORT_MODEL)))
+ assertEquals(TaskActionKind.IMPORT_MODEL, calls.last().first)
+ assertNull(calls.last().second)
+
+ state = state(entries = listOf(entry(9)))
+ assertTrue(router.route(request("audio:9", 9, TaskActionKind.TRANSCRIBE)))
+ assertEquals(9L, calls.last().second?.id)
+ assertTrue(router.route(request(TaskListDisplayItem.BatchAction.STABLE_KEY, null, TaskActionKind.TRANSCRIBE_ALL)))
+
+ state = state()
+ assertTrue(router.route(request(TaskListDisplayItem.Empty.STABLE_KEY, null, TaskActionKind.IMPORT_AUDIO)))
+ }
+
+ @Test
+ fun rejectsStaleDisabledConflictingAndMismatchedClicks() {
+ var state = state(entries = listOf(entry(9)))
+ var callCount = 0
+ val router = AndroidTaskActionRouter({ state }) { _, _ -> callCount++ }
+ val transcribe = request("audio:9", 9, TaskActionKind.TRANSCRIBE)
+
+ state = state(
+ entries = listOf(entry(9)),
+ transcription = TranscriptionTaskSnapshot(active = true, activeEntryId = 9),
+ )
+ assertFalse(router.route(transcribe))
+ assertFalse(router.route(request("audio:9", 10, TaskActionKind.PLAY)))
+ assertFalse(router.route(request("audio:missing", 9, TaskActionKind.PLAY)))
+ assertEquals(0, callCount)
+
+ state = state(entries = listOf(entry(9)), eligible = false)
+ assertFalse(router.route(transcribe))
+ assertFalse(router.route(request(TaskListDisplayItem.BatchAction.STABLE_KEY, null, TaskActionKind.TRANSCRIBE_ALL)))
+ }
+
+ @Test
+ fun resolvesRetryAndShowTextFromCurrentCatalogState() {
+ val failed = entry(4, AudioFileState.FAILED)
+ var routed: Pair? = null
+ var state = state(filter = TaskListFilter.PROCESSED, entries = listOf(failed))
+ val router = AndroidTaskActionRouter({ state }) { kind, entry -> routed = kind to entry }
+
+ assertTrue(router.route(request("audio:4", 4, TaskActionKind.RETRY_TRANSCRIPTION)))
+ assertEquals(failed, routed?.second)
+
+ val processed = entry(4, AudioFileState.PROCESSED, transcript = "hello")
+ state = state(filter = TaskListFilter.PROCESSED, entries = listOf(processed))
+ assertTrue(router.route(request("audio:4", 4, TaskActionKind.SHOW_TEXT)))
+ assertEquals(processed, routed?.second)
+ }
+
+ private fun state(
+ modelReady: Boolean = true,
+ filter: TaskListFilter = TaskListFilter.NEW,
+ entries: List = emptyList(),
+ transcription: TranscriptionTaskSnapshot = TranscriptionTaskSnapshot(),
+ eligible: Boolean = true,
+ ) = AndroidTaskListSnapshotMapper.state(
+ AndroidMainScreenInput(
+ filter = filter,
+ model = ModelSetupSnapshot(if (modelReady) ModelSetupSnapshotState.READY else ModelSetupSnapshotState.REQUIRED),
+ output = OutputSetupSnapshot(OutputSetupSnapshotState.READY),
+ folder = FolderSetupSnapshot(FolderSetupSnapshotState.READY),
+ entries = entries,
+ transcription = transcription,
+ transcriptionEligible = eligible,
+ hydration = AndroidMainScreenHydration(true, true, true, true),
+ ),
+ )
+
+ private fun request(stableId: String, entryId: Long?, kind: TaskActionKind) =
+ AndroidTaskActionRequest(stableId, entryId, kind)
+
+ private fun entry(
+ id: Long,
+ state: AudioFileState = AudioFileState.PENDING,
+ transcript: String? = null,
+ ) = AudioCatalogEntry(
+ id = id,
+ folderUri = AndroidAudioImportConstants.SOURCE_ID,
+ documentUri = "content://audio/$id",
+ displayName = "$id.ogg",
+ mimeType = "audio/ogg",
+ fingerprint = AudioFileFingerprint(1024, id),
+ state = state,
+ stateBeforeMissing = null,
+ lastError = null,
+ processedAtMillis = id.takeIf { state == AudioFileState.PROCESSED || state == AudioFileState.FAILED },
+ transcriptText = transcript,
+ )
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/AudioImportCoordinatorTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AudioImportCoordinatorTest.kt
new file mode 100644
index 0000000..9975dc8
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/AudioImportCoordinatorTest.kt
@@ -0,0 +1,190 @@
+package me.maxistar.voiceinbox
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class AudioImportCoordinatorTest {
+ @Test
+ fun validationAcceptsAudioMimeAndVoiceNoteExtensions() {
+ assertEquals(
+ null,
+ AudioImportRules.rejectionReason(metadata("voice", "audio/ogg", 10)),
+ )
+ assertEquals(
+ null,
+ AudioImportRules.rejectionReason(metadata("voice.OPUS", "application/octet-stream", 10)),
+ )
+ assertEquals(
+ "Unsupported audio format",
+ AudioImportRules.rejectionReason(metadata("notes.txt", "text/plain", 10)),
+ )
+ assertEquals(
+ "Audio file is empty",
+ AudioImportRules.rejectionReason(metadata("empty.wav", "audio/wav", 0)),
+ )
+ }
+
+ @Test
+ fun sameNameWithDistinctIdentityKeepsBothImports() {
+ val storage = FakeStorage(
+ metadata("voice.ogg", "audio/ogg", 10, source = "content://one"),
+ metadata("voice.ogg", "audio/ogg", 20, source = "content://two"),
+ )
+ val catalog = FakeCatalog()
+
+ val result = useCase(storage, catalog).ingest(listOf("content://one", "content://two"))
+
+ assertEquals(2, result.importedCount)
+ assertEquals(2, catalog.rows.size)
+ assertEquals(setOf("voice.ogg"), catalog.rows.values.map { it.displayName }.toSet())
+ assertEquals(2, storage.stored.size)
+ }
+
+ @Test
+ fun stableRedeliveryReusesCopyAndCatalogRow() {
+ val storage = FakeStorage(metadata("voice.m4a", "audio/mp4", 100))
+ val catalog = FakeCatalog()
+ assertEquals(1, useCase(storage, catalog).ingest(listOf(SOURCE)).importedCount)
+ assertEquals(1, useCase(storage, catalog).ingest(listOf(SOURCE)).duplicateCount)
+ assertEquals(1, storage.copyCount)
+ assertEquals(1, catalog.rows.size)
+ }
+
+ @Test
+ fun insufficientMetadataPreservesRepeatedDeliveries() {
+ val storage = FakeStorage(metadata("voice", "audio/ogg", null))
+ val catalog = FakeCatalog()
+ var token = 0
+ val useCase = AudioImportUseCase(
+ storage = storage,
+ catalog = catalog,
+ clockMillis = { 1234 },
+ unstableToken = { "token-${token++}" },
+ )
+
+ assertEquals(1, useCase.ingest(listOf(SOURCE)).importedCount)
+ assertEquals(1, useCase.ingest(listOf(SOURCE)).importedCount)
+ assertEquals(2, catalog.rows.size)
+ }
+
+ @Test
+ fun catalogFailureRemovesNewCopy() {
+ val storage = FakeStorage(metadata("voice.wav", "audio/wav", 10))
+ val catalog = FakeCatalog(failInsert = true)
+
+ val result = useCase(storage, catalog).ingest(listOf(SOURCE))
+
+ assertEquals(1, result.rejectedCount)
+ assertTrue(result.message().contains("catalog unavailable"))
+ assertTrue(storage.stored.isEmpty())
+ assertEquals(1, storage.deleteCount)
+ }
+
+ @Test
+ fun partialInputKeepsSuccessAndReportsRejectedItems() {
+ val storage = FakeStorage(
+ metadata("good.flac", "audio/flac", 30, source = "content://good"),
+ metadata("bad.txt", "text/plain", 5, source = "content://bad"),
+ metadata("broken.mp3", "audio/mpeg", 10, source = "content://broken"),
+ ).apply {
+ copyFailures += "content://broken"
+ }
+ val catalog = FakeCatalog()
+
+ val result = useCase(storage, catalog).ingest(
+ listOf("content://good", "content://bad", "content://broken", "content://good"),
+ )
+
+ assertEquals(1, result.importedCount)
+ assertEquals(0, result.duplicateCount)
+ assertEquals(2, result.rejectedCount)
+ assertEquals(1, catalog.rows.size)
+ assertTrue(result.message().contains("Imported 1"))
+ assertTrue(result.message().contains("2 rejected"))
+ assertFalse(storage.stored.any { it.contains("broken") })
+ }
+
+ private fun useCase(storage: FakeStorage, catalog: FakeCatalog) =
+ AudioImportUseCase(
+ storage = storage,
+ catalog = catalog,
+ clockMillis = { 1234 },
+ unstableToken = { "unstable" },
+ )
+
+ private fun metadata(
+ name: String,
+ mime: String?,
+ size: Long?,
+ source: String = SOURCE,
+ ) = AudioImportMetadata(
+ sourceId = source,
+ displayName = name,
+ mimeType = mime,
+ sizeBytes = size,
+ modifiedMillis = null,
+ )
+
+ private data class CatalogRow(
+ val displayName: String,
+ val sizeBytes: Long,
+ )
+
+ private class FakeCatalog(
+ private val failInsert: Boolean = false,
+ ) : AudioImportCatalog {
+ val rows = mutableMapOf()
+
+ override fun contains(documentId: String): Boolean = documentId in rows
+
+ override fun insertPending(
+ documentId: String,
+ displayName: String,
+ mimeType: String?,
+ sizeBytes: Long,
+ importedAtMillis: Long,
+ ) {
+ if (failInsert) error("catalog unavailable")
+ rows[documentId] = CatalogRow(displayName, sizeBytes)
+ }
+ }
+
+ private class FakeStorage(
+ vararg metadata: AudioImportMetadata,
+ ) : AudioImportStorage {
+ private val metadata = metadata.associateBy(AudioImportMetadata::sourceId)
+ val stored = mutableSetOf()
+ val copyFailures = mutableSetOf()
+ var copyCount = 0
+ var deleteCount = 0
+
+ override fun metadata(sourceId: String): AudioImportMetadata =
+ metadata[sourceId] ?: error("unreadable")
+
+ override fun destination(
+ receiptKey: String,
+ displayName: String,
+ ): AudioImportDestination {
+ val documentId = "content://imports/$receiptKey-${AudioImportRules.safeFilename(displayName)}"
+ return AudioImportDestination(documentId, documentId in stored)
+ }
+
+ override fun copy(sourceId: String, destination: AudioImportDestination): Long {
+ if (sourceId in copyFailures) error("copy failed")
+ copyCount += 1
+ stored += destination.documentId
+ return metadata(sourceId).sizeBytes ?: 12
+ }
+
+ override fun delete(destination: AudioImportDestination) {
+ deleteCount += 1
+ stored -= destination.documentId
+ }
+ }
+
+ private companion object {
+ const val SOURCE = "content://voice"
+ }
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/CatalogRefreshPolicyTest.kt b/app/src/test/java/me/maxistar/voiceinbox/CatalogRefreshPolicyTest.kt
new file mode 100644
index 0000000..e42015b
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/CatalogRefreshPolicyTest.kt
@@ -0,0 +1,63 @@
+package me.maxistar.voiceinbox
+
+import me.maxistar.voiceinbox.core.AudioCatalogSourceScope
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CatalogRefreshPolicyTest {
+ @Test
+ fun firstObservationFilenameAndTerminalStateTriggerRefresh() {
+ val first = workKey(filename = "one.wav")
+ assertTrue(CatalogRefreshPolicy.shouldRefreshForWork(null, first))
+ assertFalse(CatalogRefreshPolicy.shouldRefreshForWork(first, first))
+ assertTrue(
+ CatalogRefreshPolicy.shouldRefreshForWork(
+ first,
+ workKey(filename = "two.wav"),
+ ),
+ )
+ assertTrue(
+ CatalogRefreshPolicy.shouldRefreshForWork(
+ first,
+ workKey(filename = "one.wav", state = "SUCCEEDED", completed = 1),
+ ),
+ )
+ }
+
+ @Test
+ fun fineGrainedProgressDoesNotParticipateInRefreshKey() {
+ val before = workKey(filename = "one.wav")
+ val after = workKey(filename = "one.wav")
+
+ assertFalse(CatalogRefreshPolicy.shouldRefreshForWork(before, after))
+ }
+
+ @Test
+ fun refreshTokenRejectsOldGenerationAndScope() {
+ val scope = AudioCatalogSourceScope.of(listOf("imports", "folder"))
+ val token = CatalogRefreshToken(3, scope)
+
+ assertTrue(CatalogRefreshPolicy.isCurrent(token, 3, scope))
+ assertFalse(CatalogRefreshPolicy.isCurrent(token, 4, scope))
+ assertFalse(
+ CatalogRefreshPolicy.isCurrent(
+ token,
+ 3,
+ AudioCatalogSourceScope.single("imports"),
+ ),
+ )
+ }
+
+ private fun workKey(
+ filename: String?,
+ state: String = "RUNNING",
+ completed: Int = 0,
+ ) = CatalogWorkRefreshKey(
+ workId = "work",
+ workState = state,
+ filename = filename,
+ completedFiles = completed,
+ failedFiles = 0,
+ )
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/PersistedSelectionAccessPolicyTest.kt b/app/src/test/java/me/maxistar/voiceinbox/PersistedSelectionAccessPolicyTest.kt
new file mode 100644
index 0000000..11b2d2e
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/PersistedSelectionAccessPolicyTest.kt
@@ -0,0 +1,55 @@
+package me.maxistar.voiceinbox
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class PersistedSelectionAccessPolicyTest {
+ @Test
+ fun successfulValidationIsValidWithoutPersistedPermission() {
+ assertEquals(
+ PersistedSelectionAccessState.VALID,
+ PersistedSelectionAccessPolicy.classify(
+ validationSucceeded = true,
+ hasRequiredPersistedPermission = false,
+ ),
+ )
+ }
+
+ @Test
+ fun failedValidationWithPersistedPermissionIsTransient() {
+ val state = PersistedSelectionAccessPolicy.classify(
+ validationSucceeded = false,
+ hasRequiredPersistedPermission = true,
+ )
+
+ assertEquals(PersistedSelectionAccessState.TEMPORARILY_UNAVAILABLE, state)
+ assertFalse(PersistedSelectionAccessPolicy.shouldClearSelection(state))
+ }
+
+ @Test
+ fun failedValidationWithoutPersistedPermissionIsRevoked() {
+ val state = PersistedSelectionAccessPolicy.classify(
+ validationSucceeded = false,
+ hasRequiredPersistedPermission = false,
+ )
+
+ assertEquals(PersistedSelectionAccessState.PERMISSION_REVOKED, state)
+ assertTrue(PersistedSelectionAccessPolicy.shouldClearSelection(state))
+ }
+
+ @Test
+ fun validAndTransientSelectionsAreNeverClearedByPolicy() {
+ assertFalse(
+ PersistedSelectionAccessPolicy.shouldClearSelection(
+ PersistedSelectionAccessState.VALID,
+ ),
+ )
+ assertFalse(
+ PersistedSelectionAccessPolicy.shouldClearSelection(
+ PersistedSelectionAccessState.TEMPORARILY_UNAVAILABLE,
+ ),
+ )
+ }
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelDirectoryReaderTest.kt b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelDirectoryReaderTest.kt
new file mode 100644
index 0000000..e86b277
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelDirectoryReaderTest.kt
@@ -0,0 +1,75 @@
+package me.maxistar.voiceinbox
+
+import android.provider.DocumentsContract
+import me.maxistar.voiceinbox.core.SpeechModelFile
+import me.maxistar.voiceinbox.core.SpeechModelManifest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class SpeechModelDirectoryReaderTest {
+ @Test
+ fun exactDirectFilesAreMatchedByManifestName() {
+ val documents = listOf(
+ document("model.bin"),
+ document("config.json"),
+ document("nested", DocumentsContract.Document.MIME_TYPE_DIR),
+ document("unrelated.txt"),
+ )
+
+ val matched = SpeechModelDirectoryReader.matchRequiredDocuments(documents, manifest)
+
+ assertEquals(setOf("model.bin", "config.json"), matched.keys)
+ }
+
+ @Test
+ fun missingAndDuplicateRequiredFilesAreRejected() {
+ assertTrue(
+ runCatching {
+ SpeechModelDirectoryReader.matchRequiredDocuments(
+ listOf(document("model.bin")),
+ manifest,
+ )
+ }.exceptionOrNull()?.message?.contains("config.json") == true,
+ )
+ assertTrue(
+ runCatching {
+ SpeechModelDirectoryReader.matchRequiredDocuments(
+ listOf(document("model.bin"), document("model.bin"), document("config.json")),
+ manifest,
+ )
+ }.exceptionOrNull()?.message?.contains("Multiple") == true,
+ )
+ }
+
+ @Test
+ fun directoryWithRequiredNameDoesNotCountAsAFile() {
+ val error = runCatching {
+ SpeechModelDirectoryReader.matchRequiredDocuments(
+ listOf(
+ document("model.bin", DocumentsContract.Document.MIME_TYPE_DIR),
+ document("config.json"),
+ ),
+ manifest,
+ )
+ }.exceptionOrNull()
+
+ assertTrue(error?.message?.contains("model.bin") == true)
+ }
+
+ private fun document(name: String, mime: String = "application/octet-stream") =
+ SpeechModelSourceDocument(name, "content://test/$name/${System.nanoTime()}", mime)
+
+ companion object {
+ private val manifest = SpeechModelManifest(
+ modelId = "test/model",
+ version = "test",
+ repositoryRevision = "revision",
+ files = listOf(
+ SpeechModelFile("model.bin", 1, "hash"),
+ SpeechModelFile("config.json", 1, "hash"),
+ ),
+ safetyMarginBytes = 0,
+ )
+ }
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelInstallationWorkTest.kt b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelInstallationWorkTest.kt
new file mode 100644
index 0000000..fd752c3
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelInstallationWorkTest.kt
@@ -0,0 +1,17 @@
+package me.maxistar.voiceinbox
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class SpeechModelInstallationWorkTest {
+ @Test
+ fun downloadCompatibilityAliasesKeepTheirHistoricalValues() {
+ assertEquals("speech-model-installation", SpeechModelInstallationWork.UNIQUE_WORK_NAME)
+ assertEquals(SpeechModelInstallationWork.UNIQUE_WORK_NAME, SpeechModelDownloadWorker.UNIQUE_WORK_NAME)
+ assertEquals(SpeechModelInstallationWork.KEY_BYTES_DOWNLOADED, SpeechModelDownloadWorker.KEY_BYTES_DOWNLOADED)
+ assertEquals(SpeechModelInstallationWork.KEY_TOTAL_BYTES, SpeechModelDownloadWorker.KEY_TOTAL_BYTES)
+ assertEquals(SpeechModelInstallationWork.KEY_MESSAGE, SpeechModelDownloadWorker.KEY_MESSAGE)
+ assertEquals(SpeechModelInstallationWork.KEY_ERROR, SpeechModelDownloadWorker.KEY_ERROR)
+ assertEquals(SpeechModelInstallationWork.KEY_MODEL_PATH, SpeechModelDownloadWorker.KEY_MODEL_PATH)
+ }
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelLocalImporterTest.kt b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelLocalImporterTest.kt
new file mode 100644
index 0000000..2032a4a
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelLocalImporterTest.kt
@@ -0,0 +1,153 @@
+package me.maxistar.voiceinbox
+
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.CancellationException
+import me.maxistar.voiceinbox.core.SpeechModelFile
+import me.maxistar.voiceinbox.core.SpeechModelManifest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.ByteArrayInputStream
+import java.io.File
+import java.io.IOException
+import java.security.MessageDigest
+
+class SpeechModelLocalImporterTest {
+ @get:Rule
+ val temporaryFolder = TemporaryFolder()
+
+ @Test
+ fun validSourceIsCopiedVerifiedAndActivatedWithProgress() = runBlocking {
+ val sources = sourceUris()
+ val progress = mutableListOf()
+ val repository = repository()
+ val importer = importer(repository, sources) { uri ->
+ ByteArrayInputStream(testFiles.getValue(uri.substringAfterLast('/')))
+ }
+
+ val installed = importer.import(treeUri) { progress += it }.getOrThrow()
+
+ assertTrue(installed.isDirectory)
+ testFiles.forEach { (name, contents) ->
+ assertTrue(installed.resolve(name).readBytes().contentEquals(contents))
+ }
+ assertEquals(testManifest.totalSizeBytes, progress.last().bytesCopied)
+ assertTrue(repository.inspect() is InstalledSpeechModelState.Ready)
+ }
+
+ @Test
+ fun invalidPayloadIsRejectedAndTemporaryFileIsRemoved() = runBlocking {
+ val sources = sourceUris()
+ val repository = repository()
+ val importer = importer(repository, sources) { uri ->
+ val name = uri.substringAfterLast('/')
+ val bytes = if (name == "model.bin") "wrong".toByteArray()
+ else testFiles.getValue(name)
+ ByteArrayInputStream(bytes)
+ }
+
+ val result = importer.import(treeUri) {}
+
+ assertTrue(result.isFailure)
+ assertFalse(repository.temporaryFile(testManifest.files.first()).exists())
+ assertFalse(repository.installedDirectory.exists())
+ }
+
+ @Test
+ fun providerFailureIsRecoverableAndExistingModelSurvives() = runBlocking {
+ val repository = repository()
+ repository.prepareForInstall().getOrThrow()
+ testFiles.forEach { (name, bytes) -> repository.stagingDirectory.resolve(name).writeBytes(bytes) }
+ repository.activate().getOrThrow()
+ val importer = importer(repository, sourceUris()) { throw IOException("provider disconnected") }
+
+ val result = importer.import(treeUri) {}
+
+ assertTrue(result.exceptionOrNull()?.message?.contains("provider disconnected") == true)
+ assertTrue(repository.inspect() is InstalledSpeechModelState.Ready)
+ }
+
+ @Test
+ fun missingSourceMapCannotBeCompletedByOldStaging() = runBlocking {
+ val repository = repository()
+ repository.prepareForInstall().getOrThrow()
+ testFiles.forEach { (name, bytes) -> repository.stagingDirectory.resolve(name).writeBytes(bytes) }
+ val incomplete = sourceUris().filterKeys { it != "config.json" }
+ val importer = importer(repository, incomplete) { ByteArrayInputStream(byteArrayOf()) }
+
+ val result = importer.import(treeUri) {}
+
+ assertTrue(result.isFailure)
+ assertFalse(repository.installedDirectory.exists())
+ assertTrue(repository.stagingDirectory.listFiles().orEmpty().isEmpty())
+ }
+
+ @Test
+ fun cancellationIsPropagatedAndPartialFileIsRemoved() = runBlocking {
+ val repository = repository()
+ val importer = importer(repository, sourceUris()) { uri ->
+ ByteArrayInputStream(testFiles.getValue(uri.substringAfterLast('/')))
+ }
+
+ var cancelled = false
+ try {
+ importer.import(treeUri) { progress ->
+ if (progress.bytesCopied > 0) throw CancellationException("test cancellation")
+ }
+ } catch (_: CancellationException) {
+ cancelled = true
+ }
+
+ assertTrue(cancelled)
+ assertFalse(repository.temporaryFile(testManifest.files.first()).exists())
+ assertFalse(repository.installedDirectory.exists())
+ }
+
+ private fun importer(
+ repository: SpeechModelRepository,
+ sources: Map,
+ open: (String) -> ByteArrayInputStream,
+ ) = SpeechModelLocalImporter(
+ repository = repository,
+ requiredDocuments = { _, manifest ->
+ manifest.files.associate { entry ->
+ entry.name to (sources[entry.name]
+ ?: throw IOException("${entry.name} is missing from the selected model folder"))
+ }
+ },
+ openInputStream = open,
+ )
+
+ private fun repository() = SpeechModelRepository(
+ root = File(temporaryFolder.root, "models"),
+ manifest = testManifest,
+ usableSpace = { Long.MAX_VALUE },
+ )
+
+ private fun sourceUris() = testFiles.keys.associateWith { "content://test/$it" }
+
+ companion object {
+ private const val treeUri = "content://test/tree/model"
+ private val testFiles = linkedMapOf(
+ "model.bin" to "model".toByteArray(),
+ "config.json" to "{}".toByteArray(),
+ )
+ private val testManifest = SpeechModelManifest(
+ modelId = "example/model",
+ version = "test-version",
+ repositoryRevision = "revision",
+ files = testFiles.map { (name, contents) ->
+ SpeechModelFile(
+ name,
+ contents.size.toLong(),
+ MessageDigest.getInstance("SHA-256").digest(contents)
+ .joinToString("") { "%02x".format(it) },
+ )
+ },
+ safetyMarginBytes = 8,
+ )
+ }
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelPreparationTest.kt b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelPreparationTest.kt
new file mode 100644
index 0000000..1b38d88
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelPreparationTest.kt
@@ -0,0 +1,97 @@
+package me.maxistar.voiceinbox
+
+import me.maxistar.voiceinbox.core.SpeechModelFile
+import me.maxistar.voiceinbox.core.SpeechModelManifest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.File
+import java.security.MessageDigest
+
+class SpeechModelPreparationTest {
+ @get:Rule
+ val temporaryFolder = TemporaryFolder()
+
+ @Test
+ fun preparationVerifiesAndInitializesOnceUntilInvalidated() {
+ val repository = readyRepository()
+ var initializations = 0
+ SpeechModelPreparation.invalidate()
+
+ repeat(2) {
+ assertTrue(
+ SpeechModelPreparation.prepare(repository) {
+ initializations += 1
+ true
+ }.isSuccess,
+ )
+ }
+ assertEquals(1, initializations)
+
+ SpeechModelPreparation.invalidate()
+ assertTrue(
+ SpeechModelPreparation.prepare(repository) {
+ initializations += 1
+ true
+ }.isSuccess,
+ )
+ assertEquals(2, initializations)
+ }
+
+ @Test
+ fun invalidModelFailsBeforeNativeInitialization() {
+ val repository = readyRepository()
+ repository.installedDirectory.resolve("model.bin").writeText("other")
+ var initialized = false
+ SpeechModelPreparation.invalidate()
+
+ val result = SpeechModelPreparation.prepare(repository) {
+ initialized = true
+ true
+ }
+
+ assertTrue(result.isFailure)
+ assertTrue(!initialized)
+ }
+
+ private fun readyRepository(): SpeechModelRepository {
+ val repository = SpeechModelRepository(
+ File(temporaryFolder.root, "models"),
+ testManifest,
+ usableSpace = { Long.MAX_VALUE },
+ )
+ repository.prepareForInstall().getOrThrow()
+ testFiles.forEach { (name, contents) ->
+ repository.stagingDirectory.resolve(name).apply {
+ parentFile?.mkdirs()
+ writeBytes(contents)
+ }
+ }
+ repository.activate().getOrThrow()
+ return repository
+ }
+
+ companion object {
+ private val testFiles = linkedMapOf(
+ "model.bin" to "model".toByteArray(),
+ "config.json" to "{}".toByteArray(),
+ )
+ private val testManifest = SpeechModelManifest(
+ modelId = "example/model",
+ version = "test-version",
+ repositoryRevision = "0123456789abcdef",
+ files = testFiles.map { (name, contents) ->
+ SpeechModelFile(
+ name,
+ contents.size.toLong(),
+ MessageDigest.getInstance("SHA-256")
+ .digest(contents)
+ .joinToString("") { "%02x".format(it) },
+ )
+ },
+ safetyMarginBytes = 8,
+ )
+ }
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelReadinessManagerTest.kt b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelReadinessManagerTest.kt
index ec95103..8669454 100644
--- a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelReadinessManagerTest.kt
+++ b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelReadinessManagerTest.kt
@@ -19,13 +19,8 @@ class SpeechModelReadinessManagerTest {
fun duplicateRefreshesAreCoalescedWhileCheckIsRunning() {
val executor = QueueingExecutor()
val repository = readyRepository()
- var initializations = 0
val manager = SpeechModelReadinessManager(
repository = repository,
- initializeModel = {
- initializations += 1
- true
- },
executor = executor,
)
val firstStates = mutableListOf()
@@ -40,7 +35,6 @@ class SpeechModelReadinessManagerTest {
executor.runNext()
- assertEquals(1, initializations)
assertTrue(firstStates.last() is SpeechModelReadinessState.Ready)
assertTrue(secondStates.last() is SpeechModelReadinessState.Ready)
}
@@ -49,13 +43,8 @@ class SpeechModelReadinessManagerTest {
fun readyStateIsCachedForLaterRefreshes() {
val executor = QueueingExecutor()
val repository = readyRepository()
- var initializations = 0
val manager = SpeechModelReadinessManager(
repository = repository,
- initializeModel = {
- initializations += 1
- true
- },
executor = executor,
)
val firstStates = mutableListOf()
@@ -65,7 +54,6 @@ class SpeechModelReadinessManagerTest {
executor.runNext()
manager.refresh { cachedStates += it }
- assertEquals(1, initializations)
assertEquals(0, executor.pendingCount)
assertTrue(firstStates.last() is SpeechModelReadinessState.Ready)
assertEquals(1, cachedStates.size)
diff --git a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelRepositoryTest.kt b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelRepositoryTest.kt
index 1e668b1..6f2b40b 100644
--- a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelRepositoryTest.kt
+++ b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelRepositoryTest.kt
@@ -3,6 +3,7 @@ package me.maxistar.voiceinbox
import me.maxistar.voiceinbox.core.*
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -37,6 +38,38 @@ class SpeechModelRepositoryTest {
)
}
+ @Test
+ fun lightweightInspectionDoesNotHashInstalledPayloads() {
+ val repository = repository()
+ repository.prepareForInstall().getOrThrow()
+ writeValidStaging(repository)
+ val installed = repository.activate().getOrThrow()
+ installed.resolve("model.bin").writeText("other")
+
+ val lightweight = repository.inspectLightweight() as InstalledSpeechModelState.Ready
+ assertEquals(InstalledSpeechModelState.Ready.Verification.VERIFIED, lightweight.verification)
+ assertTrue(repository.inspect() is InstalledSpeechModelState.Invalid)
+ assertTrue(repository.inspectLightweight() is InstalledSpeechModelState.Invalid)
+ }
+
+ @Test
+ fun legacyInstallationWithoutReceiptIsAvailableLightweight() {
+ val repository = repository()
+ repository.prepareForInstall().getOrThrow()
+ writeValidStaging(repository)
+ repository.activate().getOrThrow()
+ File(temporaryFolder.root, "models/active-model").delete()
+
+ val legacy = repository.inspectLightweight() as InstalledSpeechModelState.Ready
+ assertEquals(InstalledSpeechModelState.Ready.Verification.LEGACY_UNVERIFIED, legacy.verification)
+ assertTrue(repository.inspect() is InstalledSpeechModelState.Ready)
+ assertEquals(
+ InstalledSpeechModelState.Ready.Verification.VERIFIED,
+ (repository.inspectLightweight() as InstalledSpeechModelState.Ready).verification,
+ )
+ assertTrue(File(temporaryFolder.root, "models/active-model").isFile)
+ }
+
@Test
fun corruptAndIncompleteModelsAreRejected() {
val repository = repository()
@@ -94,6 +127,98 @@ class SpeechModelRepositoryTest {
assertTrue(repository.prepareForInstall().isFailure)
}
+ @Test
+ fun freshImportClearsCurrentStagingAndChecksFullModelSpace() {
+ val repository = repository()
+ repository.prepareForInstall().getOrThrow()
+ writeValidStaging(repository)
+
+ repository.prepareFreshImport().getOrThrow()
+
+ assertTrue(repository.stagingDirectory.isDirectory)
+ assertTrue(repository.stagingDirectory.listFiles().orEmpty().isEmpty())
+
+ val insufficient = SpeechModelRepository(
+ root = File(temporaryFolder.root, "small-models"),
+ manifest = testManifest,
+ usableSpace = { testManifest.requiredFreeBytes - 1 },
+ )
+ assertTrue(insufficient.prepareFreshImport().isFailure)
+ }
+
+ @Test
+ fun verifiedTemporaryFileIsAcceptedIntoStaging() {
+ val repository = repository()
+ repository.prepareFreshImport().getOrThrow()
+ val entry = testManifest.files.first()
+ repository.temporaryFile(entry).writeBytes(testFiles.getValue(entry.name))
+
+ val accepted = repository.acceptTemporaryFile(entry).getOrThrow()
+
+ assertEquals(repository.stagingFile(entry), accepted)
+ assertTrue(accepted.isFile)
+ assertFalse(repository.temporaryFile(entry).exists())
+ }
+
+ @Test
+ fun activationFailureRestoresPreviousValidModel() {
+ val initial = repository()
+ initial.prepareForInstall().getOrThrow()
+ writeValidStaging(initial)
+ initial.activate().getOrThrow()
+
+ val failing = SpeechModelRepository(
+ root = File(temporaryFolder.root, "models"),
+ manifest = testManifest,
+ usableSpace = { Long.MAX_VALUE },
+ moveDirectory = { source, destination ->
+ if (source.path.contains("${File.separator}staging${File.separator}")) false
+ else source.renameTo(destination)
+ },
+ )
+ failing.prepareFreshImport().getOrThrow()
+ writeValidStaging(failing)
+
+ assertTrue(failing.activate().isFailure)
+ assertTrue(repository().inspect() is InstalledSpeechModelState.Ready)
+ }
+
+ @Test
+ fun interruptedReplacementRestoresBackupDuringInspection() {
+ val repository = repository()
+ repository.prepareForInstall().getOrThrow()
+ writeValidStaging(repository)
+ val installed = repository.activate().getOrThrow()
+ val root = File(temporaryFolder.root, "models")
+ val backup = File(root, "installed/${testManifest.version}.backup")
+ assertTrue(installed.renameTo(backup))
+ installed.mkdirs()
+ installed.resolve("model.bin").writeText("partial")
+ File(root, "activation-model").writeText("replacement")
+
+ val recovered = repository().inspectLightweight()
+
+ assertTrue(recovered is InstalledSpeechModelState.Ready)
+ assertFalse(backup.exists())
+ assertFalse(File(root, "activation-model").exists())
+ assertEquals("model", installed.resolve("model.bin").readText())
+ }
+
+ @Test
+ fun staleBackupIsRemovedAfterCompletedActivation() {
+ val repository = repository()
+ repository.prepareForInstall().getOrThrow()
+ writeValidStaging(repository)
+ val installed = repository.activate().getOrThrow()
+ val backup = File(temporaryFolder.root, "models/installed/${testManifest.version}.backup")
+ installed.copyRecursively(backup)
+
+ repository.cleanupStaleState()
+
+ assertFalse(backup.exists())
+ assertTrue(repository.inspectLightweight() is InstalledSpeechModelState.Ready)
+ }
+
private fun repository() = SpeechModelRepository(
root = File(temporaryFolder.root, "models"),
manifest = testManifest,
diff --git a/app/src/test/java/me/maxistar/voiceinbox/StartupProcessingCoordinatorTest.kt b/app/src/test/java/me/maxistar/voiceinbox/StartupProcessingCoordinatorTest.kt
new file mode 100644
index 0000000..e8c42fd
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/StartupProcessingCoordinatorTest.kt
@@ -0,0 +1,178 @@
+package me.maxistar.voiceinbox
+
+import me.maxistar.voiceinbox.core.StartupProcessingDecision
+import me.maxistar.voiceinbox.core.StartupProcessingPolicy
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class StartupProcessingCoordinatorTest {
+ @Test
+ fun callbackOrderingWaitsUntilEveryInputIsReady() {
+ val coordinator = StartupProcessingCoordinator()
+ coordinator.setFolderReady(true)
+ coordinator.setOutputReady(true)
+ coordinator.setModelReady(true)
+ coordinator.setTranscriptionState(known = true, active = false)
+
+ assertEquals(StartupProcessingDecision.Wait, coordinator.evaluate(StartupProcessingPolicy.ASK))
+ assertTrue(coordinator.beginStartupScan(1))
+ assertEquals(StartupProcessingDecision.Wait, coordinator.evaluate(StartupProcessingPolicy.ASK))
+ coordinator.onStartupCatalogReady(1, pendingCount = 2, failedCount = 0)
+
+ assertEquals(
+ StartupProcessingDecision.Prompt(2),
+ coordinator.evaluate(StartupProcessingPolicy.ASK),
+ )
+ }
+
+ @Test
+ fun staleStartupGenerationIsIgnored() {
+ val coordinator = readyCoordinator(generation = 2, completeCatalog = false)
+ coordinator.onStartupCatalogReady(1, pendingCount = 3, failedCount = 0)
+
+ assertEquals(
+ StartupProcessingDecision.Wait,
+ coordinator.evaluate(StartupProcessingPolicy.AUTOMATIC),
+ )
+ }
+
+ @Test
+ fun delayedPrerequisiteReevaluatesToAutomaticStart() {
+ val coordinator = readyCoordinator()
+ coordinator.setOutputReady(false)
+ assertEquals(
+ StartupProcessingDecision.Wait,
+ coordinator.evaluate(StartupProcessingPolicy.AUTOMATIC),
+ )
+
+ coordinator.setOutputReady(true)
+ assertEquals(
+ StartupProcessingDecision.Start(1),
+ coordinator.evaluate(StartupProcessingPolicy.AUTOMATIC),
+ )
+ assertTrue(coordinator.isComplete())
+ }
+
+ @Test
+ fun failedStartupScanCompletesEvaluation() {
+ val coordinator = StartupProcessingCoordinator()
+ assertTrue(coordinator.beginStartupScan(4))
+ coordinator.onStartupScanFailed(4)
+
+ coordinator.evaluate(StartupProcessingPolicy.ASK)
+
+ assertTrue(coordinator.isComplete())
+ }
+
+ @Test
+ fun restoredPromptDoesNotEmitAnotherPrompt() {
+ val original = readyCoordinator()
+ assertEquals(
+ StartupProcessingDecision.Prompt(1),
+ original.evaluate(StartupProcessingPolicy.ASK),
+ )
+
+ val restored = StartupProcessingCoordinator.restore(original.savedStage())
+
+ assertEquals(StartupProcessingDecision.Wait, restored.evaluate(StartupProcessingPolicy.ASK))
+ }
+
+ @Test
+ fun confirmedPromptWaitsForRecreatedReadinessThenStarts() {
+ val original = readyCoordinator()
+ original.evaluate(StartupProcessingPolicy.ASK)
+ val restored = StartupProcessingCoordinator.restore(original.savedStage())
+ assertTrue(restored.beginStartupScan(7))
+ assertTrue(restored.confirmPrompt())
+ assertEquals(StartupProcessingDecision.Wait, restored.evaluate(StartupProcessingPolicy.ASK))
+
+ prepareReady(restored, generation = 7)
+
+ assertEquals(
+ StartupProcessingDecision.Start(1),
+ restored.evaluate(StartupProcessingPolicy.ASK),
+ )
+ }
+
+ @Test
+ fun declinedPromptIsTerminalAndManualRefreshCannotReopenIt() {
+ val coordinator = readyCoordinator()
+ coordinator.evaluate(StartupProcessingPolicy.ASK)
+ assertTrue(coordinator.declinePrompt())
+
+ coordinator.onCatalogRefreshed(pendingCount = 5, failedCount = 0)
+
+ assertEquals(StartupProcessingDecision.Wait, coordinator.evaluate(StartupProcessingPolicy.ASK))
+ assertFalse(coordinator.beginStartupScan(8))
+ }
+
+ @Test
+ fun aFreshCoordinatorEvaluatesANewLaunch() {
+ val previous = readyCoordinator()
+ previous.evaluate(StartupProcessingPolicy.LEAVE_QUEUED)
+ assertTrue(previous.isComplete())
+
+ val nextLaunch = readyCoordinator()
+
+ assertEquals(
+ StartupProcessingDecision.Start(1),
+ nextLaunch.evaluate(StartupProcessingPolicy.AUTOMATIC),
+ )
+ }
+
+ @Test
+ fun activeWorkSuppressesHandoffUntilCatalogRefreshes() {
+ val coordinator = readyCoordinator()
+ coordinator.setTranscriptionState(known = true, active = true)
+ assertEquals(
+ StartupProcessingDecision.Wait,
+ coordinator.evaluate(StartupProcessingPolicy.AUTOMATIC),
+ )
+
+ coordinator.setTranscriptionState(known = true, active = false)
+ assertEquals(
+ StartupProcessingDecision.Wait,
+ coordinator.evaluate(StartupProcessingPolicy.AUTOMATIC),
+ )
+ coordinator.onCatalogRefreshed(pendingCount = 0, failedCount = 0)
+
+ coordinator.evaluate(StartupProcessingPolicy.AUTOMATIC)
+ assertTrue(coordinator.isComplete())
+ }
+
+ @Test
+ fun failedOnlyCatalogDoesNotStart() {
+ val coordinator = readyCoordinator(pendingCount = 0, failedCount = 3)
+
+ coordinator.evaluate(StartupProcessingPolicy.AUTOMATIC)
+
+ assertTrue(coordinator.isComplete())
+ }
+
+ private fun readyCoordinator(
+ generation: Long = 1,
+ pendingCount: Int = 1,
+ failedCount: Int = 0,
+ completeCatalog: Boolean = true,
+ ): StartupProcessingCoordinator =
+ StartupProcessingCoordinator().also { coordinator ->
+ coordinator.beginStartupScan(generation)
+ coordinator.setFolderReady(true)
+ coordinator.setOutputReady(true)
+ coordinator.setModelReady(true)
+ coordinator.setTranscriptionState(known = true, active = false)
+ if (completeCatalog) {
+ coordinator.onStartupCatalogReady(generation, pendingCount, failedCount)
+ }
+ }
+
+ private fun prepareReady(coordinator: StartupProcessingCoordinator, generation: Long) {
+ coordinator.setFolderReady(true)
+ coordinator.setOutputReady(true)
+ coordinator.setModelReady(true)
+ coordinator.setTranscriptionState(known = true, active = false)
+ coordinator.onStartupCatalogReady(generation, pendingCount = 1, failedCount = 0)
+ }
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/StartupProcessingPolicyStoreTest.kt b/app/src/test/java/me/maxistar/voiceinbox/StartupProcessingPolicyStoreTest.kt
new file mode 100644
index 0000000..be1c0f0
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/StartupProcessingPolicyStoreTest.kt
@@ -0,0 +1,64 @@
+package me.maxistar.voiceinbox
+
+import me.maxistar.voiceinbox.core.ScheduledTranscriptionSettings
+import me.maxistar.voiceinbox.core.ScheduledTranscriptionSettingsStorage
+import me.maxistar.voiceinbox.core.StartupProcessingPolicy
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class StartupProcessingPolicyStoreTest {
+ @Test
+ fun missingAndUnknownValuesDefaultToAsk() {
+ val storage = FakeStartupStorage()
+ val store = StartupProcessingPolicyStore(storage)
+
+ assertEquals(StartupProcessingPolicy.ASK, store.load())
+ storage.raw = "future-value"
+ assertEquals(StartupProcessingPolicy.ASK, store.load())
+ }
+
+ @Test
+ fun policiesRoundTripThroughStableValues() {
+ val storage = FakeStartupStorage()
+ val store = StartupProcessingPolicyStore(storage)
+
+ StartupProcessingPolicy.entries.forEach { policy ->
+ store.save(policy)
+ assertEquals(policy, store.load())
+ }
+ }
+
+ @Test
+ fun startupPolicyDoesNotChangeScheduledSettings() {
+ val scheduledStorage = FakeScheduledStorage(
+ ScheduledTranscriptionSettings(enabled = true, hour = 4, minute = 30),
+ )
+ val scheduledStore = ScheduledTranscriptionSettingsStore(scheduledStorage)
+ val startupStore = StartupProcessingPolicyStore(FakeStartupStorage())
+
+ startupStore.save(StartupProcessingPolicy.AUTOMATIC)
+
+ assertTrue(scheduledStore.load().enabled)
+ assertEquals(4, scheduledStore.load().hour)
+ assertEquals(30, scheduledStore.load().minute)
+ }
+
+ private class FakeStartupStorage(var raw: String? = null) : StartupProcessingPolicyStorage {
+ override fun loadRaw(): String? = raw
+
+ override fun saveRaw(value: String) {
+ raw = value
+ }
+ }
+
+ private class FakeScheduledStorage(
+ private var settings: ScheduledTranscriptionSettings,
+ ) : ScheduledTranscriptionSettingsStorage {
+ override fun load(): ScheduledTranscriptionSettings = settings
+
+ override fun save(settings: ScheduledTranscriptionSettings) {
+ this.settings = settings
+ }
+ }
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt b/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt
new file mode 100644
index 0000000..4634874
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt
@@ -0,0 +1,129 @@
+package me.maxistar.voiceinbox
+
+import me.maxistar.voiceinbox.core.AudioFileState
+import me.maxistar.voiceinbox.core.AudioTaskSnapshot
+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
+import me.maxistar.voiceinbox.core.TaskListInput
+import me.maxistar.voiceinbox.core.TaskListPresentationController
+import me.maxistar.voiceinbox.core.TaskProgressPresentation
+import me.maxistar.voiceinbox.core.TranscriptionTaskSnapshot
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class TaskListDisplayItemsTest {
+ @Test
+ fun setupBatchAndAudioItemsHaveStableKindsAndPlacement() {
+ val items = items(
+ filter = TaskListFilter.NEW,
+ model = ModelSetupSnapshot(ModelSetupSnapshotState.REQUIRED, downloadAvailable = true),
+ output = OutputSetupSnapshot(OutputSetupSnapshotState.REQUIRED),
+ audio = listOf(pending(7)),
+ )
+
+ assertEquals(
+ listOf(
+ TaskListDisplayItem.Setup::class,
+ TaskListDisplayItem.Setup::class,
+ TaskListDisplayItem.BatchAction::class,
+ TaskListDisplayItem.Audio::class,
+ ),
+ items.map { it::class },
+ )
+ assertEquals(listOf("setup:model", "setup:output", "action:transcribe-all", "audio:7"), items.map { it.stableKey })
+ assertEquals(1, (items[2] as TaskListDisplayItem.BatchAction).eligibleCount)
+ }
+
+ @Test
+ fun filterAndContentChangesPreserveIdentityButUpdateContents() {
+ val idle = items(audio = listOf(pending(7))).single { it is TaskListDisplayItem.Audio }
+ val active = items(
+ audio = listOf(pending(7)),
+ transcription = TranscriptionTaskSnapshot(active = true, activeEntryId = 7, phase = "Transcribing", percent = 20),
+ ).single { it is TaskListDisplayItem.Audio }
+
+ assertTrue(TaskListDisplayItemDiff.areItemsTheSame(idle, active))
+ assertFalse(TaskListDisplayItemDiff.areContentsTheSame(idle, active))
+ assertEquals("Transcribing", (active as TaskListDisplayItem.Audio).task.progress?.phase)
+ assertEquals(20, active.task.progress?.percent)
+
+ val processed = items(filter = TaskListFilter.PROCESSED, audio = listOf(pending(7)))
+ assertTrue(processed.single() is TaskListDisplayItem.Empty)
+ assertNotEquals(idle.stableKey, processed.single().stableKey)
+ }
+
+ @Test
+ fun diffUsesTypedPayloadOnlyForProgressOnlyChanges() {
+ val original = items(audio = listOf(pending(7)))
+ .filterIsInstance()
+ .single()
+ val progress = TaskProgressPresentation(
+ phase = "Transcribing",
+ percent = 35,
+ processedUs = 3_000_000,
+ durationUs = 10_000_000,
+ completedFiles = 1,
+ totalFiles = 3,
+ )
+ val progressUpdate = original.copy(task = original.task.copy(progress = progress))
+ val badgeUpdate = progressUpdate.copy(task = progressUpdate.task.copy(badge = "Changed"))
+ val actionsUpdate = progressUpdate.copy(task = progressUpdate.task.copy(actions = emptyList()))
+
+ assertEquals(
+ TaskListChangePayload.Progress(progress),
+ TaskListDisplayItemDiff.getChangePayload(original, progressUpdate),
+ )
+ assertEquals(null, TaskListDisplayItemDiff.getChangePayload(progressUpdate, badgeUpdate))
+ assertEquals(null, TaskListDisplayItemDiff.getChangePayload(progressUpdate, actionsUpdate))
+ assertEquals(
+ TaskListAdapter.stableLongId(original.stableKey),
+ TaskListAdapter.stableLongId(progressUpdate.stableKey),
+ )
+ }
+
+ @Test
+ fun emptyStateCarriesOnlyPresentedTypedActions() {
+ val item = items().single() as TaskListDisplayItem.Empty
+
+ assertEquals("No new tasks", item.message)
+ assertEquals(
+ listOf(TaskActionKind.IMPORT_AUDIO),
+ item.actions.map { it.kind },
+ )
+ }
+
+ private fun items(
+ filter: TaskListFilter = TaskListFilter.NEW,
+ model: ModelSetupSnapshot = ModelSetupSnapshot(ModelSetupSnapshotState.READY),
+ output: OutputSetupSnapshot = OutputSetupSnapshot(OutputSetupSnapshotState.READY),
+ audio: List = emptyList(),
+ transcription: TranscriptionTaskSnapshot = TranscriptionTaskSnapshot(),
+ ): List = TaskListDisplayItems.from(
+ TaskListPresentationController.state(
+ TaskListInput(
+ filter = filter,
+ model = model,
+ output = output,
+ folder = FolderSetupSnapshot(FolderSetupSnapshotState.READY),
+ audio = audio,
+ transcription = transcription,
+ ),
+ ),
+ )
+
+ private fun pending(id: Long) = AudioTaskSnapshot(
+ entryId = id,
+ title = "$id.ogg",
+ state = AudioFileState.PENDING,
+ importedAtMillis = id,
+ )
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 921b511..034e7d1 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -10,6 +10,8 @@ material = "1.13.0"
activity = "1.13.0"
constraintlayout = "2.2.1"
sqldelight = "2.1.0"
+lifecycle = "2.9.4"
+recyclerview = "1.2.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -24,6 +26,9 @@ sqldelight-android-driver = { group = "app.cash.sqldelight", name = "android-dri
sqldelight-native-driver = { group = "app.cash.sqldelight", name = "native-driver", version.ref = "sqldelight" }
sqldelight-runtime = { group = "app.cash.sqldelight", name = "runtime", version.ref = "sqldelight" }
sqldelight-sqlite-driver = { group = "app.cash.sqldelight", name = "sqlite-driver", version.ref = "sqldelight" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
+androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/iosApp/VoiceInbox.xcodeproj/project.pbxproj b/iosApp/VoiceInbox.xcodeproj/project.pbxproj
index e9ff5f6..559a8da 100644
--- a/iosApp/VoiceInbox.xcodeproj/project.pbxproj
+++ b/iosApp/VoiceInbox.xcodeproj/project.pbxproj
@@ -19,8 +19,44 @@
101010101010101010101036 /* IosSingleFileTranscriptionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101010101010101010101037 /* IosSingleFileTranscriptionController.swift */; };
101010101010101010101039 /* IosSpeechModelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101010101010101010101041 /* IosSpeechModelStore.swift */; };
101010101010101010101040 /* IosSpeechModelDirectoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101010101010101010101042 /* IosSpeechModelDirectoryPicker.swift */; };
+ 18B94FCC65FAAF5B349BF28B /* IosSharedImportStaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF805AFB19C13EC1D442253 /* IosSharedImportStaging.swift */; };
+ 361EE03677F4524C001986C0 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2A88E9CFF2023E878C6EC22 /* ShareViewController.swift */; };
+ 5E50C86D75D247316B5953AB /* VoiceInboxShareExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 242890FE4BBC56636FBA9F10 /* VoiceInboxShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ 9FAC76B26E6BD4116D968475 /* IosSharedImportStaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD030301C0E14C9932DCBFE6 /* IosSharedImportStaging.swift */; };
+ A10000000000000000000001 /* DeferredSpeechModelLoadingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000002 /* DeferredSpeechModelLoadingTests.swift */; };
/* End PBXBuildFile section */
+/* Begin PBXContainerItemProxy section */
+ B02BB42352D5BAE85F16E7A7 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 101010101010101010101010 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = D93524F1DBD24C87F8DF0EB9;
+ remoteInfo = VoiceInboxShareExtension;
+ };
+ A1000000000000000000000A /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 101010101010101010101010 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 101010101010101010101007;
+ remoteInfo = VoiceInbox;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 5885D5F7784EAD2E2CF6BB3D /* Embed App Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ 5E50C86D75D247316B5953AB /* VoiceInboxShareExtension.appex in Embed App Extensions */,
+ );
+ name = "Embed App Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
/* Begin PBXFileReference section */
101010101010101010101003 /* VoiceInbox.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoiceInbox.app; sourceTree = BUILT_PRODUCTS_DIR; };
101010101010101010101011 /* VoiceInboxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceInboxApp.swift; sourceTree = ""; };
@@ -35,6 +71,15 @@
101010101010101010101037 /* IosSingleFileTranscriptionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosSingleFileTranscriptionController.swift; sourceTree = ""; };
101010101010101010101041 /* IosSpeechModelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosSpeechModelStore.swift; sourceTree = ""; };
101010101010101010101042 /* IosSpeechModelDirectoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosSpeechModelDirectoryPicker.swift; sourceTree = ""; };
+ 1DF805AFB19C13EC1D442253 /* IosSharedImportStaging.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IosSharedImportStaging.swift; sourceTree = ""; };
+ 242890FE4BBC56636FBA9F10 /* VoiceInboxShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VoiceInboxShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ A2A88E9CFF2023E878C6EC22 /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; };
+ A3B22C5C663FBCBBFAB0A81F /* VoiceInboxShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = VoiceInboxShareExtension.entitlements; sourceTree = ""; };
+ CD030301C0E14C9932DCBFE6 /* IosSharedImportStaging.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = IosSharedImportStaging.swift; path = ../VoiceInbox/IosSharedImportStaging.swift; sourceTree = ""; };
+ EF9FEAB9A5BF00B4E66B996C /* VoiceInbox.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = VoiceInbox.entitlements; sourceTree = ""; };
+ F63411837D138D0E90F5BA1D /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ A10000000000000000000002 /* DeferredSpeechModelLoadingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredSpeechModelLoadingTests.swift; sourceTree = ""; };
+ A10000000000000000000003 /* VoiceInboxTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = VoiceInboxTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -45,6 +90,20 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 9628A0A1DCA14071F9ADBFB8 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A10000000000000000000004 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -53,6 +112,8 @@
children = (
101010101010101010101013 /* VoiceInbox */,
101010101010101010101006 /* Products */,
+ F139417D5AA7BD6EFD2ECADD /* VoiceInboxShareExtension */,
+ A10000000000000000000005 /* VoiceInboxTests */,
);
sourceTree = "";
};
@@ -60,6 +121,8 @@
isa = PBXGroup;
children = (
101010101010101010101003 /* VoiceInbox.app */,
+ 242890FE4BBC56636FBA9F10 /* VoiceInboxShareExtension.appex */,
+ A10000000000000000000003 /* VoiceInboxTests.xctest */,
);
name = Products;
sourceTree = "";
@@ -79,10 +142,31 @@
101010101010101010101026 /* IosMainScreenShellState.swift */,
101010101010101010101024 /* SettingsView.swift */,
101010101010101010101034 /* Assets.xcassets */,
+ 1DF805AFB19C13EC1D442253 /* IosSharedImportStaging.swift */,
+ EF9FEAB9A5BF00B4E66B996C /* VoiceInbox.entitlements */,
);
path = VoiceInbox;
sourceTree = "";
};
+ F139417D5AA7BD6EFD2ECADD /* VoiceInboxShareExtension */ = {
+ isa = PBXGroup;
+ children = (
+ A2A88E9CFF2023E878C6EC22 /* ShareViewController.swift */,
+ CD030301C0E14C9932DCBFE6 /* IosSharedImportStaging.swift */,
+ F63411837D138D0E90F5BA1D /* Info.plist */,
+ A3B22C5C663FBCBBFAB0A81F /* VoiceInboxShareExtension.entitlements */,
+ );
+ path = VoiceInboxShareExtension;
+ sourceTree = "";
+ };
+ A10000000000000000000005 /* VoiceInboxTests */ = {
+ isa = PBXGroup;
+ children = (
+ A10000000000000000000002 /* DeferredSpeechModelLoadingTests.swift */,
+ );
+ path = VoiceInboxTests;
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -95,16 +179,53 @@
101010101010101010101009 /* Sources */,
101010101010101010101035 /* Resources */,
101010101010101010101004 /* Frameworks */,
+ 5885D5F7784EAD2E2CF6BB3D /* Embed App Extensions */,
);
buildRules = (
);
dependencies = (
+ 7AAEC850980B5408864DC85C /* PBXTargetDependency */,
);
name = VoiceInbox;
productName = VoiceInbox;
productReference = 101010101010101010101003 /* VoiceInbox.app */;
productType = "com.apple.product-type.application";
};
+ D93524F1DBD24C87F8DF0EB9 /* VoiceInboxShareExtension */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = AB4D7BF990566F2ADFD3580A /* Build configuration list for PBXNativeTarget "VoiceInboxShareExtension" */;
+ buildPhases = (
+ 76207A5A815CD15CCD17A632 /* Sources */,
+ 9628A0A1DCA14071F9ADBFB8 /* Frameworks */,
+ 84DEA1E9E8C3D8ECF1E3BF03 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = VoiceInboxShareExtension;
+ productName = VoiceInboxShareExtension;
+ productReference = 242890FE4BBC56636FBA9F10 /* VoiceInboxShareExtension.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
+ A10000000000000000000006 /* VoiceInboxTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = A1000000000000000000000D /* Build configuration list for PBXNativeTarget "VoiceInboxTests" */;
+ buildPhases = (
+ A10000000000000000000008 /* Sources */,
+ A10000000000000000000004 /* Frameworks */,
+ A10000000000000000000007 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ A10000000000000000000009 /* PBXTargetDependency */,
+ );
+ name = VoiceInboxTests;
+ productName = VoiceInboxTests;
+ productReference = A10000000000000000000003 /* VoiceInboxTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -118,6 +239,9 @@
101010101010101010101007 = {
CreatedOnToolsVersion = 16.0;
};
+ A10000000000000000000006 = {
+ CreatedOnToolsVersion = 16.0;
+ };
};
};
buildConfigurationList = 101010101010101010101016 /* Build configuration list for PBXProject "VoiceInbox" */;
@@ -134,6 +258,8 @@
projectRoot = "";
targets = (
101010101010101010101007 /* VoiceInbox */,
+ D93524F1DBD24C87F8DF0EB9 /* VoiceInboxShareExtension */,
+ A10000000000000000000006 /* VoiceInboxTests */,
);
};
/* End PBXProject section */
@@ -147,6 +273,20 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 84DEA1E9E8C3D8ECF1E3BF03 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A10000000000000000000007 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -210,11 +350,43 @@
101010101010101010101021 /* IosScheduledTranscriptionSettingsStore.swift in Sources */,
101010101010101010101025 /* IosMainScreenShellState.swift in Sources */,
101010101010101010101022 /* SettingsView.swift in Sources */,
+ 18B94FCC65FAAF5B349BF28B /* IosSharedImportStaging.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 76207A5A815CD15CCD17A632 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 361EE03677F4524C001986C0 /* ShareViewController.swift in Sources */,
+ 9FAC76B26E6BD4116D968475 /* IosSharedImportStaging.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A10000000000000000000008 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A10000000000000000000001 /* DeferredSpeechModelLoadingTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
+/* Begin PBXTargetDependency section */
+ 7AAEC850980B5408864DC85C /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ name = VoiceInboxShareExtension;
+ target = D93524F1DBD24C87F8DF0EB9 /* VoiceInboxShareExtension */;
+ targetProxy = B02BB42352D5BAE85F16E7A7 /* PBXContainerItemProxy */;
+ };
+ A10000000000000000000009 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 101010101010101010101007 /* VoiceInbox */;
+ targetProxy = A1000000000000000000000A /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
/* Begin XCBuildConfiguration section */
101010101010101010101017 /* Debug */ = {
isa = XCBuildConfiguration;
@@ -330,8 +502,9 @@
101010101010101010101019 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
- ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = VoiceInbox/VoiceInbox.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 2835P3MJR8;
@@ -380,8 +553,9 @@
101010101010101010101020 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
- ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = VoiceInbox/VoiceInbox.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 2835P3MJR8;
@@ -425,6 +599,91 @@
};
name = Release;
};
+ 647FF692FFBAEA14F829FBEF /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ CLANG_ENABLE_OBJC_WEAK = NO;
+ CODE_SIGN_ENTITLEMENTS = VoiceInboxShareExtension/VoiceInboxShareExtension.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 2835P3MJR8;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = VoiceInboxShareExtension/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ MARKETING_VERSION = 0.1;
+ PRODUCT_BUNDLE_IDENTIFIER = me.maxistar.voiceinbox.ios.share;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SKIP_INSTALL = YES;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 912904D2DC8D23268FD953E8 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ CLANG_ENABLE_OBJC_WEAK = NO;
+ CODE_SIGN_ENTITLEMENTS = VoiceInboxShareExtension/VoiceInboxShareExtension.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 2835P3MJR8;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = VoiceInboxShareExtension/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ MARKETING_VERSION = 0.1;
+ PRODUCT_BUNDLE_IDENTIFIER = me.maxistar.voiceinbox.ios.share;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SKIP_INSTALL = YES;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ A1000000000000000000000B /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_TEAM = 2835P3MJR8;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ PRODUCT_BUNDLE_IDENTIFIER = me.maxistar.voiceinbox.ios.tests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VoiceInbox.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VoiceInbox";
+ };
+ name = Debug;
+ };
+ A1000000000000000000000C /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_TEAM = 2835P3MJR8;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ PRODUCT_BUNDLE_IDENTIFIER = me.maxistar.voiceinbox.ios.tests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VoiceInbox.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VoiceInbox";
+ };
+ name = Release;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -446,6 +705,24 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ AB4D7BF990566F2ADFD3580A /* Build configuration list for PBXNativeTarget "VoiceInboxShareExtension" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 912904D2DC8D23268FD953E8 /* Release */,
+ 647FF692FFBAEA14F829FBEF /* Debug */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ A1000000000000000000000D /* Build configuration list for PBXNativeTarget "VoiceInboxTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A1000000000000000000000B /* Debug */,
+ A1000000000000000000000C /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
};
rootObject = 101010101010101010101010 /* Project object */;
diff --git a/iosApp/VoiceInbox.xcodeproj/xcshareddata/xcschemes/VoiceInbox.xcscheme b/iosApp/VoiceInbox.xcodeproj/xcshareddata/xcschemes/VoiceInbox.xcscheme
new file mode 100644
index 0000000..ea1695e
--- /dev/null
+++ b/iosApp/VoiceInbox.xcodeproj/xcshareddata/xcschemes/VoiceInbox.xcscheme
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/iosApp/VoiceInbox.xcodeproj/xcshareddata/xcschemes/VoiceInboxShareExtension.xcscheme b/iosApp/VoiceInbox.xcodeproj/xcshareddata/xcschemes/VoiceInboxShareExtension.xcscheme
new file mode 100644
index 0000000..3924b37
--- /dev/null
+++ b/iosApp/VoiceInbox.xcodeproj/xcshareddata/xcschemes/VoiceInboxShareExtension.xcscheme
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/iosApp/VoiceInbox/ContentView.swift b/iosApp/VoiceInbox/ContentView.swift
index cdc48d7..74d158d 100644
--- a/iosApp/VoiceInbox/ContentView.swift
+++ b/iosApp/VoiceInbox/ContentView.swift
@@ -4,355 +4,135 @@ import SwiftUI
struct ContentView: View {
private let shellState = IosMainScreenShellState()
+ @Environment(\.scenePhase) private var scenePhase
@StateObject private var importStore = IosAudioImportStore()
@StateObject private var outputStore = IosOutputDocumentStore()
@StateObject private var previewPlayer = IosAudioPreviewPlayer()
@StateObject private var speechModelStore = IosSpeechModelStore()
@StateObject private var transcriber = IosSingleFileTranscriptionController()
+ @StateObject private var startupPolicyStore = IosStartupProcessingPolicyStore()
@State private var selectedTab = IosShellCatalogSelection.new
- @State private var showingImporter = false
- @State private var showingInboxFolderPicker = false
- @State private var showingOutputPicker = false
- @State private var showingModelImporter = false
+ @State private var presentedPicker: IosPresentedPicker?
@State private var shownTranscript: String?
+ @State private var startupProcessingChecked = false
+ @State private var startupFolderRefreshChecked = false
+ @State private var startupProcessingPrompt: IosStartupProcessingPrompt?
var body: some View {
let transcriptionBackendConfigured = transcriber.backendConfigured
let speechModelReady = speechModelStore.isReady
let outputReady = outputStore.isReady
- let storageSetupComplete = outputReady && !importStore.inboxFolderStatus.needsSelection
let transcriptionReady = transcriptionBackendConfigured && speechModelReady && !speechModelStore.isBusy && outputReady
- let modelStatusMessage = iOSModelStatusMessage(
- transcriptionBackendConfigured: transcriptionBackendConfigured,
- speechModelReady: speechModelReady
- )
let modelDownloadAvailable = !speechModelReady && !speechModelStore.isBusy
- let modelSetupVisible = !transcriptionBackendConfigured || !speechModelReady || speechModelStore.isBusy
let screen = shellState.screen(
selection: selectedTab,
importedFiles: importStore.files,
- runtimeReady: transcriptionBackendConfigured,
- modelReady: speechModelReady,
+ modelStatus: speechModelStore.status,
+ modelMessage: speechModelStore.message,
modelInstalling: speechModelStore.isInstalling,
+ modelInstallationPhase: speechModelStore.downloadProgress?.message ?? speechModelStore.message,
modelDownloadAvailable: modelDownloadAvailable,
modelDownloadProgress: speechModelStore.downloadProgress?.percent,
- modelMessage: modelStatusMessage,
- outputReady: outputReady,
+ modelCanCancel: speechModelStore.canCancelDownload,
+ outputStatus: outputStore.status,
+ folderStatus: importStore.inboxFolderStatus,
+ folderScanning: importStore.isScanningFolder,
activePreviewEntryId: previewPlayer.playingFileId,
previewState: previewPlayer.playingFileId == nil ? PreviewPlaybackState.idle : PreviewPlaybackState.playing,
- transcription: transcriber.state
+ transcription: transcriber.state,
+ preparationOwnerEntryId: transcriber.preparationOwnerFileId,
+ prerequisiteError: transcriber.prerequisiteError,
+ actionsEnabled: transcriptionReady && !transcriber.isActive && !importStore.isScanningFolder
)
NavigationStack {
List {
-
- Section("Status") {
- VStack(alignment: .leading, spacing: 8) {
- Text(screen.state.status.title)
- .font(.headline)
- if let detail = screen.state.status.detail {
- Text(detail)
- .foregroundStyle(.secondary)
- }
- if let meta = screen.state.status.meta {
- Text(meta)
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
- }
-
+ Section {
Picker("Catalog", selection: $selectedTab) {
ForEach(IosShellCatalogSelection.allCases) { tab in
Text(tab.title).tag(tab)
}
}
.pickerStyle(.segmented)
-
- if screen.state.list.transcribeAllVisible {
- Button {
- guard let outputDocument = outputStore.currentDocument() else {
- outputStore.refreshAccess()
- return
- }
- previewPlayer.stop()
- transcriber.transcribeAll(
- modelDirectory: speechModelStore.modelDirectory,
- outputDocument: outputDocument,
- store: importStore,
- onFinished: {
- selectedTab = .processed
- }
- )
- } label: {
- Label("Transcribe All", systemImage: "text.badge.checkmark")
- }
- .disabled(!screen.state.list.transcribeAllEnabled)
- }
-
- Button {
- showingImporter = true
- } label: {
- Label("Import Audio Files", systemImage: "square.and.arrow.down")
- }
-
- if !outputReady {
- VStack(alignment: .leading, spacing: 6) {
- Text(outputStore.status.title)
- .font(.headline)
- Text(outputStore.status.message)
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
-
- Button {
- showingOutputPicker = true
- } label: {
- Label("Select Output File", systemImage: "doc.badge.plus")
- }
- }
-
- if importStore.inboxFolderStatus.needsSelection {
- VStack(alignment: .leading, spacing: 6) {
- Text(importStore.inboxFolderStatus.title)
- .font(.headline)
- if let message = importStore.inboxFolderStatus.message {
- Text(message)
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
- }
-
- Button {
- showingInboxFolderPicker = true
- } label: {
- Label("Select Audio Folder", systemImage: "folder")
- }
- .disabled(importStore.isScanningFolder)
- }
-
- if !importStore.inboxFolderStatus.needsSelection {
- Button {
- importStore.refreshInboxFolder()
- selectedTab = .new
- } label: {
- Label("Refresh Audio Folder", systemImage: "arrow.clockwise")
- }
- .disabled(importStore.isScanningFolder)
- }
-
- if importStore.isScanningFolder {
- ProgressView()
- }
+ .accessibilityIdentifier("task-list-filter")
}
Section {
- if screen.state.list.emptyVisible {
+ if let emptyMessage = screen.state.emptyMessage {
VStack(alignment: .leading, spacing: 8) {
- Text(screen.state.list.emptyMessage)
+ Text(emptyMessage)
.foregroundStyle(.secondary)
- Button {
- showingInboxFolderPicker = true
- } label: {
- Label("Select Audio Folder", systemImage: "folder")
- }
- Button {
- showingImporter = true
- } label: {
- Label("Import Audio Files", systemImage: "square.and.arrow.down")
- }
- }
- } else {
- ForEach(screen.rows) { row in
- VStack(alignment: .leading, spacing: 8) {
- HStack(alignment: .top) {
- VStack(alignment: .leading, spacing: 4) {
- Text(row.title)
- .font(.headline)
- Text(row.subtitle)
- .font(.subheadline)
- .foregroundStyle(.secondary)
- }
-
- Spacer()
-
- Text(row.badge)
- .font(.caption)
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(.thinMaterial)
- .clipShape(Capsule())
+ ForEach(Array(screen.state.emptyActions.enumerated()), id: \.offset) { _, action in
+ Button(action.label) {
+ perform(action: action, task: nil, screen: screen)
}
-
- HStack {
- if let importedFile = importedFile(for: row) {
- Button {
- previewPlayer.toggle(
- fileId: importedFile.id,
- url: importStore.localURL(for: importedFile)
- )
- } label: {
- Label(
- previewPlayer.playingFileId == importedFile.id ? "Stop" : row.state.preview.label,
- systemImage: previewPlayer.playingFileId == importedFile.id ? "stop.fill" : "play.fill"
- )
- }
- .disabled(
- transcriber.isActive ||
- !row.state.preview.enabled
- )
-
- if importedFile.status == .pending {
- Button {
- guard let outputDocument = outputStore.currentDocument() else {
- outputStore.refreshAccess()
- return
- }
- transcriber.transcribe(
- file: importedFile,
- localURL: importStore.localURL(for: importedFile),
- modelDirectory: speechModelStore.modelDirectory,
- outputDocument: outputDocument,
- store: importStore,
- onSuccess: { transcript in
- shownTranscript = transcript
- selectedTab = .processed
- }
- )
- } label: {
- Label("Transcribe", systemImage: "text.badge.checkmark")
- }
- .disabled(
- transcriber.isActive ||
- !transcriptionReady
- )
- }
-
- if importedFile.status == .failed {
- Button {
- guard let outputDocument = outputStore.currentDocument() else {
- outputStore.refreshAccess()
- return
- }
- transcriber.retry(
- file: importedFile,
- localURL: importStore.localURL(for: importedFile),
- modelDirectory: speechModelStore.modelDirectory,
- outputDocument: outputDocument,
- store: importStore,
- onSuccess: { transcript in
- shownTranscript = transcript
- selectedTab = .processed
- }
- )
- } label: {
- Label("Retry", systemImage: "arrow.clockwise")
- }
- .disabled(
- transcriber.isActive ||
- !transcriptionReady
- )
- }
- }
-
- if row.state.showTextVisible {
- Button("Show Text") {
- shownTranscript = row.transcriptText
- }
- }
- }
- .buttonStyle(.bordered)
- .font(.caption)
+ .buttonStyle(.borderless)
+ .disabled(!action.enabled)
}
- .padding(.vertical, 4)
- }
- }
- } header: {
- if !storageSetupComplete {
- Text(selectedTab.title)
- }
- }
-
- if let importMessage = importStore.importMessage {
- Section("Import result") {
- Text(importMessage)
- .foregroundStyle(.secondary)
- }
- }
-
- if let playbackError = previewPlayer.errorMessage {
- Section("Playback") {
- Text(playbackError)
- .foregroundStyle(.secondary)
- Button("Dismiss") {
- previewPlayer.clearError()
}
- }
- }
-
- if modelSetupVisible {
- Section("Transcription Setup") {
- if !transcriptionBackendConfigured {
- Text(IosSingleFileTranscriptionController.backendUnavailableMessage)
- .foregroundStyle(.secondary)
- }
- VStack(alignment: .leading, spacing: 6) {
- Text(speechModelStore.status.summary)
- if speechModelStore.isInstalling {
- if let progress = speechModelStore.downloadProgress {
- ProgressView(value: Double(progress.percent), total: 100)
- Text("\(progress.percent)% • \(formatBytes(progress.bytesDownloaded)) of \(formatBytes(progress.totalBytes))")
- .font(.footnote)
- .foregroundStyle(.secondary)
- } else {
- ProgressView()
- }
- }
- if let detail = speechModelStore.status.detail {
- Text(detail)
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
- if let modelMessage = speechModelStore.message {
- Text(modelMessage)
- .font(.footnote)
- .foregroundStyle(.secondary)
+ } else {
+ ForEach(screen.state.tasks.filter { $0 is SetupTaskPresentation }, id: \.stableId) { task in
+ TaskListRow(task: task) { action in
+ perform(action: action, task: task, screen: screen)
}
+ .id(task.stableId)
+ .accessibilityIdentifier("task-row-\(task.stableId)")
}
- if modelDownloadAvailable {
+ if screen.state.batchAction.visible {
Button {
- speechModelStore.downloadModel()
+ transcribeAll()
} label: {
- Label("Download Speech Model", systemImage: "arrow.down.circle")
+ Label(
+ "Transcribe All (\(screen.state.batchAction.eligibleCount))",
+ systemImage: "text.badge.checkmark"
+ )
}
- .buttonStyle(.borderedProminent)
+ .disabled(!screen.state.batchAction.enabled)
+ .accessibilityIdentifier("transcribe-all")
}
- if !speechModelReady {
- Button {
- showingModelImporter = true
- } label: {
- Label("Install Speech Model Manually", systemImage: "square.and.arrow.down")
+ ForEach(screen.state.tasks.filter { $0 is AudioTaskPresentation }, id: \.stableId) { task in
+ TaskListRow(task: task) { action in
+ perform(action: action, task: task, screen: screen)
}
- .disabled(speechModelStore.isBusy)
+ .id(task.stableId)
+ .accessibilityIdentifier("task-row-\(task.stableId)")
}
-
}
}
+ Section {
+ Button {
+ presentPicker(.audioFiles)
+ } label: {
+ Label("Import Audio Files", systemImage: "square.and.arrow.down")
+ }
+ if !importStore.inboxFolderStatus.needsSelection {
+ Button {
+ importStore.refreshInboxFolder()
+ selectedTab = .new
+ } label: {
+ Label("Refresh Audio Folder", systemImage: "arrow.clockwise")
+ }
+ .disabled(importStore.isScanningFolder)
+ }
+ }
}
.navigationTitle("Voice Inbox")
+ .navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
NavigationLink {
SettingsView(
importStore: importStore,
outputStore: outputStore,
+ startupPolicyStore: startupPolicyStore,
selectInboxFolder: {
- showingInboxFolderPicker = true
+ presentPicker(.audioFolder)
},
selectOutputFile: {
- showingOutputPicker = true
+ presentPicker(.outputFile)
}
)
} label: {
@@ -361,35 +141,44 @@ struct ContentView: View {
.accessibilityLabel("Settings")
}
}
- .sheet(isPresented: $showingImporter) {
- IosAudioDocumentPicker { urls in
- importStore.importFiles(from: urls)
- showingImporter = false
- selectedTab = .new
- }
- }
- .sheet(isPresented: $showingInboxFolderPicker) {
- IosSpeechModelDirectoryPicker { url in
- importStore.selectInboxFolder(url)
- showingInboxFolderPicker = false
- selectedTab = .new
- }
- }
- .sheet(isPresented: $showingOutputPicker) {
- IosOutputDocumentPicker { url in
- outputStore.selectOutputFile(url)
- showingOutputPicker = false
- }
- }
- .sheet(isPresented: $showingModelImporter) {
- IosSpeechModelDirectoryPicker { url in
- speechModelStore.installModel(from: url)
- showingModelImporter = false
+ .sheet(item: $presentedPicker) { picker in
+ switch picker {
+ case .audioFiles:
+ IosAudioDocumentPicker { urls in
+ importStore.importFiles(from: urls)
+ presentedPicker = nil
+ selectedTab = .new
+ }
+ case .audioFolder:
+ IosSpeechModelDirectoryPicker { url in
+ importStore.selectInboxFolder(url)
+ presentedPicker = nil
+ selectedTab = .new
+ }
+ case .outputFile:
+ IosOutputDocumentPicker { url in
+ outputStore.selectOutputFile(url)
+ presentedPicker = nil
+ }
+ case .speechModelFolder:
+ IosSpeechModelDirectoryPicker { url in
+ speechModelStore.installModel(from: url)
+ presentedPicker = nil
+ }
}
}
.onChange(of: importStore.files) { files in
previewPlayer.stopIfUnavailable(availableFileIds: Set(files.map(\.id)))
}
+ .onAppear {
+ refreshStartupSources()
+ evaluateStartupProcessingIfNeeded()
+ }
+ .onChange(of: scenePhase) { phase in
+ guard phase == .active else { return }
+ refreshStartupSources()
+ evaluateStartupProcessingIfNeeded()
+ }
.sheet(item: transcriptBinding) { transcript in
NavigationStack {
ScrollView {
@@ -407,6 +196,31 @@ struct ContentView: View {
}
}
}
+ .sheet(item: $startupProcessingPrompt) { prompt in
+ StartupProcessingPromptView(
+ pendingCount: prompt.pendingCount,
+ onYes: { alwaysAtStartup in
+ startupProcessingPrompt = nil
+ if alwaysAtStartup {
+ startupPolicyStore.policy = .yes
+ }
+ startStartupProcessing()
+ },
+ onNo: { alwaysAtStartup in
+ startupProcessingPrompt = nil
+ if alwaysAtStartup {
+ startupPolicyStore.policy = .no
+ }
+ }
+ )
+ }
+ .alert(item: globalMessageBinding) { message in
+ Alert(
+ title: Text(message.title),
+ message: Text(message.text),
+ dismissButton: .default(Text("OK"))
+ )
+ }
}
}
@@ -421,39 +235,333 @@ struct ContentView: View {
)
}
- private func importedFile(for row: IosShellAudioRow) -> IosImportedAudioFile? {
- guard row.imported else { return nil }
- return importStore.files.first { $0.id == row.id }
+ private var globalMessageBinding: Binding {
+ Binding(
+ get: {
+ if let error = previewPlayer.errorMessage {
+ return IosGlobalMessage(title: "Playback", text: error)
+ }
+ if let message = importStore.importMessage {
+ return IosGlobalMessage(title: "Voice Inbox", text: message)
+ }
+ return nil
+ },
+ set: { value in
+ guard value == nil else { return }
+ previewPlayer.clearError()
+ importStore.importMessage = nil
+ }
+ )
+ }
+
+ private func perform(
+ action: TaskActionPresentation,
+ task: TaskPresentation?,
+ screen: IosTaskListScreen
+ ) {
+ guard action.enabled, let route = IosTaskActionRouter.route(action.kind) else { return }
+ switch route {
+ case .modelDownload:
+ speechModelStore.downloadModel()
+ case .modelImport:
+ presentPicker(.speechModelFolder)
+ case .modelCancel:
+ speechModelStore.cancelDownload()
+ case .outputSelection:
+ presentPicker(.outputFile)
+ case .folderSelection:
+ presentPicker(.audioFolder)
+ case .folderRefresh:
+ importStore.refreshInboxFolder()
+ selectedTab = .new
+ case .audioImport:
+ presentPicker(.audioFiles)
+ case .transcribe, .retry, .play, .stop, .showText:
+ guard let audioTask = task as? AudioTaskPresentation,
+ let file = screen.filesById[audioTask.entryId] else { return }
+ performAudio(action: action.kind, file: file)
+ }
+ }
+
+ private func presentPicker(_ picker: IosPresentedPicker) {
+ guard presentedPicker == nil else { return }
+ presentedPicker = picker
+ }
+
+ private func performAudio(action: TaskActionKind, file: IosImportedAudioFile) {
+ switch action {
+ case .play:
+ previewPlayer.toggle(fileId: file.id, url: importStore.localURL(for: file))
+ case .stop:
+ previewPlayer.stop()
+ case .showText:
+ shownTranscript = file.transcriptText
+ case .transcribe, .retryTranscription:
+ guard let outputDocument = outputStore.currentDocument() else {
+ outputStore.refreshAccess()
+ return
+ }
+ previewPlayer.stop()
+ let onSuccess: (String) -> Void = { transcript in
+ shownTranscript = transcript
+ selectedTab = .processed
+ }
+ if action == .retryTranscription {
+ transcriber.retry(
+ file: file,
+ localURL: importStore.localURL(for: file),
+ modelDirectory: speechModelStore.modelDirectory,
+ modelStore: speechModelStore,
+ outputDocument: outputDocument,
+ store: importStore,
+ onSuccess: onSuccess
+ )
+ } else {
+ transcriber.transcribe(
+ file: file,
+ localURL: importStore.localURL(for: file),
+ modelDirectory: speechModelStore.modelDirectory,
+ modelStore: speechModelStore,
+ outputDocument: outputDocument,
+ store: importStore,
+ onSuccess: onSuccess
+ )
+ }
+ default:
+ break
+ }
}
- private func iOSModelStatusMessage(
- transcriptionBackendConfigured: Bool,
- speechModelReady: Bool
- ) -> String {
- if !transcriptionBackendConfigured {
- return IosSingleFileTranscriptionController.backendUnavailableMessage
+ private func transcribeAll() {
+ guard let outputDocument = outputStore.currentDocument() else {
+ outputStore.refreshAccess()
+ return
}
- if let progress = speechModelStore.downloadProgress {
- return progress.message
+ previewPlayer.stop()
+ transcriber.transcribeAll(
+ modelDirectory: speechModelStore.modelDirectory,
+ modelStore: speechModelStore,
+ outputDocument: outputDocument,
+ store: importStore,
+ onFinished: { selectedTab = .processed }
+ )
+ }
+
+ private func refreshStartupSources() {
+ var foundNewWork = false
+ if importStore.ingestSharedImports()?.imported ?? 0 > 0 {
+ foundNewWork = true
}
- if speechModelStore.isInstalling {
- return "Installing speech model..."
+
+ if !startupFolderRefreshChecked,
+ !importStore.inboxFolderStatus.needsSelection {
+ startupFolderRefreshChecked = true
+ let pendingBeforeRefresh = importStore.pendingCount
+ importStore.refreshInboxFolder()
+ foundNewWork = foundNewWork || importStore.pendingCount > pendingBeforeRefresh
+ }
+
+ if foundNewWork {
+ selectedTab = .new
+ startupProcessingChecked = false
}
- if !speechModelReady {
- return speechModelStore.status.summary
+ }
+
+ private func evaluateStartupProcessingIfNeeded() {
+ guard !startupProcessingChecked else { return }
+ startupProcessingChecked = true
+ guard importStore.pendingCount > 0 else { return }
+
+ switch startupPolicyStore.policy {
+ case .ask:
+ startupProcessingPrompt = IosStartupProcessingPrompt(pendingCount: importStore.pendingCount)
+ case .yes:
+ startStartupProcessing()
+ case .no:
+ break
}
- return "Ready"
}
- private func formatBytes(_ bytes: Int64) -> String {
- let formatter = ByteCountFormatter()
- formatter.allowedUnits = [.useMB, .useGB]
- formatter.countStyle = .file
- return formatter.string(fromByteCount: bytes)
+ private func startStartupProcessing() {
+ guard importStore.pendingCount > 0 else { return }
+ guard !transcriber.isActive else {
+ importStore.importMessage = "Found files to process, but transcription is already running."
+ return
+ }
+ guard transcriber.backendConfigured else {
+ importStore.importMessage = "Found files to process, but the iOS transcription backend is not configured."
+ return
+ }
+ guard speechModelStore.isReady, !speechModelStore.isBusy else {
+ importStore.importMessage = "Found files to process, but the speech model is not ready."
+ return
+ }
+ guard let outputDocument = outputStore.currentDocument() else {
+ outputStore.refreshAccess()
+ importStore.importMessage = "Found files to process, but the output file is not ready."
+ return
+ }
+
+ previewPlayer.stop()
+ selectedTab = .new
+ transcriber.transcribeAll(
+ modelDirectory: speechModelStore.modelDirectory,
+ modelStore: speechModelStore,
+ outputDocument: outputDocument,
+ store: importStore,
+ onFinished: {
+ selectedTab = .processed
+ }
+ )
}
+
+}
+
+private enum IosPresentedPicker: String, Identifiable {
+ case audioFiles
+ case audioFolder
+ case outputFile
+ case speechModelFolder
+
+ var id: String { rawValue }
+}
+
+private struct TaskListRow: View {
+ let task: TaskPresentation
+ let onAction: (TaskActionPresentation) -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .top) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(task.title)
+ .font(.headline)
+ if let detail = task.detail, !detail.isEmpty {
+ Text(detail)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ Spacer()
+ Text(task.badge)
+ .font(.caption)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(.thinMaterial)
+ .clipShape(Capsule())
+ }
+
+ if let error = task.errorMessage, !error.isEmpty {
+ Text(error)
+ .font(.footnote)
+ .foregroundStyle(.red)
+ }
+
+ if let progress = task.progress {
+ VStack(alignment: .leading, spacing: 4) {
+ if let percent = progress.percent?.int32Value {
+ ProgressView(value: Double(percent), total: 100)
+ .accessibilityIdentifier("task-progress-\(task.stableId)")
+ } else {
+ ProgressView()
+ .accessibilityIdentifier("task-progress-\(task.stableId)")
+ }
+ Text(progressLabel(progress))
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ if !task.actions.isEmpty {
+ HStack {
+ ForEach(Array(task.actions.enumerated()), id: \.offset) { _, action in
+ Button(action.label) { onAction(action) }
+ .disabled(!action.enabled)
+ .accessibilityIdentifier("task-action-\(task.stableId)-\(action.kind.name.lowercased())")
+ }
+ }
+ .buttonStyle(.bordered)
+ .font(.caption)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+
+ private func progressLabel(_ progress: TaskProgressPresentation) -> String {
+ var parts = [progress.phase]
+ if let completed = progress.completedFiles?.int32Value,
+ let total = progress.totalFiles?.int32Value,
+ total > 0 {
+ parts.append("\(completed) / \(total) files")
+ }
+ if let failed = progress.failedFiles?.int32Value, failed > 0 {
+ parts.append("\(failed) failed")
+ }
+ return parts.joined(separator: " • ")
+ }
+}
+
+private struct IosGlobalMessage: Identifiable {
+ let id = UUID()
+ let title: String
+ let text: String
}
private struct IosDisplayedTranscript: Identifiable {
let id = UUID()
let text: String
}
+
+private struct IosStartupProcessingPrompt: Identifiable {
+ let id = UUID()
+ let pendingCount: Int
+}
+
+private struct StartupProcessingPromptView: View {
+ let pendingCount: Int
+ let onYes: (Bool) -> Void
+ let onNo: (Bool) -> Void
+
+ @State private var alwaysAtStartup = false
+
+ var body: some View {
+ NavigationStack {
+ VStack(alignment: .leading, spacing: 20) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Found files to process. Process now?")
+ .font(.title3)
+ .fontWeight(.semibold)
+ Text(summary)
+ .foregroundStyle(.secondary)
+ }
+
+ Toggle("Always do this at startup", isOn: $alwaysAtStartup)
+
+ Spacer()
+ }
+ .padding()
+ .navigationTitle("Process Files")
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("No") {
+ onNo(alwaysAtStartup)
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Yes") {
+ onYes(alwaysAtStartup)
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ }
+ .presentationDetents([.medium])
+ }
+
+ private var summary: String {
+ if pendingCount == 1 {
+ return "1 file is waiting in New."
+ }
+ return "\(pendingCount) files are waiting in New."
+ }
+}
diff --git a/iosApp/VoiceInbox/IosAudioImportStore.swift b/iosApp/VoiceInbox/IosAudioImportStore.swift
index 52faa0f..f1d6409 100644
--- a/iosApp/VoiceInbox/IosAudioImportStore.swift
+++ b/iosApp/VoiceInbox/IosAudioImportStore.swift
@@ -64,6 +64,7 @@ struct IosImportedAudioFile: Codable, Identifiable, Equatable {
var transcriptText: String?
var durationUs: Int64?
var lastError: String?
+ var processedAt: Date?
init(
id: Int64,
@@ -74,7 +75,8 @@ struct IosImportedAudioFile: Codable, Identifiable, Equatable {
status: IosImportedAudioStatus = .pending,
transcriptText: String? = nil,
durationUs: Int64? = nil,
- lastError: String? = nil
+ lastError: String? = nil,
+ processedAt: Date? = nil
) {
self.id = id
self.displayName = displayName
@@ -85,6 +87,7 @@ struct IosImportedAudioFile: Codable, Identifiable, Equatable {
self.transcriptText = transcriptText
self.durationUs = durationUs
self.lastError = lastError
+ self.processedAt = processedAt
}
init(from decoder: Decoder) throws {
@@ -98,6 +101,7 @@ struct IosImportedAudioFile: Codable, Identifiable, Equatable {
transcriptText = try container.decodeIfPresent(String.self, forKey: .transcriptText)
durationUs = try container.decodeIfPresent(Int64.self, forKey: .durationUs)
lastError = try container.decodeIfPresent(String.self, forKey: .lastError)
+ processedAt = try container.decodeIfPresent(Date.self, forKey: .processedAt)
}
var formattedSize: String {
@@ -112,6 +116,14 @@ struct IosAudioImportSummary {
let imported: Int
let skipped: Int
let failed: Int
+ let importedFileIds: [Int64]
+
+ init(imported: Int, skipped: Int, failed: Int, importedFileIds: [Int64] = []) {
+ self.imported = imported
+ self.skipped = skipped
+ self.failed = failed
+ self.importedFileIds = importedFileIds
+ }
var message: String {
var parts: [String] = []
@@ -120,12 +132,33 @@ struct IosAudioImportSummary {
if failed > 0 { parts.append("\(failed) failed") }
return parts.isEmpty ? "No files imported" : parts.joined(separator: ", ")
}
+
+ var alertMessage: String? {
+ failed > 0 ? message : nil
+ }
}
struct IosInboxFolderStatus {
let displayName: String?
let message: String?
let needsSelection: Bool
+ let hasError: Bool
+
+ init(displayName: String?, message: String?, needsSelection: Bool) {
+ self.init(
+ displayName: displayName,
+ message: message,
+ needsSelection: needsSelection,
+ hasError: false
+ )
+ }
+
+ init(displayName: String?, message: String?, needsSelection: Bool, hasError: Bool) {
+ self.displayName = displayName
+ self.message = message
+ self.needsSelection = needsSelection
+ self.hasError = hasError
+ }
var title: String {
displayName ?? "No audio folder selected"
@@ -163,7 +196,27 @@ final class IosAudioImportStore: ObservableObject {
func importFiles(from urls: [URL]) {
let summary = copySupportedFiles(from: urls)
load()
- importMessage = summary.message
+ importMessage = summary.alertMessage
+ }
+
+ @discardableResult
+ func ingestSharedImports() -> IosAudioImportSummary? {
+ guard let directory = try? IosSharedImportStaging.stagingDirectory(fileManager: fileManager) else { return nil }
+ guard let urls = try? fileManager.contentsOfDirectory(
+ at: directory,
+ includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
+ options: [.skipsHiddenFiles]
+ ) else {
+ return nil
+ }
+
+ let stagedFiles = urls.filter { $0.pathExtension.lowercased() != IosSharedImportStaging.manifestExtension }
+ guard !stagedFiles.isEmpty else { return nil }
+
+ let summary = copySupportedSharedFiles(from: stagedFiles)
+ load()
+ importMessage = summary.alertMessage
+ return summary
}
func selectInboxFolder(_ url: URL) {
@@ -207,10 +260,13 @@ final class IosAudioImportStore: ObservableObject {
isScanningFolder = false
inboxFolderStatus = IosInboxFolderStatus(
displayName: folder.url.lastPathComponent,
- message: summary.message,
- needsSelection: false
+ message: summary.failed > 0
+ ? "Audio folder scan failed. Check folder access and try again."
+ : summary.message,
+ needsSelection: false,
+ hasError: summary.failed > 0
)
- importMessage = summary.message
+ importMessage = nil
}
func localURL(for file: IosImportedAudioFile) -> URL {
@@ -221,6 +277,10 @@ final class IosAudioImportStore: ObservableObject {
importDirectory.appendingPathComponent(documentUri)
}
+ var pendingCount: Int {
+ files.count { $0.status == .pending }
+ }
+
func refresh() {
load()
}
@@ -241,7 +301,7 @@ final class IosAudioImportStore: ObservableObject {
}
func markFailed(fileId: Int64, error: String) {
- catalog.markFailed(id: fileId, message: error)
+ catalog.markFailedAt(id: fileId, message: error, processedAtMillis: currentTimeMillis())
load()
}
@@ -345,6 +405,7 @@ final class IosAudioImportStore: ObservableObject {
var imported = 0
var skipped = 0
var failed = 0
+ var importedFileIds: [Int64] = []
let existingByLocalName = Dictionary(uniqueKeysWithValues: files.map { ($0.localFileName, $0) })
for sourceURL in urls {
@@ -374,7 +435,7 @@ final class IosAudioImportStore: ObservableObject {
try fileManager.removeItem(at: targetURL)
}
try fileManager.copyItem(at: sourceURL, to: targetURL)
- _ = catalog.upsertImportedFile(
+ let importedFile = catalog.upsertImportedFile(
folderUri: IosAudioCatalogConstants.importedFolderUri,
documentUri: targetFileName,
displayName: sourceURL.lastPathComponent,
@@ -387,13 +448,19 @@ final class IosAudioImportStore: ObservableObject {
transcriptText: nil,
durationUs: nil
)
+ importedFileIds.append(importedFile.id)
imported += 1
} catch {
failed += 1
}
}
- return IosAudioImportSummary(imported: imported, skipped: skipped, failed: failed)
+ return IosAudioImportSummary(
+ imported: imported,
+ skipped: skipped,
+ failed: failed,
+ importedFileIds: importedFileIds
+ )
}
private func copySupportedFiles(from urls: [URL]) -> IosAudioImportSummary {
@@ -401,6 +468,7 @@ final class IosAudioImportStore: ObservableObject {
var imported = 0
var skipped = 0
var failed = 0
+ var importedFileIds: [Int64] = []
for sourceURL in urls {
guard isSupportedAudio(sourceURL) else {
@@ -419,7 +487,7 @@ final class IosAudioImportStore: ObservableObject {
let targetFileName = nextAvailableFileName(for: sourceURL.lastPathComponent)
let targetURL = importDirectory.appendingPathComponent(targetFileName)
try fileManager.copyItem(at: sourceURL, to: targetURL)
- _ = catalog.upsertImportedFile(
+ let importedFile = catalog.upsertImportedFile(
folderUri: IosAudioCatalogConstants.importedFolderUri,
documentUri: targetFileName,
displayName: sourceURL.lastPathComponent,
@@ -432,13 +500,70 @@ final class IosAudioImportStore: ObservableObject {
transcriptText: nil,
durationUs: nil
)
+ importedFileIds.append(importedFile.id)
+ imported += 1
+ } catch {
+ failed += 1
+ }
+ }
+
+ return IosAudioImportSummary(
+ imported: imported,
+ skipped: skipped,
+ failed: failed,
+ importedFileIds: importedFileIds
+ )
+ }
+
+ private func copySupportedSharedFiles(from urls: [URL]) -> IosAudioImportSummary {
+ ensureImportDirectoryExists()
+ var imported = 0
+ var skipped = 0
+ var failed = 0
+ var importedFileIds: [Int64] = []
+
+ for stagedURL in urls {
+ guard isSupportedAudio(stagedURL) else {
+ IosSharedImportStaging.removeStagedFileAndMetadata(stagedURL, fileManager: fileManager)
+ skipped += 1
+ continue
+ }
+
+ let metadata = IosSharedImportStaging.readMetadata(for: stagedURL)
+ let displayName = metadata?.displayName ?? stagedURL.lastPathComponent
+ let importedAtMillis = metadata?.modifiedMillis ?? metadata?.stagedAtMillis ?? currentTimeMillis()
+
+ do {
+ let targetFileName = nextAvailableFileName(for: displayName)
+ let targetURL = importDirectory.appendingPathComponent(targetFileName)
+ try fileManager.copyItem(at: stagedURL, to: targetURL)
+ let importedFile = catalog.upsertImportedFile(
+ folderUri: IosAudioCatalogConstants.importedFolderUri,
+ documentUri: targetFileName,
+ displayName: displayName,
+ mimeType: nil,
+ sizeBytes: KotlinLong(longLong: fileSize(at: targetURL)),
+ importedAtMillis: KotlinLong(longLong: importedAtMillis),
+ state: AudioFileState.pending,
+ lastError: nil,
+ processedAtMillis: nil,
+ transcriptText: nil,
+ durationUs: nil
+ )
+ IosSharedImportStaging.removeStagedFileAndMetadata(stagedURL, fileManager: fileManager)
+ importedFileIds.append(importedFile.id)
imported += 1
} catch {
failed += 1
}
}
- return IosAudioImportSummary(imported: imported, skipped: skipped, failed: failed)
+ return IosAudioImportSummary(
+ imported: imported,
+ skipped: skipped,
+ failed: failed,
+ importedFileIds: importedFileIds
+ )
}
private func load() {
@@ -480,7 +605,10 @@ final class IosAudioImportStore: ObservableObject {
status: IosImportedAudioStatus(sharedState: file.state),
transcriptText: file.transcriptText,
durationUs: file.durationUs?.int64Value,
- lastError: file.lastError
+ lastError: file.lastError,
+ processedAt: file.processedAtMillis.map {
+ Date(timeIntervalSince1970: Double($0.int64Value) / 1000)
+ }
)
}
diff --git a/iosApp/VoiceInbox/IosMainScreenShellState.swift b/iosApp/VoiceInbox/IosMainScreenShellState.swift
index 9d74496..a7bb138 100644
--- a/iosApp/VoiceInbox/IosMainScreenShellState.swift
+++ b/iosApp/VoiceInbox/IosMainScreenShellState.swift
@@ -4,218 +4,209 @@ import Shared
enum IosShellCatalogSelection: String, CaseIterable, Identifiable {
case new
case processed
+ case all
var id: String { rawValue }
var title: String {
switch self {
- case .new:
- "New"
- case .processed:
- "Processed"
+ case .new: "New"
+ case .processed: "Processed"
+ case .all: "All"
}
}
- var sharedTab: MainScreenCatalogTab {
+ var sharedFilter: TaskListFilter {
switch self {
- case .new:
- MainScreenCatalogTab.theNew
- case .processed:
- MainScreenCatalogTab.processed
+ case .new: .theNew
+ case .processed: .processed
+ case .all: .all
}
}
}
-struct IosShellMainScreen {
- let state: MainScreenState
- let rows: [IosShellAudioRow]
+struct IosTaskListScreen {
+ let state: TaskListState
+ let filesById: [Int64: IosImportedAudioFile]
}
-struct IosShellAudioRow: Identifiable {
- let id: Int64
- let title: String
- let subtitle: String
- let badge: String
- let imported: Bool
- let status: IosImportedAudioStatus?
- let transcriptText: String?
- let lastError: String?
- let state: MainScreenRowState
+enum IosTaskActionRoute: Equatable {
+ case modelDownload
+ case modelImport
+ case modelCancel
+ case outputSelection
+ case folderSelection
+ case folderRefresh
+ case transcribe
+ case retry
+ case play
+ case stop
+ case showText
+ case audioImport
+}
+
+enum IosTaskActionRouter {
+ static func route(_ kind: TaskActionKind) -> IosTaskActionRoute? {
+ switch kind {
+ case .downloadModel, .retryModelDownload: .modelDownload
+ case .importModel: .modelImport
+ case .cancelModelDownload: .modelCancel
+ case .selectOutput: .outputSelection
+ case .selectFolder: .folderSelection
+ case .refreshFolder: .folderRefresh
+ case .transcribe: .transcribe
+ case .retryTranscription: .retry
+ case .play: .play
+ case .stop: .stop
+ case .showText: .showText
+ case .importAudio: .audioImport
+ default: nil
+ }
+ }
}
final class IosMainScreenShellState {
func screen(
selection: IosShellCatalogSelection,
importedFiles: [IosImportedAudioFile],
- runtimeReady: Bool,
- modelReady: Bool,
+ modelStatus: IosSpeechModelStatus,
+ modelMessage: String?,
modelInstalling: Bool,
+ modelInstallationPhase: String?,
modelDownloadAvailable: Bool,
modelDownloadProgress: Int?,
- modelMessage: String,
- outputReady: Bool,
+ modelCanCancel: Bool,
+ outputStatus: IosOutputDocumentStatus,
+ folderStatus: IosInboxFolderStatus,
+ folderScanning: Bool,
activePreviewEntryId: Int64?,
previewState: PreviewPlaybackState,
- transcription: IosSingleFileTranscriptionState? = nil
- ) -> IosShellMainScreen {
- let rowsForSelection = displayRows(for: selection, importedFiles: importedFiles)
- let rows = rowsForSelection.map { $0.input }
- let pendingCount = importedFiles.count { $0.status == .pending }
- let state = MainScreenStateController.shared.state(
- input: input(
- selectedTab: selection.sharedTab,
- pendingCount: pendingCount,
- displayedRowCount: Int32(rows.count),
- runtimeReady: runtimeReady,
- modelReady: modelReady,
- modelInstalling: modelInstalling,
- modelDownloadAvailable: modelDownloadAvailable,
- modelDownloadProgress: modelDownloadProgress,
- modelMessage: modelMessage,
- outputReady: outputReady,
- activePreviewEntryId: activePreviewEntryId,
- previewState: previewState,
- transcription: transcription,
- rows: rows
+ transcription: IosSingleFileTranscriptionState,
+ preparationOwnerEntryId: Int64?,
+ prerequisiteError: String?,
+ actionsEnabled: Bool
+ ) -> IosTaskListScreen {
+ let input = TaskListInput(
+ filter: selection.sharedFilter,
+ model: modelSnapshot(
+ status: modelStatus,
+ message: modelMessage,
+ installing: modelInstalling,
+ installationPhase: modelInstallationPhase,
+ downloadAvailable: modelDownloadAvailable,
+ progress: modelDownloadProgress,
+ canCancel: modelCanCancel
+ ),
+ output: outputSnapshot(outputStatus),
+ folder: folderSnapshot(folderStatus, scanning: folderScanning),
+ audio: importedFiles.map { file in
+ AudioTaskSnapshot(
+ entryId: file.id,
+ title: file.displayName,
+ detail: subtitle(for: file),
+ state: file.status.sharedState,
+ importedAtMillis: Int64(file.importedAt.timeIntervalSince1970 * 1000),
+ terminalAtMillis: file.processedAt.map {
+ KotlinLong(longLong: Int64($0.timeIntervalSince1970 * 1000))
+ },
+ lastError: file.lastError,
+ hasTranscriptText: file.transcriptText?.isEmpty == false,
+ noSpeech: Self.isNoSpeech(file.lastError),
+ eligibleForTranscription: actionsEnabled,
+ eligibleForPreview: actionsEnabled
+ )
+ },
+ preview: PreviewTaskSnapshot(
+ activeEntryId: activePreviewEntryId.map(KotlinLong.init(longLong:)),
+ state: previewState
+ ),
+ transcription: TranscriptionTaskSnapshot(
+ active: transcription.active,
+ activeEntryId: transcription.fileId.map(KotlinLong.init(longLong:)),
+ preparationOwnerEntryId: preparationOwnerEntryId.map(KotlinLong.init(longLong:)),
+ phase: transcription.phase,
+ percent: transcription.progressPercent.map { KotlinInt(int: Int32($0)) },
+ processedUs: transcription.processedUs > 0 ? KotlinLong(longLong: transcription.processedUs) : nil,
+ durationUs: transcription.durationUs > 0 ? KotlinLong(longLong: transcription.durationUs) : nil,
+ completedFiles: transcription.totalFiles > 0 ? KotlinInt(int: transcription.completedFiles) : nil,
+ totalFiles: transcription.totalFiles > 0 ? KotlinInt(int: transcription.totalFiles) : nil,
+ failedFiles: transcription.failedFiles > 0 ? KotlinInt(int: transcription.failedFiles) : nil,
+ prerequisiteError: prerequisiteError
)
)
- let sharedRowsById = Dictionary(uniqueKeysWithValues: state.rows.map { ($0.entryId, $0) })
- let displayRows = rowsForSelection.compactMap { row -> IosShellAudioRow? in
- guard let rowState = sharedRowsById[row.input.entryId] else { return nil }
- return IosShellAudioRow(
- id: row.input.entryId,
- title: row.title,
- subtitle: row.subtitle,
- badge: row.badge,
- imported: row.imported,
- status: row.status,
- transcriptText: row.transcriptText,
- lastError: row.lastError,
- state: rowState
- )
+ return IosTaskListScreen(
+ state: TaskListPresentationController.shared.state(input: input),
+ filesById: Dictionary(uniqueKeysWithValues: importedFiles.map { ($0.id, $0) })
+ )
+ }
+
+ private func modelSnapshot(
+ status: IosSpeechModelStatus,
+ message: String?,
+ installing: Bool,
+ installationPhase: String?,
+ downloadAvailable: Bool,
+ progress: Int?,
+ canCancel: Bool
+ ) -> ModelSetupSnapshot {
+ let state: ModelSetupSnapshotState
+ if installing {
+ state = .installing
+ } else if status.isReady {
+ state = .ready
+ } else if status.installationState == .invalid {
+ state = .invalid
+ } else {
+ state = .required
}
- return IosShellMainScreen(
+ return ModelSetupSnapshot(
state: state,
- rows: displayRows
+ detail: status.detail ?? message,
+ installationPhase: installing ? installationPhase : nil,
+ progressPercent: progress.map { KotlinInt(int: Int32($0)) },
+ downloadAvailable: downloadAvailable,
+ canCancel: canCancel
)
}
- private func displayRows(for selection: IosShellCatalogSelection, importedFiles: [IosImportedAudioFile]) -> [DisplayRow] {
- switch selection {
- case .new:
- importedFiles.filter { $0.status == .pending || $0.status == .processing }.map { file in
- DisplayRow(
- input: MainScreenRowInput(
- entryId: file.id,
- state: file.status.sharedState,
- hasTranscriptText: file.transcriptText?.isEmpty == false
- ),
- title: file.displayName,
- subtitle: subtitle(for: file),
- badge: file.status.badge,
- imported: true,
- status: file.status,
- transcriptText: file.transcriptText,
- lastError: file.lastError
- )
- }
- case .processed:
- importedFiles.filter { $0.status == .processed || $0.status == .failed }.map { file in
- DisplayRow(
- input: MainScreenRowInput(
- entryId: file.id,
- state: file.status.sharedState,
- hasTranscriptText: file.transcriptText?.isEmpty == false
- ),
- title: file.displayName,
- subtitle: subtitle(for: file),
- badge: file.status.badge,
- imported: true,
- status: file.status,
- transcriptText: file.transcriptText,
- lastError: file.lastError
- )
- }
+ private func outputSnapshot(_ status: IosOutputDocumentStatus) -> OutputSetupSnapshot {
+ let state: OutputSetupSnapshotState = status.ready
+ ? .ready
+ : (status.displayName == nil ? .required : .invalid)
+ return OutputSetupSnapshot(state: state, detail: status.message)
+ }
+
+ private func folderSnapshot(
+ _ status: IosInboxFolderStatus,
+ scanning: Bool
+ ) -> FolderSetupSnapshot {
+ let state: FolderSetupSnapshotState
+ if scanning {
+ state = .scanning
+ } else if status.hasError {
+ state = .error
+ } else if status.needsSelection, status.displayName != nil {
+ state = .error
+ } else if status.needsSelection {
+ state = .unselected
+ } else {
+ state = .ready
}
+ return FolderSetupSnapshot(state: state, detail: status.message)
}
private func subtitle(for file: IosImportedAudioFile) -> String {
var parts = ["Imported", file.formattedSize]
- if let lastError = file.lastError, !lastError.isEmpty {
- parts.append(lastError)
- } else if let durationUs = file.durationUs, durationUs > 0 {
- parts.append(formatDuration(microseconds: durationUs))
+ if let durationUs = file.durationUs, durationUs > 0 {
+ let totalSeconds = durationUs / 1_000_000
+ parts.append("\(totalSeconds / 60):\(String(format: "%02d", totalSeconds % 60))")
}
return parts.joined(separator: " • ")
}
- private func formatDuration(microseconds: Int64) -> String {
- let totalSeconds = microseconds / 1_000_000
- let minutes = totalSeconds / 60
- let seconds = String(format: "%02d", totalSeconds % 60)
- return "\(minutes):\(seconds)"
- }
-
- private func input(
- selectedTab: MainScreenCatalogTab,
- pendingCount: Int,
- displayedRowCount: Int32,
- runtimeReady: Bool,
- modelReady: Bool,
- modelInstalling: Bool,
- modelDownloadAvailable: Bool,
- modelDownloadProgress: Int?,
- modelMessage: String,
- outputReady: Bool,
- activePreviewEntryId: Int64?,
- previewState: PreviewPlaybackState,
- transcription: IosSingleFileTranscriptionState?,
- rows: [MainScreenRowInput]
- ) -> MainScreenInput {
- let active = transcription?.active == true
- let processedUs = transcription?.processedUs ?? 0
- let durationUs = transcription?.durationUs ?? 0
- let ready = runtimeReady && modelReady && !modelInstalling
- return MainScreenInput(
- modelMessage: modelMessage,
- modelLoading: modelInstalling,
- modelDownloadAvailable: modelDownloadAvailable,
- modelDownloadProgress: modelDownloadProgress.map { KotlinInt(int: Int32($0)) },
- modelReady: ready,
- outputSelected: outputReady,
- folderSelected: true,
- pendingCount: Int32(pendingCount),
- folderChecking: false,
- folderScanQueued: false,
- scanning: false,
- scanMessage: nil,
- transcriptionState: active ? TranscriptionObservationState.active : TranscriptionObservationState.idle,
- transcriptionPhase: transcription?.phase,
- transcriptionFilename: transcription?.fileName,
- transcriptionIndeterminate: active && transcription?.progressPercent == nil,
- transcriptionProgress: Int32(transcription?.progressPercent ?? 0),
- processedUs: processedUs,
- durationUs: durationUs,
- completedFiles: transcription?.completedFiles ?? 0,
- totalFiles: transcription?.totalFiles ?? 0,
- failedFiles: transcription?.failedFiles ?? 0,
- errorMessage: nil,
- selectedTab: selectedTab,
- displayedRowCount: displayedRowCount,
- activePreviewEntryId: activePreviewEntryId.map { KotlinLong(longLong: $0) },
- previewState: previewState,
- rows: rows
- )
- }
-
- private struct DisplayRow {
- let input: MainScreenRowInput
- let title: String
- let subtitle: String
- let badge: String
- let imported: Bool
- let status: IosImportedAudioStatus?
- let transcriptText: String?
- let lastError: String?
+ static func isNoSpeech(_ message: String?) -> Bool {
+ guard let message else { return false }
+ return message.localizedCaseInsensitiveContains("no text") ||
+ message.localizedCaseInsensitiveContains("no speech")
}
}
diff --git a/iosApp/VoiceInbox/IosSharedImportStaging.swift b/iosApp/VoiceInbox/IosSharedImportStaging.swift
new file mode 100644
index 0000000..b30fc04
--- /dev/null
+++ b/iosApp/VoiceInbox/IosSharedImportStaging.swift
@@ -0,0 +1,120 @@
+import Foundation
+import UniformTypeIdentifiers
+
+enum IosSharedImportStaging {
+ static let appGroupIdentifier = "group.me.maxistar.voiceinbox.ios"
+ static let directoryName = "SharedImports"
+ static let manifestExtension = "json"
+
+ static let supportedExtensions: Set = [
+ "aac", "aif", "aiff", "caf", "flac", "m4a", "mp3", "mp4", "wav"
+ ]
+
+ static let supportedTypeIdentifiers: [String] = [
+ UTType.audio.identifier,
+ UTType.movie.identifier,
+ UTType.mpeg4Audio.identifier,
+ "com.microsoft.waveform-audio",
+ "public.mp3",
+ "public.aiff-audio",
+ "org.xiph.flac",
+ ]
+
+ struct Metadata: Codable {
+ let stagedFileName: String
+ let displayName: String
+ let originalPathHint: String?
+ let sizeBytes: Int64
+ let modifiedMillis: Int64?
+ let stagedAtMillis: Int64
+ }
+
+ static func stagingDirectory(fileManager: FileManager = .default) throws -> URL {
+ guard let container = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) else {
+ throw StagingError.appGroupUnavailable
+ }
+ let directory = container.appendingPathComponent(directoryName, isDirectory: true)
+ try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
+ return directory
+ }
+
+ static func isSupportedAudio(_ url: URL) -> Bool {
+ supportedExtensions.contains(url.pathExtension.lowercased())
+ }
+
+ static func uniqueStagedFileName(for displayName: String, in directory: URL, fileManager: FileManager = .default) -> String {
+ let sanitized = sanitizedFileName(displayName)
+ let base = (sanitized as NSString).deletingPathExtension
+ let ext = (sanitized as NSString).pathExtension
+ var candidate = sanitized
+ var suffix = 2
+
+ while fileManager.fileExists(atPath: directory.appendingPathComponent(candidate).path) ||
+ fileManager.fileExists(atPath: directory.appendingPathComponent(manifestFileName(for: candidate)).path) {
+ candidate = ext.isEmpty ? "\(base)-\(suffix)" : "\(base)-\(suffix).\(ext)"
+ suffix += 1
+ }
+
+ return candidate
+ }
+
+ static func manifestFileName(for stagedFileName: String) -> String {
+ "\(stagedFileName).\(manifestExtension)"
+ }
+
+ static func writeMetadata(_ metadata: Metadata, in directory: URL, fileManager: FileManager = .default) throws {
+ let data = try JSONEncoder().encode(metadata)
+ let metadataURL = directory.appendingPathComponent(manifestFileName(for: metadata.stagedFileName))
+ try data.write(to: metadataURL, options: [.atomic])
+ }
+
+ static func readMetadata(for stagedFileURL: URL) -> Metadata? {
+ let metadataURL = stagedFileURL
+ .deletingLastPathComponent()
+ .appendingPathComponent(manifestFileName(for: stagedFileURL.lastPathComponent))
+ guard let data = try? Data(contentsOf: metadataURL) else { return nil }
+ return try? JSONDecoder().decode(Metadata.self, from: data)
+ }
+
+ static func removeStagedFileAndMetadata(_ stagedFileURL: URL, fileManager: FileManager = .default) {
+ try? fileManager.removeItem(at: stagedFileURL)
+ let metadataURL = stagedFileURL
+ .deletingLastPathComponent()
+ .appendingPathComponent(manifestFileName(for: stagedFileURL.lastPathComponent))
+ try? fileManager.removeItem(at: metadataURL)
+ }
+
+ static func fileSize(at url: URL) -> Int64 {
+ let values = try? url.resourceValues(forKeys: [.fileSizeKey])
+ return Int64(values?.fileSize ?? 0)
+ }
+
+ static func fileModifiedMillis(at url: URL) -> Int64? {
+ let values = try? url.resourceValues(forKeys: [.contentModificationDateKey])
+ return values?.contentModificationDate.map { Int64($0.timeIntervalSince1970 * 1000) }
+ }
+
+ static func currentTimeMillis() -> Int64 {
+ Int64(Date().timeIntervalSince1970 * 1000)
+ }
+
+ static func sanitizedFileName(_ name: String) -> String {
+ let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._- "))
+ let scalars = name.unicodeScalars.map { scalar in
+ allowed.contains(scalar) ? Character(scalar) : "-"
+ }
+ let sanitized = String(scalars).trimmingCharacters(in: .whitespacesAndNewlines)
+ return sanitized.isEmpty ? "audio-file" : sanitized
+ }
+
+ enum StagingError: LocalizedError {
+ case appGroupUnavailable
+
+ var errorDescription: String? {
+ switch self {
+ case .appGroupUnavailable:
+ "Shared import storage is not available. Check the App Group entitlement."
+ }
+ }
+ }
+}
diff --git a/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift b/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift
index 77b1326..90d13f2 100644
--- a/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift
+++ b/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift
@@ -2,6 +2,18 @@ import AVFoundation
import Foundation
import Shared
+enum IosTranscriptionPreparationGate {
+ @MainActor
+ static func prepareAndClaim(
+ prepare: () async -> Bool,
+ claim: () -> Void
+ ) async -> Bool {
+ guard await prepare() else { return false }
+ claim()
+ return true
+ }
+}
+
@_silgen_name("voiceinbox_transcription_initialize")
private func voiceinbox_transcription_initialize(_ modelDirectory: UnsafePointer) -> Bool
@@ -20,6 +32,9 @@ private func voiceinbox_transcription_string_free(_ value: UnsafeMutablePointer<
@_silgen_name("voiceinbox_transcription_backend_configured")
private func voiceinbox_transcription_backend_configured() -> Bool
+@_silgen_name("voiceinbox_transcription_reset")
+private func voiceinbox_transcription_reset()
+
struct IosSingleFileTranscriptionState {
let active: Bool
let fileId: Int64?
@@ -59,6 +74,8 @@ struct IosSingleFileTranscriptionState {
@MainActor
final class IosSingleFileTranscriptionController: ObservableObject {
@Published private(set) var state = IosSingleFileTranscriptionState.idle
+ @Published private(set) var preparationOwnerFileId: Int64?
+ @Published private(set) var prerequisiteError: String?
@Published var message: String?
private var task: Task?
@@ -80,17 +97,19 @@ final class IosSingleFileTranscriptionController: ObservableObject {
file: IosImportedAudioFile,
localURL: URL,
modelDirectory: URL,
+ modelStore: IosSpeechModelStore,
outputDocument: IosSelectedOutputDocument,
store: IosAudioImportStore,
onSuccess: ((String) -> Void)? = nil
) {
task?.cancel()
- store.markProcessing(fileId: file.id)
+ preparationOwnerFileId = file.id
+ prerequisiteError = nil
state = IosSingleFileTranscriptionState(
active: true,
fileId: file.id,
fileName: file.displayName,
- phase: SingleFileTranscriptionUseCase.companion.PHASE_DECODING_AUDIO,
+ phase: "Preparing speech model",
processedUs: 0,
durationUs: 0,
completedFiles: 0,
@@ -102,14 +121,31 @@ final class IosSingleFileTranscriptionController: ObservableObject {
message = nil
task = Task {
+ let prepared = await IosTranscriptionPreparationGate.prepareAndClaim(
+ prepare: { await modelStore.prepareForTranscription() != nil },
+ claim: { store.markProcessing(fileId: file.id) }
+ )
+ guard prepared else {
+ let error = modelStore.message ?? "Speech model preparation failed."
+ prerequisiteError = error
+ message = error
+ state = .idle
+ return
+ }
+ guard !Task.isCancelled else {
+ state = .idle
+ return
+ }
let outcome = await Task.detached(priority: .userInitiated) {
Self.runSharedTranscription(
file: file,
localURL: localURL,
modelDirectory: modelDirectory.path,
- outputDocument: outputDocument
+ outputDocument: outputDocument,
+ modelPrepared: true
) { progress in
Task { @MainActor in
+ self.preparationOwnerFileId = nil
self.state = IosSingleFileTranscriptionState(
active: true,
fileId: file.id,
@@ -146,23 +182,34 @@ final class IosSingleFileTranscriptionController: ObservableObject {
store.markFailed(fileId: file.id, error: error)
message = error
}
+ preparationOwnerFileId = nil
+ prerequisiteError = nil
state = .idle
}
}
func transcribeAll(
modelDirectory: URL,
+ modelStore: IosSpeechModelStore,
outputDocument: IosSelectedOutputDocument,
store: IosAudioImportStore,
onFinished: (() -> Void)? = nil
) {
guard !state.active else { return }
task?.cancel()
+ preparationOwnerFileId = store.files
+ .filter { $0.status == .pending }
+ .sorted {
+ if $0.importedAt != $1.importedAt { return $0.importedAt < $1.importedAt }
+ return $0.id < $1.id
+ }
+ .first?.id
+ prerequisiteError = nil
state = IosSingleFileTranscriptionState(
active: true,
fileId: nil,
fileName: nil,
- phase: "Preparing transcription",
+ phase: "Preparing speech model",
processedUs: 0,
durationUs: 0,
completedFiles: 0,
@@ -174,15 +221,27 @@ final class IosSingleFileTranscriptionController: ObservableObject {
message = nil
task = Task {
+ guard await modelStore.prepareForTranscription() != nil else {
+ let error = modelStore.message ?? "Speech model preparation failed."
+ prerequisiteError = error
+ message = error
+ state = .idle
+ return
+ }
+ guard !Task.isCancelled else {
+ state = .idle
+ return
+ }
let outcome = await Task.detached(priority: .userInitiated) {
Self.runSharedBatchTranscription(
modelDirectory: modelDirectory.path,
outputDocument: outputDocument
) { progress in
Task { @MainActor in
+ self.preparationOwnerFileId = nil
self.state = IosSingleFileTranscriptionState(
active: true,
- fileId: nil,
+ fileId: progress.activeEntryId?.int64Value,
fileName: progress.filename,
phase: progress.phase,
processedUs: progress.processedUs?.int64Value ?? 0,
@@ -204,6 +263,8 @@ final class IosSingleFileTranscriptionController: ObservableObject {
}
message = outcome.summary
+ preparationOwnerFileId = nil
+ prerequisiteError = nil
state = IosSingleFileTranscriptionState(
active: false,
fileId: nil,
@@ -225,6 +286,7 @@ final class IosSingleFileTranscriptionController: ObservableObject {
file: IosImportedAudioFile,
localURL: URL,
modelDirectory: URL,
+ modelStore: IosSpeechModelStore,
outputDocument: IosSelectedOutputDocument,
store: IosAudioImportStore,
onSuccess: ((String) -> Void)? = nil
@@ -234,6 +296,7 @@ final class IosSingleFileTranscriptionController: ObservableObject {
file: file,
localURL: localURL,
modelDirectory: modelDirectory,
+ modelStore: modelStore,
outputDocument: outputDocument,
store: store,
onSuccess: onSuccess
@@ -245,11 +308,12 @@ final class IosSingleFileTranscriptionController: ObservableObject {
localURL: URL,
modelDirectory: String,
outputDocument: IosSelectedOutputDocument,
+ modelPrepared: Bool = false,
onProgress: @escaping (SingleFileTranscriptionProgress) -> Void
) -> SingleFileTranscriptionOutcome {
let decoder = IosPlatformAudioDecoder(localURL: localURL)
let transcriber = IosNativeTranscriber(modelDirectory: modelDirectory)
- guard transcriber.initialize(modelDirectory: transcriber.modelDirectory) else {
+ guard modelPrepared || transcriber.initialize(modelDirectory: transcriber.modelDirectory) else {
return SingleFileTranscriptionOutcome(
success: false,
result: nil,
@@ -306,7 +370,9 @@ final class IosSingleFileTranscriptionController: ObservableObject {
)
return useCase.transcribe(
input: BatchTranscriptionInput(
- folderId: IosAudioCatalogConstants.importedFolderUri,
+ sourceScope: AudioCatalogSourceScope(
+ sourceIds: [IosAudioCatalogConstants.importedFolderUri]
+ ),
outputId: outputDocument.id,
runId: UUID().uuidString,
retryEntryId: nil
@@ -345,6 +411,7 @@ private final class IosBatchEntryOutcomeTranscriber: OutcomeBatchEntryTranscribe
localURL: Self.localURL(documentUri: entry.documentUri),
modelDirectory: modelDirectory,
outputDocument: outputDocument,
+ modelPrepared: true,
onProgress: onProgress
)
}
@@ -477,7 +544,7 @@ private final class IosPlatformAudioDecoder: PlatformAudioDecoder {
}
}
-private final class IosNativeTranscriber: PlatformNativeTranscriber {
+final class IosNativeTranscriber: PlatformNativeTranscriber {
let modelDirectory: String
private(set) var lastError: String?
@@ -489,12 +556,24 @@ private final class IosNativeTranscriber: PlatformNativeTranscriber {
voiceinbox_transcription_backend_configured()
}
+ static func prepare(modelDirectory: String) -> Bool {
+ modelDirectory.withCString { voiceinbox_transcription_initialize($0) }
+ }
+
+ static func resetModel() {
+ voiceinbox_transcription_reset()
+ }
+
+ static func consumeLastError() -> String? {
+ guard let pointer = voiceinbox_transcription_last_error() else { return nil }
+ defer { voiceinbox_transcription_string_free(pointer) }
+ return String(cString: pointer)
+ }
+
func initialize(modelDirectory: String) -> Bool {
- let ok = modelDirectory.withCString { pointer in
- voiceinbox_transcription_initialize(pointer)
- }
+ let ok = Self.prepare(modelDirectory: modelDirectory)
if !ok {
- lastError = Self.consumeNativeError()
+ lastError = Self.consumeLastError() ?? "iOS native transcription failed."
}
return ok
}
@@ -510,7 +589,7 @@ private final class IosNativeTranscriber: PlatformNativeTranscriber {
voiceinbox_transcription_transcribe_chunk_json(pointer.baseAddress, pointer.count)
}
guard let result else {
- lastError = Self.consumeNativeError()
+ lastError = Self.consumeLastError() ?? "iOS native transcription failed."
return nil
}
defer { voiceinbox_transcription_string_free(result) }
@@ -526,13 +605,6 @@ private final class IosNativeTranscriber: PlatformNativeTranscriber {
return text
}
- private static func consumeNativeError() -> String {
- guard let pointer = voiceinbox_transcription_last_error() else {
- return "iOS native transcription failed."
- }
- defer { voiceinbox_transcription_string_free(pointer) }
- return String(cString: pointer)
- }
}
private final class IosTranscriptStaging: PlatformTranscriptStaging {
diff --git a/iosApp/VoiceInbox/IosSpeechModelStore.swift b/iosApp/VoiceInbox/IosSpeechModelStore.swift
index 3a404b0..29d68f4 100644
--- a/iosApp/VoiceInbox/IosSpeechModelStore.swift
+++ b/iosApp/VoiceInbox/IosSpeechModelStore.swift
@@ -3,11 +3,22 @@ import CryptoKit
import Foundation
import Shared
+enum IosSpeechModelInstallationState: Equatable {
+ case missing
+ case installedVerified
+ case installedLegacy
+ case invalid
+}
+
struct IosSpeechModelStatus {
let directory: URL
- let isReady: Bool
+ let installationState: IosSpeechModelInstallationState
let missingFiles: [String]
+ var isReady: Bool {
+ installationState == .installedVerified || installationState == .installedLegacy
+ }
+
var summary: String {
if isReady {
return "Speech model installed"
@@ -48,6 +59,14 @@ enum IosSpeechModelPaths {
static var backupDirectory: URL {
applicationSupportDirectory.appendingPathComponent("SpeechModel.previous", isDirectory: true)
}
+
+ static var receiptFile: URL {
+ applicationSupportDirectory.appendingPathComponent("SpeechModel.receipt")
+ }
+
+ static var invalidFile: URL {
+ applicationSupportDirectory.appendingPathComponent("SpeechModel.invalid")
+ }
}
struct IosSpeechModelDownloadProgress {
@@ -66,13 +85,52 @@ final class IosSpeechModelStore: ObservableObject {
@Published private(set) var status: IosSpeechModelStatus
@Published private(set) var isInstalling = false
@Published private(set) var downloadProgress: IosSpeechModelDownloadProgress?
+ @Published private(set) var runtimeState = SpeechModelRuntimeState.unloaded
@Published var message: String?
private var downloadTask: Task?
+ private var preparationTask: Task?
+ private let installationDirectory: URL
+ private let inspectInstallation: @Sendable (URL) -> IosSpeechModelStatus
+ private let validateInstallation: @Sendable (URL) -> [String]
+ private let prepareNative: @Sendable (String) -> Bool
+ private let nativeError: @Sendable () -> String?
+ private let recordVerified: @Sendable () -> Void
+ private let recordInvalid: @Sendable (String) -> Void
+ private let resetNative: @Sendable () -> Void
+
+ convenience init() {
+ self.init(
+ directory: IosSpeechModelPaths.modelDirectory,
+ inspectInstallation: { Self.inspectLightweight(directory: $0) },
+ validateInstallation: { Self.validateModelFiles(in: $0).missingFiles },
+ prepareNative: { IosNativeTranscriber.prepare(modelDirectory: $0) },
+ nativeError: { IosNativeTranscriber.consumeLastError() },
+ recordVerified: { Self.recordVerifiedInstallation() },
+ recordInvalid: { Self.recordInvalidInstallation($0) },
+ resetNative: { IosNativeTranscriber.resetModel() }
+ )
+ }
- init() {
- let directory = IosSpeechModelPaths.modelDirectory
- status = Self.validate(directory: directory)
+ init(
+ directory: URL,
+ inspectInstallation: @escaping @Sendable (URL) -> IosSpeechModelStatus,
+ validateInstallation: @escaping @Sendable (URL) -> [String],
+ prepareNative: @escaping @Sendable (String) -> Bool,
+ nativeError: @escaping @Sendable () -> String?,
+ recordVerified: @escaping @Sendable () -> Void,
+ recordInvalid: @escaping @Sendable (String) -> Void,
+ resetNative: @escaping @Sendable () -> Void
+ ) {
+ installationDirectory = directory
+ self.inspectInstallation = inspectInstallation
+ self.validateInstallation = validateInstallation
+ self.prepareNative = prepareNative
+ self.nativeError = nativeError
+ self.recordVerified = recordVerified
+ self.recordInvalid = recordInvalid
+ self.resetNative = resetNative
+ status = inspectInstallation(directory)
}
var isReady: Bool {
@@ -87,8 +145,12 @@ final class IosSpeechModelStore: ObservableObject {
isInstalling || downloadTask != nil
}
+ var canCancelDownload: Bool {
+ downloadTask != nil
+ }
+
func reload() {
- status = Self.validate(directory: IosSpeechModelPaths.modelDirectory)
+ status = inspectInstallation(installationDirectory)
}
func installModel(from sourceURL: URL) {
@@ -104,7 +166,8 @@ final class IosSpeechModelStore: ObservableObject {
}.value
isInstalling = false
- status = Self.validate(directory: IosSpeechModelPaths.modelDirectory)
+ status = Self.inspectLightweight(directory: IosSpeechModelPaths.modelDirectory)
+ runtimeState = .unloaded
message = result.message
}
}
@@ -134,7 +197,8 @@ final class IosSpeechModelStore: ObservableObject {
isInstalling = false
downloadTask = nil
- status = Self.validate(directory: IosSpeechModelPaths.modelDirectory)
+ status = Self.inspectLightweight(directory: IosSpeechModelPaths.modelDirectory)
+ runtimeState = .unloaded
downloadProgress = nil
message = result.message
}
@@ -148,13 +212,130 @@ final class IosSpeechModelStore: ObservableObject {
message = "Speech model download cancelled."
}
- nonisolated private static func validate(directory: URL) -> IosSpeechModelStatus {
- let result = validateModelFiles(in: directory)
+ func prepareForTranscription() async -> URL? {
+ if runtimeState == .loaded { return modelDirectory }
+ let task: Task
+ if let existing = preparationTask {
+ task = existing
+ } else {
+ runtimeState = .loading
+ message = "Preparing speech model..."
+ let directory = modelDirectory
+ let validateInstallation = validateInstallation
+ let prepareNative = prepareNative
+ let nativeError = nativeError
+ let recordVerified = recordVerified
+ let recordInvalid = recordInvalid
+ task = Task.detached(priority: .userInitiated) {
+ let validationIssues = validateInstallation(directory)
+ guard validationIssues.isEmpty else {
+ let reason = validationIssues.joined(separator: ", ")
+ recordInvalid(reason)
+ return PreparationResult(
+ directory: nil,
+ invalidInstallation: true,
+ message: "Speech model is invalid: \(reason)"
+ )
+ }
+ guard prepareNative(directory.path) else {
+ return PreparationResult(
+ directory: nil,
+ invalidInstallation: false,
+ message: nativeError()
+ ?? "Speech model failed to load."
+ )
+ }
+ recordVerified()
+ return PreparationResult(directory: directory, invalidInstallation: false, message: nil)
+ }
+ preparationTask = task
+ }
+
+ let result = await task.value
+ preparationTask = nil
+ if let directory = result.directory {
+ status = inspectInstallation(directory)
+ runtimeState = .loaded
+ message = nil
+ return directory
+ }
+ runtimeState = .failed
+ if result.invalidInstallation {
+ status = inspectInstallation(modelDirectory)
+ }
+ message = result.message
+ return nil
+ }
+
+ func invalidateRuntimeAfterReplacement() {
+ preparationTask?.cancel()
+ preparationTask = nil
+ runtimeState = .unloaded
+ resetNative()
+ }
+
+ nonisolated static func inspectLightweight(
+ directory: URL,
+ receiptFile: URL = IosSpeechModelPaths.receiptFile,
+ invalidFile: URL = IosSpeechModelPaths.invalidFile,
+ requiredFileNames: [String]? = nil
+ ) -> IosSpeechModelStatus {
+ if let reason = try? String(contentsOf: invalidFile, encoding: .utf8),
+ !reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ return IosSpeechModelStatus(
+ directory: directory,
+ installationState: .invalid,
+ missingFiles: [reason]
+ )
+ }
+ let fileManager = FileManager.default
+ var isDirectory: ObjCBool = false
+ guard fileManager.fileExists(atPath: directory.path, isDirectory: &isDirectory), isDirectory.boolValue else {
+ return IosSpeechModelStatus(
+ directory: directory,
+ installationState: .missing,
+ missingFiles: ["model directory"]
+ )
+ }
+ let missing = (requiredFileNames ?? manifestFiles().map(\.name)).compactMap { fileName in
+ fileManager.isReadableFile(atPath: directory.appendingPathComponent(fileName).path)
+ ? nil
+ : fileName
+ }
+ guard missing.isEmpty else {
+ return IosSpeechModelStatus(
+ directory: directory,
+ installationState: .invalid,
+ missingFiles: missing
+ )
+ }
+ let receipt = try? String(contentsOf: receiptFile, encoding: .utf8)
+ let state: IosSpeechModelInstallationState = receipt?.trimmingCharacters(in: .whitespacesAndNewlines)
+ == EmbeddedSpeechModel.shared.manifest.version
+ ? .installedVerified
+ : .installedLegacy
return IosSpeechModelStatus(
directory: directory,
- isReady: result.missingFiles.isEmpty,
- missingFiles: result.missingFiles
+ installationState: state,
+ missingFiles: []
+ )
+ }
+
+ nonisolated private static func recordVerifiedInstallation() {
+ try? FileManager.default.createDirectory(
+ at: IosSpeechModelPaths.applicationSupportDirectory,
+ withIntermediateDirectories: true
+ )
+ try? EmbeddedSpeechModel.shared.manifest.version.write(
+ to: IosSpeechModelPaths.receiptFile,
+ atomically: true,
+ encoding: .utf8
)
+ try? removeItemIfExists(IosSpeechModelPaths.invalidFile)
+ }
+
+ nonisolated private static func recordInvalidInstallation(_ reason: String) {
+ try? reason.write(to: IosSpeechModelPaths.invalidFile, atomically: true, encoding: .utf8)
}
nonisolated private static func validateModelFiles(in directory: URL) -> ModelValidationResult {
@@ -221,6 +402,8 @@ final class IosSpeechModelStore: ObservableObject {
do {
try fileManager.moveItem(at: IosSpeechModelPaths.installDirectory, to: IosSpeechModelPaths.modelDirectory)
try removeItemIfExists(IosSpeechModelPaths.backupDirectory)
+ recordVerifiedInstallation()
+ IosNativeTranscriber.resetModel()
return InstallResult(message: "Speech model installed.")
} catch {
if fileManager.fileExists(atPath: IosSpeechModelPaths.backupDirectory.path) {
@@ -311,6 +494,8 @@ final class IosSpeechModelStore: ObservableObject {
}
try activateStagedModel()
+ recordVerifiedInstallation()
+ IosNativeTranscriber.resetModel()
return InstallResult(message: "Speech model downloaded and installed.")
} catch is CancellationError {
try? removeItemIfExists(IosSpeechModelPaths.stagingDirectory)
@@ -461,8 +646,7 @@ final class IosSpeechModelStore: ObservableObject {
nonisolated private static func manifestFiles() -> [IosSpeechModelManifestFile] {
let manifest = EmbeddedSpeechModel.shared.manifest
- return manifest.files.compactMap { item in
- guard let file = item as? SpeechModelFile else { return nil }
+ return manifest.files.map { file in
return IosSpeechModelManifestFile(
name: file.name,
sizeBytes: file.sizeBytes,
@@ -484,6 +668,12 @@ final class IosSpeechModelStore: ObservableObject {
}
}
+ private struct PreparationResult {
+ let directory: URL?
+ let invalidInstallation: Bool
+ let message: String?
+ }
+
private struct IosSpeechModelManifestFile {
let name: String
let sizeBytes: Int64
diff --git a/iosApp/VoiceInbox/SettingsView.swift b/iosApp/VoiceInbox/SettingsView.swift
index 3cc6cfb..c348ee5 100644
--- a/iosApp/VoiceInbox/SettingsView.swift
+++ b/iosApp/VoiceInbox/SettingsView.swift
@@ -1,9 +1,63 @@
import Shared
import SwiftUI
+enum IosStartupProcessingPolicy: String, CaseIterable, Identifiable {
+ case ask
+ case yes
+ case no
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .ask:
+ "Ask"
+ case .yes:
+ "Yes"
+ case .no:
+ "No"
+ }
+ }
+
+ var detail: String {
+ switch self {
+ case .ask:
+ "Ask before processing queued files when Voice Inbox starts."
+ case .yes:
+ "Automatically process queued files when Voice Inbox starts."
+ case .no:
+ "Do not process queued files automatically at startup."
+ }
+ }
+}
+
+@MainActor
+final class IosStartupProcessingPolicyStore: ObservableObject {
+ @Published var policy: IosStartupProcessingPolicy {
+ didSet {
+ defaults.set(policy.rawValue, forKey: Self.policyKey)
+ }
+ }
+
+ private let defaults: UserDefaults
+
+ init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ if let rawValue = defaults.string(forKey: Self.policyKey),
+ let storedPolicy = IosStartupProcessingPolicy(rawValue: rawValue) {
+ policy = storedPolicy
+ } else {
+ policy = .ask
+ }
+ }
+
+ private static let policyKey = "iosStartupProcessingPolicy"
+}
+
struct SettingsView: View {
@ObservedObject var importStore: IosAudioImportStore
@ObservedObject var outputStore: IosOutputDocumentStore
+ @ObservedObject var startupPolicyStore: IosStartupProcessingPolicyStore
let selectInboxFolder: () -> Void
let selectOutputFile: () -> Void
@@ -56,6 +110,18 @@ struct SettingsView: View {
}
}
+ Section("Startup Processing") {
+ Picker("When queued files are found", selection: $startupPolicyStore.policy) {
+ ForEach(IosStartupProcessingPolicy.allCases) { policy in
+ Text(policy.title).tag(policy)
+ }
+ }
+
+ Text(startupPolicyStore.policy.detail)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
Section("About") {
LabeledContent("Version", value: appVersion)
Link("Website", destination: websiteURL)
diff --git a/iosApp/VoiceInbox/VoiceInbox.entitlements b/iosApp/VoiceInbox/VoiceInbox.entitlements
new file mode 100644
index 0000000..087647e
--- /dev/null
+++ b/iosApp/VoiceInbox/VoiceInbox.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.me.maxistar.voiceinbox.ios
+
+
+
diff --git a/iosApp/VoiceInboxShareExtension/Info.plist b/iosApp/VoiceInboxShareExtension/Info.plist
new file mode 100644
index 0000000..4beb2e5
--- /dev/null
+++ b/iosApp/VoiceInboxShareExtension/Info.plist
@@ -0,0 +1,43 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Voice Inbox
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ NSExtension
+
+ NSExtensionAttributes
+
+ NSExtensionActivationRule
+
+ NSExtensionActivationSupportsAudioWithMaxCount
+ 20
+ NSExtensionActivationSupportsFileWithMaxCount
+ 20
+ NSExtensionActivationSupportsMovieWithMaxCount
+ 20
+
+
+ NSExtensionPointIdentifier
+ com.apple.share-services
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).ShareViewController
+
+
+
diff --git a/iosApp/VoiceInboxShareExtension/ShareViewController.swift b/iosApp/VoiceInboxShareExtension/ShareViewController.swift
new file mode 100644
index 0000000..8e93de4
--- /dev/null
+++ b/iosApp/VoiceInboxShareExtension/ShareViewController.swift
@@ -0,0 +1,172 @@
+import UIKit
+import UniformTypeIdentifiers
+
+final class ShareViewController: UIViewController {
+ private let statusLabel = UILabel()
+ private let doneButton = UIButton(type: .system)
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ configureView()
+ importSharedItems()
+ }
+
+ private func configureView() {
+ view.backgroundColor = .systemBackground
+
+ statusLabel.translatesAutoresizingMaskIntoConstraints = false
+ statusLabel.numberOfLines = 0
+ statusLabel.textAlignment = .center
+ statusLabel.text = "Importing audio..."
+
+ doneButton.translatesAutoresizingMaskIntoConstraints = false
+ doneButton.setTitle("Done", for: .normal)
+ doneButton.addTarget(self, action: #selector(closeExtension), for: .touchUpInside)
+ doneButton.isHidden = true
+
+ view.addSubview(statusLabel)
+ view.addSubview(doneButton)
+
+ NSLayoutConstraint.activate([
+ statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
+ statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
+ statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -24),
+
+ doneButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 20),
+ doneButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+ ])
+ }
+
+ private func importSharedItems() {
+ Task {
+ let result = await stageSharedItems()
+ await MainActor.run {
+ statusLabel.text = result.message
+ doneButton.isHidden = false
+ }
+ }
+ }
+
+ private func stageSharedItems() async -> ShareImportResult {
+ guard let extensionItems = extensionContext?.inputItems as? [NSExtensionItem] else {
+ return ShareImportResult(imported: 0, skipped: 0, failed: 1)
+ }
+
+ var imported = 0
+ var skipped = 0
+ var failed = 0
+
+ for item in extensionItems {
+ for provider in item.attachments ?? [] {
+ do {
+ if try await stageItemProvider(provider) {
+ imported += 1
+ } else {
+ skipped += 1
+ }
+ } catch {
+ failed += 1
+ }
+ }
+ }
+
+ return ShareImportResult(imported: imported, skipped: skipped, failed: failed)
+ }
+
+ private func stageItemProvider(_ provider: NSItemProvider) async throws -> Bool {
+ if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier),
+ let url = try await loadURL(from: provider, typeIdentifier: UTType.fileURL.identifier) {
+ return try stageFile(at: url)
+ }
+
+ for typeIdentifier in IosSharedImportStaging.supportedTypeIdentifiers {
+ if provider.hasItemConformingToTypeIdentifier(typeIdentifier),
+ let url = try await loadFileRepresentation(from: provider, typeIdentifier: typeIdentifier) {
+ return try stageFile(at: url)
+ }
+ }
+
+ return false
+ }
+
+ private func loadURL(from provider: NSItemProvider, typeIdentifier: String) async throws -> URL? {
+ try await withCheckedThrowingContinuation { continuation in
+ provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, error in
+ if let error {
+ continuation.resume(throwing: error)
+ return
+ }
+ if let url = item as? URL {
+ continuation.resume(returning: url)
+ return
+ }
+ if let data = item as? Data,
+ let url = URL(dataRepresentation: data, relativeTo: nil) {
+ continuation.resume(returning: url)
+ return
+ }
+ continuation.resume(returning: nil)
+ }
+ }
+ }
+
+ private func loadFileRepresentation(from provider: NSItemProvider, typeIdentifier: String) async throws -> URL? {
+ try await withCheckedThrowingContinuation { continuation in
+ provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in
+ if let error {
+ continuation.resume(throwing: error)
+ return
+ }
+ continuation.resume(returning: url)
+ }
+ }
+ }
+
+ private func stageFile(at sourceURL: URL) throws -> Bool {
+ guard IosSharedImportStaging.isSupportedAudio(sourceURL) else { return false }
+
+ let fileManager = FileManager.default
+ let directory = try IosSharedImportStaging.stagingDirectory(fileManager: fileManager)
+ let displayName = sourceURL.lastPathComponent
+ let stagedFileName = IosSharedImportStaging.uniqueStagedFileName(
+ for: displayName,
+ in: directory,
+ fileManager: fileManager
+ )
+ let stagedURL = directory.appendingPathComponent(stagedFileName)
+
+ if fileManager.fileExists(atPath: stagedURL.path) {
+ try fileManager.removeItem(at: stagedURL)
+ }
+ try fileManager.copyItem(at: sourceURL, to: stagedURL)
+
+ let metadata = IosSharedImportStaging.Metadata(
+ stagedFileName: stagedFileName,
+ displayName: displayName,
+ originalPathHint: sourceURL.lastPathComponent,
+ sizeBytes: IosSharedImportStaging.fileSize(at: stagedURL),
+ modifiedMillis: IosSharedImportStaging.fileModifiedMillis(at: sourceURL),
+ stagedAtMillis: IosSharedImportStaging.currentTimeMillis()
+ )
+ try IosSharedImportStaging.writeMetadata(metadata, in: directory, fileManager: fileManager)
+ return true
+ }
+
+ @objc private func closeExtension() {
+ extensionContext?.completeRequest(returningItems: nil)
+ }
+}
+
+private struct ShareImportResult {
+ let imported: Int
+ let skipped: Int
+ let failed: Int
+
+ var message: String {
+ var parts: [String] = []
+ if imported > 0 { parts.append("\(imported) staged for Voice Inbox") }
+ if skipped > 0 { parts.append("\(skipped) skipped") }
+ if failed > 0 { parts.append("\(failed) failed") }
+ return parts.isEmpty ? "No supported audio files found." : parts.joined(separator: ", ")
+ }
+}
diff --git a/iosApp/VoiceInboxShareExtension/VoiceInboxShareExtension.entitlements b/iosApp/VoiceInboxShareExtension/VoiceInboxShareExtension.entitlements
new file mode 100644
index 0000000..087647e
--- /dev/null
+++ b/iosApp/VoiceInboxShareExtension/VoiceInboxShareExtension.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.me.maxistar.voiceinbox.ios
+
+
+
diff --git a/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift b/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift
new file mode 100644
index 0000000..d3a95cf
--- /dev/null
+++ b/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift
@@ -0,0 +1,259 @@
+import Foundation
+import Shared
+import XCTest
+@testable import VoiceInbox
+
+final class DeferredSpeechModelLoadingTests: XCTestCase {
+ func testLightweightInspectionDistinguishesMissingLegacyVerifiedAndKnownInvalidWithoutReadingPayloads() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ let model = root.appendingPathComponent("SpeechModel", isDirectory: true)
+ let receipt = root.appendingPathComponent("SpeechModel.receipt")
+ let invalid = root.appendingPathComponent("SpeechModel.invalid")
+ defer { try? FileManager.default.removeItem(at: root) }
+
+ let missing = IosSpeechModelStore.inspectLightweight(
+ directory: model,
+ receiptFile: receipt,
+ invalidFile: invalid,
+ requiredFileNames: ["large-model.onnx"]
+ )
+ XCTAssertEqual(missing.installationState, .missing)
+
+ try FileManager.default.createDirectory(at: model, withIntermediateDirectories: true)
+ // Deliberately invalid payload contents prove the startup probe only checks presence/readability.
+ try Data("not an ONNX model".utf8).write(to: model.appendingPathComponent("large-model.onnx"))
+ let legacy = IosSpeechModelStore.inspectLightweight(
+ directory: model,
+ receiptFile: receipt,
+ invalidFile: invalid,
+ requiredFileNames: ["large-model.onnx"]
+ )
+ XCTAssertEqual(legacy.installationState, .installedLegacy)
+ XCTAssertTrue(legacy.isReady)
+
+ try EmbeddedSpeechModel.shared.manifest.version.write(to: receipt, atomically: true, encoding: .utf8)
+ let verified = IosSpeechModelStore.inspectLightweight(
+ directory: model,
+ receiptFile: receipt,
+ invalidFile: invalid,
+ requiredFileNames: ["large-model.onnx"]
+ )
+ XCTAssertEqual(verified.installationState, .installedVerified)
+
+ try "checksum mismatch".write(to: invalid, atomically: true, encoding: .utf8)
+ let knownInvalid = IosSpeechModelStore.inspectLightweight(
+ directory: model,
+ receiptFile: receipt,
+ invalidFile: invalid,
+ requiredFileNames: ["large-model.onnx"]
+ )
+ XCTAssertEqual(knownInvalid.installationState, .invalid)
+ XCTAssertFalse(knownInvalid.isReady)
+ }
+
+ @MainActor
+ func testLegacyPreparationWritesReceiptAndConcurrentAndLaterCallsReuseSingleLoad() async throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ let receipt = root.appendingPathComponent("SpeechModel.receipt")
+ defer { try? FileManager.default.removeItem(at: root) }
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ let loads = LockedCounter()
+
+ let store = IosSpeechModelStore(
+ directory: root,
+ inspectInstallation: { directory in
+ IosSpeechModelStatus(
+ directory: directory,
+ installationState: FileManager.default.fileExists(atPath: receipt.path)
+ ? .installedVerified
+ : .installedLegacy,
+ missingFiles: []
+ )
+ },
+ validateInstallation: { _ in [] },
+ prepareNative: { _ in
+ loads.increment()
+ Thread.sleep(forTimeInterval: 0.05)
+ return true
+ },
+ nativeError: { nil },
+ recordVerified: {
+ try? EmbeddedSpeechModel.shared.manifest.version.write(
+ to: receipt,
+ atomically: true,
+ encoding: .utf8
+ )
+ },
+ recordInvalid: { _ in },
+ resetNative: {}
+ )
+
+ async let first = store.prepareForTranscription()
+ async let second = store.prepareForTranscription()
+ let results = await [first, second]
+
+ XCTAssertEqual(results.compactMap { $0 }.count, 2)
+ XCTAssertEqual(loads.value, 1)
+ XCTAssertEqual(store.status.installationState, .installedVerified)
+ XCTAssertTrue(FileManager.default.fileExists(atPath: receipt.path))
+
+ _ = await store.prepareForTranscription()
+ XCTAssertEqual(loads.value, 1)
+
+ store.invalidateRuntimeAfterReplacement()
+ XCTAssertEqual(store.runtimeState, .unloaded)
+ _ = await store.prepareForTranscription()
+ XCTAssertEqual(loads.value, 2)
+ }
+
+ @MainActor
+ func testPreparationFailureDoesNotClaimAudioAndDeferredHandoffDoesNotPrepareEarly() async {
+ let prepares = LockedCounter()
+ let claims = LockedCounter()
+
+ XCTAssertEqual(prepares.value, 0)
+ XCTAssertEqual(claims.value, 0)
+
+ let handedOff = await IosTranscriptionPreparationGate.prepareAndClaim(
+ prepare: {
+ prepares.increment()
+ return false
+ },
+ claim: { claims.increment() }
+ )
+
+ XCTAssertFalse(handedOff)
+ XCTAssertEqual(prepares.value, 1)
+ XCTAssertEqual(claims.value, 0)
+ }
+
+ @MainActor
+ func testTaskListAdapterSupportsAllAndKeepsOptionalUnselectedFolderOutOfTasks() {
+ let modelStatus = IosSpeechModelStatus(
+ directory: FileManager.default.temporaryDirectory,
+ installationState: .installedVerified,
+ missingFiles: []
+ )
+ let files = [
+ IosImportedAudioFile(
+ id: 1,
+ displayName: "new.m4a",
+ localFileName: "new.m4a",
+ sizeBytes: 10,
+ importedAt: Date(timeIntervalSince1970: 100),
+ status: .pending
+ ),
+ IosImportedAudioFile(
+ id: 2,
+ displayName: "failed.m4a",
+ localFileName: "failed.m4a",
+ sizeBytes: 20,
+ importedAt: Date(timeIntervalSince1970: 200),
+ status: .failed,
+ lastError: "decode failed",
+ processedAt: Date(timeIntervalSince1970: 300)
+ ),
+ ]
+
+ let screen = IosMainScreenShellState().screen(
+ selection: .all,
+ importedFiles: files,
+ modelStatus: modelStatus,
+ modelMessage: nil,
+ modelInstalling: false,
+ modelInstallationPhase: nil,
+ modelDownloadAvailable: false,
+ modelDownloadProgress: nil,
+ modelCanCancel: false,
+ outputStatus: IosOutputDocumentStatus(displayName: "notes.md", message: "Ready", ready: true),
+ folderStatus: IosInboxFolderStatus(displayName: nil, message: nil, needsSelection: true),
+ folderScanning: false,
+ activePreviewEntryId: nil,
+ previewState: .idle,
+ transcription: .idle,
+ preparationOwnerEntryId: nil,
+ prerequisiteError: nil,
+ actionsEnabled: true
+ )
+
+ XCTAssertEqual(screen.state.tasks.map(\.stableId), ["audio:2", "audio:1"])
+ XCTAssertFalse(screen.state.tasks.contains { $0.stableId == "setup:model" })
+ XCTAssertFalse(screen.state.tasks.contains { $0.stableId == "setup:folder" })
+ XCTAssertEqual(screen.filesById.count, 2)
+ }
+
+ @MainActor
+ func testTaskListAdapterAttachesPreparationFailureToPendingOwner() {
+ let file = IosImportedAudioFile(
+ id: 9,
+ displayName: "voice.m4a",
+ localFileName: "voice.m4a",
+ sizeBytes: 10,
+ importedAt: Date(),
+ status: .pending
+ )
+ let screen = IosMainScreenShellState().screen(
+ selection: .new,
+ importedFiles: [file],
+ modelStatus: IosSpeechModelStatus(
+ directory: FileManager.default.temporaryDirectory,
+ installationState: .installedVerified,
+ missingFiles: []
+ ),
+ modelMessage: nil,
+ modelInstalling: false,
+ modelInstallationPhase: nil,
+ modelDownloadAvailable: false,
+ modelDownloadProgress: nil,
+ modelCanCancel: false,
+ outputStatus: IosOutputDocumentStatus(displayName: "notes.md", message: "Ready", ready: true),
+ folderStatus: IosInboxFolderStatus(displayName: nil, message: nil, needsSelection: true),
+ folderScanning: false,
+ activePreviewEntryId: nil,
+ previewState: .idle,
+ transcription: .idle,
+ preparationOwnerEntryId: 9,
+ prerequisiteError: "Model could not be loaded",
+ actionsEnabled: true
+ )
+
+ guard let task = screen.state.tasks.first as? AudioTaskPresentation else {
+ return XCTFail("Expected an audio task")
+ }
+ XCTAssertEqual(task.state, .pending)
+ XCTAssertEqual(task.errorMessage, "Model could not be loaded")
+ }
+
+ func testTypedActionsHaveExplicitIosRoutes() {
+ XCTAssertEqual(IosTaskActionRouter.route(.downloadModel), .modelDownload)
+ XCTAssertEqual(IosTaskActionRouter.route(.selectOutput), .outputSelection)
+ XCTAssertEqual(IosTaskActionRouter.route(.selectFolder), .folderSelection)
+ XCTAssertEqual(IosTaskActionRouter.route(.transcribe), .transcribe)
+ XCTAssertEqual(IosTaskActionRouter.route(.retryTranscription), .retry)
+ XCTAssertEqual(IosTaskActionRouter.route(.showText), .showText)
+ }
+
+ func testRoutineImportAndScanSummariesDoNotRequestAnAlert() {
+ XCTAssertNil(IosAudioImportSummary(imported: 2, skipped: 0, failed: 0).alertMessage)
+ XCTAssertNil(IosAudioImportSummary(imported: 0, skipped: 4, failed: 0).alertMessage)
+ XCTAssertEqual(
+ IosAudioImportSummary(imported: 1, skipped: 0, failed: 1).alertMessage,
+ "1 imported, 1 failed"
+ )
+ }
+}
+
+private final class LockedCounter: @unchecked Sendable {
+ private let lock = NSLock()
+ private var count = 0
+
+ var value: Int {
+ lock.withLock { count }
+ }
+
+ func increment() {
+ lock.withLock { count += 1 }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/AudioCatalog.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/AudioCatalog.kt
index da89fbc..ccaefce 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/AudioCatalog.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/AudioCatalog.kt
@@ -35,19 +35,55 @@ data class AudioCatalogEntry(
val transcriptText: String?,
)
+data class AudioCatalogSourceScope(
+ val sourceIds: List,
+) {
+ init {
+ require(sourceIds.isNotEmpty()) { "At least one audio catalog source is required" }
+ require(sourceIds.none(String::isBlank)) { "Audio catalog source ids must not be blank" }
+ require(sourceIds.distinct().size == sourceIds.size) {
+ "Audio catalog source ids must be unique"
+ }
+ }
+
+ companion object {
+ fun single(sourceId: String): AudioCatalogSourceScope =
+ AudioCatalogSourceScope(listOf(sourceId))
+
+ fun of(sourceIds: Iterable): AudioCatalogSourceScope =
+ AudioCatalogSourceScope(sourceIds.distinct().toList())
+ }
+}
+
interface AudioCatalogQueuePort {
- fun pendingCount(folderUri: String): Int
+ fun pendingCount(scope: AudioCatalogSourceScope): Int
+
+ fun pendingCount(sourceId: String): Int =
+ pendingCount(AudioCatalogSourceScope.single(sourceId))
fun recoverInterrupted()
- fun claimPending(folderUri: String, specificId: Long? = null): AudioCatalogEntry?
+ fun claimPending(
+ scope: AudioCatalogSourceScope,
+ specificId: Long? = null,
+ ): AudioCatalogEntry?
+
+ fun claimPending(sourceId: String, specificId: Long? = null): AudioCatalogEntry? =
+ claimPending(AudioCatalogSourceScope.single(sourceId), specificId)
- fun claimFailed(folderUri: String, id: Long): AudioCatalogEntry?
+ fun claimFailed(scope: AudioCatalogSourceScope, id: Long): AudioCatalogEntry?
+
+ fun claimFailed(sourceId: String, id: Long): AudioCatalogEntry? =
+ claimFailed(AudioCatalogSourceScope.single(sourceId), id)
fun markProcessed(id: Long, processedAtMillis: Long, transcriptText: String)
fun markFailed(id: Long, message: String)
+ fun markFailedAt(id: Long, message: String, processedAtMillis: Long) {
+ markFailed(id, message)
+ }
+
fun markPending(id: Long)
}
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCase.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCase.kt
index e58d1d3..eecd85c 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCase.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCase.kt
@@ -3,7 +3,7 @@ package me.maxistar.voiceinbox.core
import kotlin.coroutines.cancellation.CancellationException
data class BatchTranscriptionInput(
- val folderId: String,
+ val sourceScope: AudioCatalogSourceScope,
val outputId: String,
val runId: String,
val retryEntryId: Long? = null,
@@ -11,6 +11,7 @@ data class BatchTranscriptionInput(
data class BatchTranscriptionProgress(
val phase: String,
+ val activeEntryId: Long? = null,
val filename: String? = null,
val completed: Int,
val total: Int,
@@ -62,7 +63,7 @@ class BatchTranscriptionUseCase(
): BatchTranscriptionResult {
catalog.recoverInterrupted()
val total = if (input.retryEntryId == null) {
- catalog.pendingCount(input.folderId)
+ catalog.pendingCount(input.sourceScope)
} else {
1
}
@@ -83,6 +84,7 @@ class BatchTranscriptionUseCase(
onProgress(
BatchTranscriptionProgress(
phase = progress.phase,
+ activeEntryId = entry.id,
filename = entry.displayName,
completed = completed,
total = total,
@@ -106,7 +108,11 @@ class BatchTranscriptionUseCase(
currentEntry = null
throw cancelled
} catch (error: Throwable) {
- catalog.markFailed(entry.id, error.message ?: ERROR_TRANSCRIPTION_FAILED)
+ catalog.markFailedAt(
+ entry.id,
+ error.message ?: ERROR_TRANSCRIPTION_FAILED,
+ clock.currentTimeMillis(),
+ )
failed += 1
}
completed += 1
@@ -137,9 +143,10 @@ class BatchTranscriptionUseCase(
completed: Int,
): AudioCatalogEntry? =
if (input.retryEntryId == null) {
- catalog.claimPending(input.folderId)
+ catalog.claimPending(input.sourceScope)
} else if (completed == 0) {
- catalog.claimFailed(input.folderId, input.retryEntryId)
+ catalog.claimFailed(input.sourceScope, input.retryEntryId)
+ ?: catalog.claimPending(input.sourceScope, input.retryEntryId)
} else {
null
}
@@ -150,6 +157,7 @@ class BatchTranscriptionUseCase(
failed: Int,
): BatchTranscriptionProgress = BatchTranscriptionProgress(
phase = BatchTranscriptionRules.summary(completed, total, failed),
+ activeEntryId = null,
filename = null,
completed = completed,
total = total,
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/MainScreenStateController.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/MainScreenStateController.kt
index 752cf3a..5466619 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/MainScreenStateController.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/MainScreenStateController.kt
@@ -13,12 +13,14 @@ data class MainScreenRowInput(
data class MainScreenInput(
val modelMessage: String,
- val modelLoading: Boolean,
+ val modelInstallationState: SpeechModelInstallationState,
+ val modelRuntimeState: SpeechModelRuntimeState,
val modelDownloadAvailable: Boolean,
val modelDownloadProgress: Int?,
- val modelReady: Boolean,
val outputSelected: Boolean,
val folderSelected: Boolean,
+ val outputReady: Boolean = outputSelected,
+ val folderReady: Boolean = folderSelected,
val pendingCount: Int,
val folderChecking: Boolean,
val folderScanQueued: Boolean,
@@ -40,6 +42,7 @@ data class MainScreenInput(
val activePreviewEntryId: Long?,
val previewState: PreviewPlaybackState,
val rows: List = emptyList(),
+ val importInboxAvailable: Boolean = false,
)
data class MainScreenState(
@@ -77,12 +80,20 @@ object MainScreenStateController {
fun state(input: MainScreenInput): MainScreenState {
val busy = input.folderChecking || input.folderScanQueued || input.scanning
val controls = TranscriptionUiRules.catalogControls(
- modelReady = input.modelReady,
- outputSelected = input.outputSelected,
- folderSelected = input.folderSelected,
+ modelInstallationState = input.modelInstallationState,
+ outputSelected = input.outputReady,
+ folderSelected = input.folderReady,
pendingCount = input.pendingCount,
transcriptionState = input.transcriptionState,
scanning = busy,
+ audioInputAvailable = if (input.folderSelected) {
+ input.folderReady
+ } else {
+ input.importInboxAvailable
+ },
+ ).copy(
+ outputSetupVisible = !input.outputSelected,
+ folderSetupVisible = !input.folderSelected,
)
return MainScreenState(
status = TranscriptionUiRules.statusProgressBlock(input.toStatusInput()),
@@ -132,10 +143,10 @@ object MainScreenStateController {
private fun MainScreenInput.toStatusInput(): StatusProgressInput =
StatusProgressInput(
modelMessage = modelMessage,
- modelLoading = modelLoading,
+ modelInstallationState = modelInstallationState,
+ modelRuntimeState = modelRuntimeState,
modelDownloadAvailable = modelDownloadAvailable,
modelDownloadProgress = modelDownloadProgress,
- modelReady = modelReady,
outputSelected = outputSelected,
folderSelected = folderSelected,
pendingCount = pendingCount,
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/SpeechModelLifecycle.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/SpeechModelLifecycle.kt
new file mode 100644
index 0000000..f8888cd
--- /dev/null
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/SpeechModelLifecycle.kt
@@ -0,0 +1,21 @@
+package me.maxistar.voiceinbox.core
+
+enum class SpeechModelInstallationState {
+ NOT_INSTALLED,
+ INSTALLING,
+ INSTALLED,
+ INVALID,
+}
+
+enum class SpeechModelRuntimeState {
+ UNLOADED,
+ LOADING,
+ LOADED,
+ FAILED,
+}
+
+val SpeechModelInstallationState.isAvailable: Boolean
+ get() = this == SpeechModelInstallationState.INSTALLED
+
+val SpeechModelRuntimeState.isPreparing: Boolean
+ get() = this == SpeechModelRuntimeState.LOADING
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepository.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepository.kt
index 0aa33ca..2a9f471 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepository.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepository.kt
@@ -51,10 +51,18 @@ class SqlDelightAudioCatalogRepository(
queries.selectDisplayRows(folderUri, mapper = ::toCatalogFile).executeAsList()
fun newEntries(folderUri: String): List =
- queries.selectNewDisplayRows(folderUri, mapper = ::toCatalogEntry).executeAsList()
+ newEntries(AudioCatalogSourceScope.single(folderUri))
+
+ fun newEntries(scope: AudioCatalogSourceScope): List =
+ queries.selectNewDisplayRowsForSources(scope.sourceIds, mapper = ::toCatalogEntry)
+ .executeAsList()
fun processedEntries(folderUri: String): List =
- queries.selectProcessedDisplayRows(folderUri, mapper = ::toCatalogEntry).executeAsList()
+ processedEntries(AudioCatalogSourceScope.single(folderUri))
+
+ fun processedEntries(scope: AudioCatalogSourceScope): List =
+ queries.selectProcessedDisplayRowsForSources(scope.sourceIds, mapper = ::toCatalogEntry)
+ .executeAsList()
fun missingEntries(folderUri: String): List =
queries.selectMissingDisplayRows(folderUri, mapper = ::toCatalogEntry).executeAsList()
@@ -65,6 +73,10 @@ class SqlDelightAudioCatalogRepository(
fun catalogFile(id: Long): SqlDelightAudioCatalogFile? =
queries.selectById(id, mapper = ::toCatalogFile).executeAsOneOrNull()
+ fun catalogFile(folderUri: String, documentUri: String): SqlDelightAudioCatalogFile? =
+ queries.selectByFolderDocument(folderUri, documentUri, mapper = ::toCatalogFile)
+ .executeAsOneOrNull()
+
fun upsertImportedFile(
folderUri: String,
documentUri: String,
@@ -141,21 +153,27 @@ class SqlDelightAudioCatalogRepository(
}
}
- override fun pendingCount(folderUri: String): Int =
- queries.pendingCount(folderUri).executeAsOne().toInt()
+ override fun pendingCount(scope: AudioCatalogSourceScope): Int =
+ queries.pendingCountForSources(scope.sourceIds).executeAsOne().toInt()
override fun recoverInterrupted() {
queries.recoverInterrupted()
}
- override fun claimPending(folderUri: String, specificId: Long?): AudioCatalogEntry? =
+ override fun claimPending(
+ scope: AudioCatalogSourceScope,
+ specificId: Long?,
+ ): AudioCatalogEntry? =
queries.transactionWithResult {
val candidate = if (specificId == null) {
- queries.selectNextPendingForClaim(folderUri, mapper = ::toCatalogEntry)
+ queries.selectNextPendingForClaimInSources(
+ scope.sourceIds,
+ mapper = ::toCatalogEntry,
+ )
.executeAsOneOrNull()
} else {
- queries.selectSpecificPendingForClaim(
- folder_uri = folderUri,
+ queries.selectSpecificPendingForClaimInSources(
+ folder_uri = scope.sourceIds,
id = specificId,
mapper = ::toCatalogEntry,
).executeAsOneOrNull()
@@ -163,10 +181,10 @@ class SqlDelightAudioCatalogRepository(
markClaimed(candidate.id, AudioFileState.PENDING)
}
- override fun claimFailed(folderUri: String, id: Long): AudioCatalogEntry? =
+ override fun claimFailed(scope: AudioCatalogSourceScope, id: Long): AudioCatalogEntry? =
queries.transactionWithResult {
- queries.selectSpecificFailedForClaim(
- folder_uri = folderUri,
+ queries.selectSpecificFailedForClaimInSources(
+ folder_uri = scope.sourceIds,
id = id,
mapper = ::toCatalogEntry,
).executeAsOneOrNull() ?: return@transactionWithResult null
@@ -183,7 +201,11 @@ class SqlDelightAudioCatalogRepository(
}
override fun markFailed(id: Long, message: String) {
- queries.markFailed(last_error = message, id = id)
+ queries.markFailed(last_error = message, processed_at = null, id = id)
+ }
+
+ override fun markFailedAt(id: Long, message: String, processedAtMillis: Long) {
+ queries.markFailed(last_error = message, processed_at = processedAtMillis, id = id)
}
override fun markPending(id: Long) {
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRules.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRules.kt
new file mode 100644
index 0000000..472be6d
--- /dev/null
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRules.kt
@@ -0,0 +1,67 @@
+package me.maxistar.voiceinbox.core
+
+enum class StartupProcessingPolicy {
+ ASK,
+ AUTOMATIC,
+ LEAVE_QUEUED,
+}
+
+data class StartupProcessingInput(
+ val policy: StartupProcessingPolicy = StartupProcessingPolicy.ASK,
+ val evaluationComplete: Boolean = false,
+ val startupScanComplete: Boolean = false,
+ val startupScanFailed: Boolean = false,
+ val catalogReady: Boolean = false,
+ val pendingCount: Int = 0,
+ val failedCount: Int = 0,
+ val folderReady: Boolean = false,
+ val outputReady: Boolean = false,
+ val modelInstallationState: SpeechModelInstallationState = SpeechModelInstallationState.NOT_INSTALLED,
+ val transcriptionStateKnown: Boolean = false,
+ val transcriptionActive: Boolean = false,
+)
+
+sealed interface StartupProcessingDecision {
+ data object Wait : StartupProcessingDecision
+
+ data class Prompt(val pendingCount: Int) : StartupProcessingDecision
+
+ data class Start(val pendingCount: Int) : StartupProcessingDecision
+
+ data class Finish(val reason: StartupProcessingFinishReason) : StartupProcessingDecision
+}
+
+enum class StartupProcessingFinishReason {
+ ALREADY_EVALUATED,
+ SCAN_FAILED,
+ NO_PENDING,
+ LEAVE_QUEUED,
+}
+
+object StartupProcessingRules {
+ fun decide(input: StartupProcessingInput): StartupProcessingDecision {
+ if (input.evaluationComplete) {
+ return StartupProcessingDecision.Finish(StartupProcessingFinishReason.ALREADY_EVALUATED)
+ }
+ if (input.startupScanFailed) {
+ return StartupProcessingDecision.Finish(StartupProcessingFinishReason.SCAN_FAILED)
+ }
+ if (!input.startupScanComplete || !input.catalogReady || !input.transcriptionStateKnown) {
+ return StartupProcessingDecision.Wait
+ }
+ if (input.pendingCount <= 0) {
+ return StartupProcessingDecision.Finish(StartupProcessingFinishReason.NO_PENDING)
+ }
+ if (input.policy == StartupProcessingPolicy.LEAVE_QUEUED) {
+ return StartupProcessingDecision.Finish(StartupProcessingFinishReason.LEAVE_QUEUED)
+ }
+ if (!input.folderReady || !input.outputReady || !input.modelInstallationState.isAvailable || input.transcriptionActive) {
+ return StartupProcessingDecision.Wait
+ }
+ return when (input.policy) {
+ StartupProcessingPolicy.ASK -> StartupProcessingDecision.Prompt(input.pendingCount)
+ StartupProcessingPolicy.AUTOMATIC -> StartupProcessingDecision.Start(input.pendingCount)
+ StartupProcessingPolicy.LEAVE_QUEUED -> error("Leave-queued policy is handled above")
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt
new file mode 100644
index 0000000..40ac0fc
--- /dev/null
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt
@@ -0,0 +1,443 @@
+package me.maxistar.voiceinbox.core
+
+enum class TaskListFilter {
+ NEW,
+ PROCESSED,
+ ALL,
+}
+
+enum class SetupTaskKind {
+ MODEL,
+ OUTPUT,
+ FOLDER,
+}
+
+enum class SetupTaskState {
+ REQUIRED,
+ ACTIVE,
+ ERROR,
+}
+
+enum class AudioTaskState {
+ PENDING,
+ PROCESSING,
+ SUCCEEDED,
+ FAILED,
+ NO_SPEECH,
+}
+
+enum class TaskRetention {
+ UNTIL_COMPLETED,
+ RETAINED,
+}
+
+enum class TaskActionKind {
+ DOWNLOAD_MODEL,
+ IMPORT_MODEL,
+ CANCEL_MODEL_DOWNLOAD,
+ RETRY_MODEL_DOWNLOAD,
+ SELECT_OUTPUT,
+ SELECT_FOLDER,
+ REFRESH_FOLDER,
+ TRANSCRIBE,
+ TRANSCRIBE_ALL,
+ RETRY_TRANSCRIPTION,
+ PLAY,
+ STOP,
+ SHOW_TEXT,
+ IMPORT_AUDIO,
+}
+
+data class TaskActionPresentation(
+ val kind: TaskActionKind,
+ val label: String,
+ val enabled: Boolean = true,
+)
+
+data class TaskProgressPresentation(
+ val phase: String,
+ val percent: Int? = null,
+ val processedUs: Long? = null,
+ val durationUs: Long? = null,
+ val completedFiles: Int? = null,
+ val totalFiles: Int? = null,
+ val failedFiles: Int? = null,
+)
+
+sealed class TaskPresentation {
+ abstract val stableId: String
+ abstract val title: String
+ abstract val detail: String?
+ abstract val badge: String
+ abstract val progress: TaskProgressPresentation?
+ abstract val errorMessage: String?
+ abstract val actions: List
+ abstract val retention: TaskRetention
+}
+
+data class SetupTaskPresentation(
+ override val stableId: String,
+ val kind: SetupTaskKind,
+ val state: SetupTaskState,
+ override val title: String,
+ override val detail: String?,
+ override val badge: String,
+ override val progress: TaskProgressPresentation?,
+ override val errorMessage: String?,
+ override val actions: List,
+ override val retention: TaskRetention = TaskRetention.UNTIL_COMPLETED,
+) : TaskPresentation()
+
+data class AudioTaskPresentation(
+ override val stableId: String,
+ val entryId: Long,
+ val state: AudioTaskState,
+ override val title: String,
+ override val detail: String?,
+ override val badge: String,
+ override val progress: TaskProgressPresentation?,
+ override val errorMessage: String?,
+ override val actions: List,
+ override val retention: TaskRetention = TaskRetention.RETAINED,
+) : TaskPresentation()
+
+enum class ModelSetupSnapshotState {
+ REQUIRED,
+ INSTALLING,
+ INVALID,
+ READY,
+}
+
+data class ModelSetupSnapshot(
+ val state: ModelSetupSnapshotState,
+ val detail: String? = null,
+ val installationPhase: String? = null,
+ val progressPercent: Int? = null,
+ val downloadAvailable: Boolean = false,
+ val canCancel: Boolean = false,
+)
+
+enum class OutputSetupSnapshotState {
+ REQUIRED,
+ INVALID,
+ READY,
+}
+
+data class OutputSetupSnapshot(
+ val state: OutputSetupSnapshotState,
+ val detail: String? = null,
+)
+
+enum class FolderSetupSnapshotState {
+ UNSELECTED,
+ READY,
+ SCANNING,
+ ERROR,
+}
+
+data class FolderSetupSnapshot(
+ val state: FolderSetupSnapshotState,
+ val detail: String? = null,
+)
+
+data class AudioTaskSnapshot(
+ val entryId: Long,
+ val title: String,
+ val detail: String? = null,
+ val state: AudioFileState,
+ val importedAtMillis: Long,
+ val terminalAtMillis: Long? = null,
+ val lastError: String? = null,
+ val hasTranscriptText: Boolean = false,
+ val noSpeech: Boolean = false,
+ val eligibleForTranscription: Boolean = true,
+ val eligibleForPreview: Boolean = true,
+)
+
+data class PreviewTaskSnapshot(
+ val activeEntryId: Long? = null,
+ val state: PreviewPlaybackState = PreviewPlaybackState.IDLE,
+)
+
+data class TranscriptionTaskSnapshot(
+ val active: Boolean = false,
+ val activeEntryId: Long? = null,
+ val preparationOwnerEntryId: Long? = null,
+ val phase: String? = null,
+ val percent: Int? = null,
+ val processedUs: Long? = null,
+ val durationUs: Long? = null,
+ val completedFiles: Int? = null,
+ val totalFiles: Int? = null,
+ val failedFiles: Int? = null,
+ val prerequisiteError: String? = null,
+)
+
+data class TaskListInput(
+ val filter: TaskListFilter,
+ val model: ModelSetupSnapshot,
+ val output: OutputSetupSnapshot,
+ val folder: FolderSetupSnapshot,
+ val audio: List,
+ val preview: PreviewTaskSnapshot = PreviewTaskSnapshot(),
+ val transcription: TranscriptionTaskSnapshot = TranscriptionTaskSnapshot(),
+)
+
+data class TaskListBatchActionState(
+ val visible: Boolean,
+ val enabled: Boolean,
+ val eligibleCount: Int,
+)
+
+data class TaskListState(
+ val filter: TaskListFilter,
+ val tasks: List,
+ val emptyMessage: String?,
+ val emptyActions: List,
+ val batchAction: TaskListBatchActionState,
+)
+
+object TaskListPresentationController {
+ fun state(input: TaskListInput): TaskListState {
+ val setupTasks = setupTasks(input)
+ val audioTasks = input.audio
+ .filter { visibleInFilter(it.state, input.filter) }
+ .sortedWith(audioComparator(input.filter))
+ .map { audioTask(it, input) }
+ val visibleSetup = if (input.filter == TaskListFilter.PROCESSED) emptyList() else setupTasks
+ val tasks = visibleSetup + audioTasks
+ val eligibleCount = input.audio.count {
+ it.state == AudioFileState.PENDING && it.eligibleForTranscription
+ }
+ val batchVisible = input.filter == TaskListFilter.NEW && eligibleCount > 0
+ return TaskListState(
+ filter = input.filter,
+ tasks = tasks,
+ emptyMessage = if (tasks.isEmpty()) emptyMessage(input.filter) else null,
+ emptyActions = if (tasks.isEmpty() && input.filter != TaskListFilter.PROCESSED) {
+ buildList {
+ add(TaskActionPresentation(TaskActionKind.IMPORT_AUDIO, "Import Audio Files"))
+ if (input.folder.state == FolderSetupSnapshotState.UNSELECTED) {
+ add(TaskActionPresentation(TaskActionKind.SELECT_FOLDER, "Select Audio Folder"))
+ }
+ }
+ } else {
+ emptyList()
+ },
+ batchAction = TaskListBatchActionState(
+ visible = batchVisible,
+ enabled = batchVisible && !input.transcription.active,
+ eligibleCount = eligibleCount,
+ ),
+ )
+ }
+
+ fun preparationOwnerEntryId(audio: List): Long? = audio
+ .asSequence()
+ .filter { it.state == AudioFileState.PENDING && it.eligibleForTranscription }
+ .sortedWith(compareBy({ it.importedAtMillis }, { it.title.lowercase() }, { it.entryId }))
+ .firstOrNull()
+ ?.entryId
+
+ private fun setupTasks(input: TaskListInput): List = buildList {
+ modelTask(input.model)?.let(::add)
+ outputTask(input.output)?.let(::add)
+ folderTask(input.folder)?.let(::add)
+ }
+
+ private fun modelTask(snapshot: ModelSetupSnapshot): SetupTaskPresentation? {
+ if (snapshot.state == ModelSetupSnapshotState.READY) return null
+ val active = snapshot.state == ModelSetupSnapshotState.INSTALLING
+ val error = snapshot.state == ModelSetupSnapshotState.INVALID
+ val actions = when {
+ active && snapshot.canCancel -> listOf(
+ TaskActionPresentation(TaskActionKind.CANCEL_MODEL_DOWNLOAD, "Cancel"),
+ )
+ active -> emptyList()
+ error -> listOf(
+ TaskActionPresentation(TaskActionKind.RETRY_MODEL_DOWNLOAD, "Retry Download", snapshot.downloadAvailable),
+ TaskActionPresentation(TaskActionKind.IMPORT_MODEL, "Install Manually"),
+ )
+ else -> buildList {
+ if (snapshot.downloadAvailable) {
+ add(TaskActionPresentation(TaskActionKind.DOWNLOAD_MODEL, "Download Model"))
+ }
+ add(TaskActionPresentation(TaskActionKind.IMPORT_MODEL, "Install Manually"))
+ }
+ }
+ return SetupTaskPresentation(
+ stableId = "setup:model",
+ kind = SetupTaskKind.MODEL,
+ state = when {
+ active -> SetupTaskState.ACTIVE
+ error -> SetupTaskState.ERROR
+ else -> SetupTaskState.REQUIRED
+ },
+ title = "Install Speech Model",
+ detail = snapshot.detail,
+ badge = if (active) "Installing" else if (error) "Needs attention" else "Required",
+ progress = if (active) {
+ TaskProgressPresentation(
+ snapshot.installationPhase?.takeIf(String::isNotBlank) ?: "Installing model",
+ snapshot.progressPercent,
+ )
+ } else {
+ null
+ },
+ errorMessage = snapshot.detail.takeIf { error },
+ actions = actions,
+ )
+ }
+
+ private fun outputTask(snapshot: OutputSetupSnapshot): SetupTaskPresentation? {
+ if (snapshot.state == OutputSetupSnapshotState.READY) return null
+ val error = snapshot.state == OutputSetupSnapshotState.INVALID
+ return SetupTaskPresentation(
+ stableId = "setup:output",
+ kind = SetupTaskKind.OUTPUT,
+ state = if (error) SetupTaskState.ERROR else SetupTaskState.REQUIRED,
+ title = "Select Output File",
+ detail = snapshot.detail,
+ badge = if (error) "Needs attention" else "Required",
+ progress = null,
+ errorMessage = snapshot.detail.takeIf { error },
+ actions = listOf(TaskActionPresentation(TaskActionKind.SELECT_OUTPUT, "Select Output File")),
+ )
+ }
+
+ private fun folderTask(snapshot: FolderSetupSnapshot): SetupTaskPresentation? = when (snapshot.state) {
+ FolderSetupSnapshotState.UNSELECTED,
+ FolderSetupSnapshotState.READY,
+ -> null
+ FolderSetupSnapshotState.SCANNING -> SetupTaskPresentation(
+ stableId = "setup:folder",
+ kind = SetupTaskKind.FOLDER,
+ state = SetupTaskState.ACTIVE,
+ title = "Refresh Audio Folder",
+ detail = snapshot.detail,
+ badge = "Scanning",
+ progress = TaskProgressPresentation("Scanning audio folder"),
+ errorMessage = null,
+ actions = emptyList(),
+ )
+ FolderSetupSnapshotState.ERROR -> SetupTaskPresentation(
+ stableId = "setup:folder",
+ kind = SetupTaskKind.FOLDER,
+ state = SetupTaskState.ERROR,
+ title = "Restore Audio Folder Access",
+ detail = snapshot.detail,
+ badge = "Needs attention",
+ progress = null,
+ errorMessage = snapshot.detail,
+ actions = listOf(
+ TaskActionPresentation(TaskActionKind.SELECT_FOLDER, "Select Folder"),
+ TaskActionPresentation(TaskActionKind.REFRESH_FOLDER, "Retry"),
+ ),
+ )
+ }
+
+ private fun audioTask(snapshot: AudioTaskSnapshot, input: TaskListInput): AudioTaskPresentation {
+ val progressOwnerEntryId = input.transcription.activeEntryId
+ ?: input.transcription.preparationOwnerEntryId
+ ?: preparationOwnerEntryId(input.audio).takeIf {
+ input.transcription.active && input.transcription.phase?.contains("model", ignoreCase = true) == true
+ }
+ val ownsProgress = input.transcription.active && (
+ progressOwnerEntryId == snapshot.entryId
+ )
+ val presentationState = when (snapshot.state) {
+ AudioFileState.PENDING -> if (ownsProgress) AudioTaskState.PROCESSING else AudioTaskState.PENDING
+ AudioFileState.PROCESSING -> AudioTaskState.PROCESSING
+ AudioFileState.PROCESSED -> AudioTaskState.SUCCEEDED
+ AudioFileState.FAILED -> if (snapshot.noSpeech) AudioTaskState.NO_SPEECH else AudioTaskState.FAILED
+ AudioFileState.MISSING -> AudioTaskState.FAILED
+ }
+ val isPreviewing = input.preview.activeEntryId == snapshot.entryId &&
+ input.preview.state != PreviewPlaybackState.IDLE
+ val actions = buildList {
+ when (presentationState) {
+ AudioTaskState.PENDING -> add(
+ TaskActionPresentation(
+ TaskActionKind.TRANSCRIBE,
+ "Transcribe",
+ snapshot.eligibleForTranscription && !input.transcription.active,
+ ),
+ )
+ AudioTaskState.FAILED,
+ AudioTaskState.NO_SPEECH,
+ -> add(
+ TaskActionPresentation(
+ TaskActionKind.RETRY_TRANSCRIPTION,
+ "Retry",
+ snapshot.eligibleForTranscription && !input.transcription.active,
+ ),
+ )
+ AudioTaskState.SUCCEEDED -> if (snapshot.hasTranscriptText) {
+ add(TaskActionPresentation(TaskActionKind.SHOW_TEXT, "Show Text"))
+ }
+ AudioTaskState.PROCESSING -> Unit
+ }
+ add(
+ TaskActionPresentation(
+ if (isPreviewing) TaskActionKind.STOP else TaskActionKind.PLAY,
+ if (isPreviewing) "Stop" else "Play",
+ isPreviewing || (snapshot.eligibleForPreview && !input.transcription.active),
+ ),
+ )
+ }
+ val prerequisiteError = input.transcription.prerequisiteError
+ .takeIf {
+ snapshot.state == AudioFileState.PENDING &&
+ (input.transcription.preparationOwnerEntryId ?: preparationOwnerEntryId(input.audio)) == snapshot.entryId
+ }
+ return AudioTaskPresentation(
+ stableId = "audio:${snapshot.entryId}",
+ entryId = snapshot.entryId,
+ state = presentationState,
+ title = snapshot.title,
+ detail = snapshot.detail,
+ badge = when (presentationState) {
+ AudioTaskState.PENDING -> "New"
+ AudioTaskState.PROCESSING -> "Processing"
+ AudioTaskState.SUCCEEDED -> "Processed"
+ AudioTaskState.FAILED -> "Failed"
+ AudioTaskState.NO_SPEECH -> "No speech"
+ },
+ progress = if (ownsProgress) input.transcription.toProgress() else null,
+ errorMessage = prerequisiteError ?: snapshot.lastError,
+ actions = actions,
+ )
+ }
+
+ private fun TranscriptionTaskSnapshot.toProgress(): TaskProgressPresentation =
+ TaskProgressPresentation(
+ phase = phase ?: "Processing",
+ percent = percent,
+ processedUs = processedUs,
+ durationUs = durationUs,
+ completedFiles = completedFiles,
+ totalFiles = totalFiles,
+ failedFiles = failedFiles,
+ )
+
+ private fun visibleInFilter(state: AudioFileState, filter: TaskListFilter): Boolean = when (filter) {
+ TaskListFilter.NEW -> state == AudioFileState.PENDING || state == AudioFileState.PROCESSING
+ TaskListFilter.PROCESSED -> state == AudioFileState.PROCESSED || state == AudioFileState.FAILED
+ TaskListFilter.ALL -> true
+ }
+
+ private fun audioComparator(filter: TaskListFilter): Comparator = when (filter) {
+ TaskListFilter.PROCESSED -> compareByDescending {
+ it.terminalAtMillis ?: it.importedAtMillis
+ }.thenByDescending { it.importedAtMillis }.thenByDescending { it.entryId }
+ TaskListFilter.NEW,
+ TaskListFilter.ALL,
+ -> compareByDescending { it.importedAtMillis }.thenByDescending { it.entryId }
+ }
+
+ private fun emptyMessage(filter: TaskListFilter): String = when (filter) {
+ TaskListFilter.NEW -> "No new tasks"
+ TaskListFilter.PROCESSED -> "No processed audio files"
+ TaskListFilter.ALL -> "No audio tasks"
+ }
+}
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRules.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRules.kt
index 3366394..f390ec5 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRules.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRules.kt
@@ -12,10 +12,10 @@ data class CatalogControlState(
data class StatusProgressInput(
val modelMessage: String,
- val modelLoading: Boolean,
+ val modelInstallationState: SpeechModelInstallationState,
+ val modelRuntimeState: SpeechModelRuntimeState,
val modelDownloadAvailable: Boolean,
val modelDownloadProgress: Int?,
- val modelReady: Boolean,
val outputSelected: Boolean,
val folderSelected: Boolean,
val pendingCount: Int,
@@ -77,23 +77,25 @@ object TranscriptionUiRules {
}
fun catalogControls(
- modelReady: Boolean,
+ modelInstallationState: SpeechModelInstallationState,
outputSelected: Boolean,
folderSelected: Boolean,
pendingCount: Int,
transcriptionState: TranscriptionObservationState,
scanning: Boolean,
+ audioInputAvailable: Boolean = folderSelected,
): CatalogControlState {
val workReady = transcriptionState == TranscriptionObservationState.IDLE ||
transcriptionState == TranscriptionObservationState.FINISHED
val idle = workReady && !scanning
- val prerequisites = modelReady && outputSelected && folderSelected && idle
+ val modelAvailable = modelInstallationState.isAvailable
+ val prerequisites = modelAvailable && outputSelected && audioInputAvailable && idle
return CatalogControlState(
- outputEnabled = modelReady && idle,
- folderEnabled = modelReady && idle,
+ outputEnabled = idle,
+ folderEnabled = idle,
outputSetupVisible = !outputSelected,
folderSetupVisible = !folderSelected,
- refreshEnabled = modelReady && folderSelected && idle,
+ refreshEnabled = modelAvailable && folderSelected && idle,
transcribeAllEnabled = prerequisites && pendingCount > 0,
retryEnabled = prerequisites,
)
@@ -126,7 +128,7 @@ object TranscriptionUiRules {
fun statusProgressBlock(input: StatusProgressInput): StatusProgressBlockState =
when {
input.transcriptionState == TranscriptionObservationState.ACTIVE -> activeTranscription(input)
- input.modelLoading -> StatusProgressBlockState(
+ input.modelInstallationState == SpeechModelInstallationState.INSTALLING -> StatusProgressBlockState(
title = input.modelMessage,
progressVisible = true,
progressIndeterminate = input.modelDownloadProgress == null,
@@ -142,7 +144,7 @@ object TranscriptionUiRules {
progressVisible = true,
progressIndeterminate = true,
)
- !input.modelReady -> StatusProgressBlockState(
+ !input.modelInstallationState.isAvailable -> StatusProgressBlockState(
title = input.modelMessage,
downloadVisible = input.modelDownloadAvailable,
)
diff --git a/shared/src/commonMain/sqldelight/me/maxistar/voiceinbox/db/AudioFiles.sq b/shared/src/commonMain/sqldelight/me/maxistar/voiceinbox/db/AudioFiles.sq
index 99d2800..7e24637 100644
--- a/shared/src/commonMain/sqldelight/me/maxistar/voiceinbox/db/AudioFiles.sq
+++ b/shared/src/commonMain/sqldelight/me/maxistar/voiceinbox/db/AudioFiles.sq
@@ -53,6 +53,17 @@ ORDER BY
display_name COLLATE NOCASE ASC,
document_uri ASC;
+selectNewDisplayRowsForSources:
+SELECT *
+FROM audio_files
+WHERE folder_uri IN ?
+ AND state IN ('PENDING', 'PROCESSING')
+ORDER BY
+ CASE WHEN modified_millis IS NULL THEN 1 ELSE 0 END,
+ modified_millis DESC,
+ display_name COLLATE NOCASE ASC,
+ document_uri ASC;
+
selectProcessedDisplayRows:
SELECT *
FROM audio_files
@@ -64,6 +75,17 @@ ORDER BY
display_name COLLATE NOCASE ASC,
document_uri ASC;
+selectProcessedDisplayRowsForSources:
+SELECT *
+FROM audio_files
+WHERE folder_uri IN ?
+ AND state IN ('PROCESSED', 'FAILED')
+ORDER BY
+ CASE WHEN modified_millis IS NULL THEN 1 ELSE 0 END,
+ modified_millis DESC,
+ display_name COLLATE NOCASE ASC,
+ document_uri ASC;
+
selectMissingDisplayRows:
SELECT *
FROM audio_files
@@ -86,6 +108,12 @@ FROM audio_files
WHERE folder_uri = ?
AND state = 'PENDING';
+pendingCountForSources:
+SELECT count(*)
+FROM audio_files
+WHERE folder_uri IN ?
+ AND state = 'PENDING';
+
selectNextPendingForClaim:
SELECT *
FROM audio_files
@@ -98,6 +126,18 @@ ORDER BY
document_uri ASC
LIMIT 1;
+selectNextPendingForClaimInSources:
+SELECT *
+FROM audio_files
+WHERE folder_uri IN ?
+ AND state = 'PENDING'
+ORDER BY
+ CASE WHEN modified_millis IS NULL THEN 1 ELSE 0 END,
+ modified_millis ASC,
+ display_name COLLATE NOCASE ASC,
+ document_uri ASC
+LIMIT 1;
+
selectSpecificPendingForClaim:
SELECT *
FROM audio_files
@@ -105,6 +145,13 @@ WHERE folder_uri = ?
AND state = 'PENDING'
AND id = ?;
+selectSpecificPendingForClaimInSources:
+SELECT *
+FROM audio_files
+WHERE folder_uri IN ?
+ AND state = 'PENDING'
+ AND id = ?;
+
selectSpecificFailedForClaim:
SELECT *
FROM audio_files
@@ -112,6 +159,13 @@ WHERE folder_uri = ?
AND state = 'FAILED'
AND id = ?;
+selectSpecificFailedForClaimInSources:
+SELECT *
+FROM audio_files
+WHERE folder_uri IN ?
+ AND state = 'FAILED'
+ AND id = ?;
+
insertScannedFile:
INSERT INTO audio_files(
folder_uri,
@@ -215,7 +269,7 @@ markFailed:
UPDATE audio_files
SET state = 'FAILED',
last_error = ?,
- processed_at = NULL,
+ processed_at = ?,
transcript_text = NULL,
duration_us = NULL
WHERE id = ?;
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/AudioCatalogQueuePortTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/AudioCatalogQueuePortTest.kt
index aca91d7..8111563 100644
--- a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/AudioCatalogQueuePortTest.kt
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/AudioCatalogQueuePortTest.kt
@@ -38,8 +38,10 @@ class AudioCatalogQueuePortTest {
) : AudioCatalogQueuePort {
private val entries = entries.associateBy(AudioCatalogEntry::id).toMutableMap()
- override fun pendingCount(folderUri: String): Int =
- entries.values.count { it.folderUri == folderUri && it.state == AudioFileState.PENDING }
+ override fun pendingCount(scope: AudioCatalogSourceScope): Int =
+ entries.values.count {
+ it.folderUri in scope.sourceIds && it.state == AudioFileState.PENDING
+ }
override fun recoverInterrupted() {
entries.keys.toList().forEach { id ->
@@ -56,12 +58,12 @@ class AudioCatalogQueuePortTest {
}
override fun claimPending(
- folderUri: String,
+ scope: AudioCatalogSourceScope,
specificId: Long?,
- ): AudioCatalogEntry? = claim(folderUri, AudioFileState.PENDING, specificId)
+ ): AudioCatalogEntry? = claim(scope, AudioFileState.PENDING, specificId)
- override fun claimFailed(folderUri: String, id: Long): AudioCatalogEntry? =
- claim(folderUri, AudioFileState.FAILED, id)
+ override fun claimFailed(scope: AudioCatalogSourceScope, id: Long): AudioCatalogEntry? =
+ claim(scope, AudioFileState.FAILED, id)
override fun markProcessed(
id: Long,
@@ -105,12 +107,12 @@ class AudioCatalogQueuePortTest {
}
private fun claim(
- folderUri: String,
+ scope: AudioCatalogSourceScope,
state: AudioFileState,
specificId: Long?,
): AudioCatalogEntry? {
val entry = entries.values
- .filter { it.folderUri == folderUri && it.state == state }
+ .filter { it.folderUri in scope.sourceIds && it.state == state }
.filter { specificId == null || it.id == specificId }
.sortedWith(AudioCatalogRules.processingOrder)
.firstOrNull()
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCaseTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCaseTest.kt
index 02e0b7e..4958817 100644
--- a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCaseTest.kt
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCaseTest.kt
@@ -51,6 +51,24 @@ class BatchTranscriptionUseCaseTest {
assertEquals(BatchTranscriptionResult(1, 1, 0, "Completed 1 of 1", 100, false), result)
}
+ @Test
+ fun singleEntryRequestClaimsOnlyTheRequestedPendingEntry() {
+ val catalog = FakeCatalog(
+ entry(1, "first.wav", AudioFileState.PENDING),
+ entry(2, "selected.wav", AudioFileState.PENDING),
+ )
+ val transcriber = FakeTranscriber(
+ 2L to SingleFileTranscriptionResult(10, 5, "selected"),
+ )
+
+ val result = useCase(catalog, transcriber).transcribe(input(retryEntryId = 2)) {}
+
+ assertEquals(listOf(2L), transcriber.transcribedIds)
+ assertEquals(AudioFileState.PENDING, catalog.entry(1).state)
+ assertEquals(AudioFileState.PROCESSED, catalog.entry(2).state)
+ assertEquals(1, result.total)
+ }
+
@Test
fun failedFileIsMarkedAndTranscribeAllContinues() {
val catalog = FakeCatalog(
@@ -69,6 +87,7 @@ class BatchTranscriptionUseCaseTest {
assertEquals(AudioFileState.FAILED, catalog.entry(1).state)
assertEquals("decode failed", catalog.entry(1).lastError)
+ assertEquals(1234, catalog.entry(1).processedAtMillis)
assertEquals(AudioFileState.PROCESSED, catalog.entry(2).state)
assertEquals(BatchTranscriptionResult(2, 2, 1, "Completed 2 of 2, 1 failed", 100, false), result)
assertEquals("Completed 1 of 2, 1 failed", progress.first { it.filename == null }.phase)
@@ -103,6 +122,7 @@ class BatchTranscriptionUseCaseTest {
assertEquals(
BatchTranscriptionProgress(
phase = "Transcribing",
+ activeEntryId = 1,
filename = "one.wav",
completed = 0,
total = 1,
@@ -113,6 +133,31 @@ class BatchTranscriptionUseCaseTest {
),
progress.first(),
)
+ assertEquals(1L, progress.first().activeEntryId)
+ assertNull(progress.last().activeEntryId)
+ }
+
+ @Test
+ fun transcribeAllProcessesPendingEntriesAcrossActiveSources() {
+ val catalog = FakeCatalog(
+ entry(1, "folder.wav", AudioFileState.PENDING, sourceId = FOLDER),
+ entry(2, "imported.ogg", AudioFileState.PENDING, sourceId = IMPORTS),
+ entry(3, "inactive.wav", AudioFileState.PENDING, sourceId = OTHER_FOLDER),
+ )
+ val transcriber = FakeTranscriber(
+ 1L to SingleFileTranscriptionResult(10, 5, "folder"),
+ 2L to SingleFileTranscriptionResult(20, 6, "import"),
+ )
+
+ val result = useCase(catalog, transcriber).transcribe(
+ input().copy(sourceScope = AudioCatalogSourceScope.of(listOf(FOLDER, IMPORTS))),
+ ) {}
+
+ assertEquals(listOf(1L, 2L), transcriber.transcribedIds)
+ assertEquals(AudioFileState.PROCESSED, catalog.entry(1).state)
+ assertEquals(AudioFileState.PROCESSED, catalog.entry(2).state)
+ assertEquals(AudioFileState.PENDING, catalog.entry(3).state)
+ assertEquals(2, result.total)
}
private fun useCase(
@@ -121,7 +166,7 @@ class BatchTranscriptionUseCaseTest {
) = BatchTranscriptionUseCase(catalog, transcriber, FakeClock)
private fun input(retryEntryId: Long? = null) = BatchTranscriptionInput(
- folderId = FOLDER,
+ sourceScope = AudioCatalogSourceScope.single(FOLDER),
outputId = "content://output",
runId = "run-1",
retryEntryId = retryEntryId,
@@ -135,8 +180,10 @@ class BatchTranscriptionUseCaseTest {
fun entry(id: Long): AudioCatalogEntry = entries.getValue(id)
- override fun pendingCount(folderUri: String): Int =
- entries.values.count { it.folderUri == folderUri && it.state == AudioFileState.PENDING }
+ override fun pendingCount(scope: AudioCatalogSourceScope): Int =
+ entries.values.count {
+ it.folderUri in scope.sourceIds && it.state == AudioFileState.PENDING
+ }
override fun recoverInterrupted() {
recoveries += 1
@@ -149,12 +196,12 @@ class BatchTranscriptionUseCaseTest {
}
override fun claimPending(
- folderUri: String,
+ scope: AudioCatalogSourceScope,
specificId: Long?,
- ): AudioCatalogEntry? = claim(folderUri, AudioFileState.PENDING, specificId)
+ ): AudioCatalogEntry? = claim(scope, AudioFileState.PENDING, specificId)
- override fun claimFailed(folderUri: String, id: Long): AudioCatalogEntry? =
- claim(folderUri, AudioFileState.FAILED, id)
+ override fun claimFailed(scope: AudioCatalogSourceScope, id: Long): AudioCatalogEntry? =
+ claim(scope, AudioFileState.FAILED, id)
override fun markProcessed(
id: Long,
@@ -182,6 +229,11 @@ class BatchTranscriptionUseCaseTest {
}
}
+ override fun markFailedAt(id: Long, message: String, processedAtMillis: Long) {
+ markFailed(id, message)
+ update(id) { it.copy(processedAtMillis = processedAtMillis) }
+ }
+
override fun markPending(id: Long) {
update(id) {
if (it.state == AudioFileState.PROCESSING) {
@@ -198,12 +250,12 @@ class BatchTranscriptionUseCaseTest {
}
private fun claim(
- folderUri: String,
+ scope: AudioCatalogSourceScope,
state: AudioFileState,
specificId: Long?,
): AudioCatalogEntry? {
val entry = entries.values
- .filter { it.folderUri == folderUri && it.state == state }
+ .filter { it.folderUri in scope.sourceIds && it.state == state }
.filter { specificId == null || it.id == specificId }
.sortedWith(AudioCatalogRules.processingOrder)
.firstOrNull()
@@ -249,9 +301,10 @@ class BatchTranscriptionUseCaseTest {
id: Long,
name: String,
state: AudioFileState,
+ sourceId: String = FOLDER,
) = AudioCatalogEntry(
id = id,
- folderUri = FOLDER,
+ folderUri = sourceId,
documentUri = "content://audio/$id",
displayName = name,
mimeType = "audio/wav",
@@ -265,5 +318,7 @@ class BatchTranscriptionUseCaseTest {
private companion object {
const val FOLDER = "content://folder"
+ const val IMPORTS = "android-imported-audio"
+ const val OTHER_FOLDER = "content://other-folder"
}
}
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/MainScreenStateControllerTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/MainScreenStateControllerTest.kt
index d1c1d97..ffd1397 100644
--- a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/MainScreenStateControllerTest.kt
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/MainScreenStateControllerTest.kt
@@ -6,12 +6,27 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue
class MainScreenStateControllerTest {
+ @Test
+ fun importInboxEnablesPendingWorkWithoutSelectedFolder() {
+ val state = MainScreenStateController.state(
+ input(
+ outputSelected = true,
+ folderSelected = false,
+ pendingCount = 1,
+ importInboxAvailable = true,
+ ),
+ )
+
+ assertEquals(true, state.controls.transcribeAllEnabled)
+ assertEquals(false, state.controls.refreshEnabled)
+ }
+
@Test
fun setupControlsReflectMissingModelOutputAndFolder() {
assertEquals(
CatalogControlState(
- outputEnabled = false,
- folderEnabled = false,
+ outputEnabled = true,
+ folderEnabled = true,
outputSetupVisible = true,
folderSetupVisible = true,
refreshEnabled = false,
@@ -48,18 +63,59 @@ class MainScreenStateControllerTest {
)
}
+ @Test
+ fun restoringSelectionsStayConfiguredButDoNotEnableDependentActions() {
+ val state = state(
+ input(
+ outputSelected = true,
+ folderSelected = true,
+ outputReady = false,
+ folderReady = false,
+ pendingCount = 2,
+ ),
+ )
+
+ assertFalse(state.controls.outputSetupVisible)
+ assertFalse(state.controls.folderSetupVisible)
+ assertFalse(state.controls.refreshEnabled)
+ assertFalse(state.controls.transcribeAllEnabled)
+ }
+
+ @Test
+ fun installedModelDoesNotRequireLoadedRuntimeForActions() {
+ val unloaded = state(
+ input(
+ outputSelected = true,
+ folderSelected = true,
+ pendingCount = 1,
+ modelRuntimeState = SpeechModelRuntimeState.UNLOADED,
+ ),
+ )
+ val retryableFailure = state(
+ input(
+ outputSelected = true,
+ folderSelected = true,
+ pendingCount = 1,
+ modelRuntimeState = SpeechModelRuntimeState.FAILED,
+ ),
+ )
+
+ assertTrue(unloaded.controls.transcribeAllEnabled)
+ assertTrue(retryableFailure.controls.transcribeAllEnabled)
+ }
+
@Test
fun controlsAreDisabledDuringActiveWorkOrQueuedScan() {
- assertFalse(
- state(
- input(
- outputSelected = true,
- folderSelected = true,
- pendingCount = 2,
- transcriptionState = TranscriptionObservationState.ACTIVE,
- ),
- ).controls.transcribeAllEnabled,
+ val active = state(
+ input(
+ modelReady = false,
+ pendingCount = 2,
+ transcriptionState = TranscriptionObservationState.ACTIVE,
+ ),
)
+ assertFalse(active.controls.outputEnabled)
+ assertFalse(active.controls.folderEnabled)
+ assertFalse(active.controls.transcribeAllEnabled)
val queued = state(
input(
@@ -256,8 +312,11 @@ class MainScreenStateControllerTest {
modelDownloadAvailable: Boolean = false,
modelDownloadProgress: Int? = null,
modelReady: Boolean = true,
+ modelRuntimeState: SpeechModelRuntimeState = SpeechModelRuntimeState.UNLOADED,
outputSelected: Boolean = false,
folderSelected: Boolean = false,
+ outputReady: Boolean = outputSelected,
+ folderReady: Boolean = folderSelected,
pendingCount: Int = 0,
folderChecking: Boolean = false,
folderScanQueued: Boolean = false,
@@ -279,14 +338,21 @@ class MainScreenStateControllerTest {
activePreviewEntryId: Long? = null,
previewState: PreviewPlaybackState = PreviewPlaybackState.IDLE,
rows: List = emptyList(),
+ importInboxAvailable: Boolean = false,
) = MainScreenInput(
modelMessage = modelMessage,
- modelLoading = modelLoading,
+ modelInstallationState = when {
+ modelLoading -> SpeechModelInstallationState.INSTALLING
+ modelReady -> SpeechModelInstallationState.INSTALLED
+ else -> SpeechModelInstallationState.NOT_INSTALLED
+ },
+ modelRuntimeState = modelRuntimeState,
modelDownloadAvailable = modelDownloadAvailable,
modelDownloadProgress = modelDownloadProgress,
- modelReady = modelReady,
outputSelected = outputSelected,
folderSelected = folderSelected,
+ outputReady = outputReady,
+ folderReady = folderReady,
pendingCount = pendingCount,
folderChecking = folderChecking,
folderScanQueued = folderScanQueued,
@@ -308,6 +374,7 @@ class MainScreenStateControllerTest {
activePreviewEntryId = activePreviewEntryId,
previewState = previewState,
rows = rows,
+ importInboxAvailable = importInboxAvailable,
)
private fun row(
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRulesTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRulesTest.kt
new file mode 100644
index 0000000..cbe133b
--- /dev/null
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRulesTest.kt
@@ -0,0 +1,109 @@
+package me.maxistar.voiceinbox.core
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class StartupProcessingRulesTest {
+ @Test
+ fun askPromptsWhenStartupStateIsReady() {
+ assertEquals(
+ StartupProcessingDecision.Prompt(3),
+ StartupProcessingRules.decide(readyInput(policy = StartupProcessingPolicy.ASK, pendingCount = 3)),
+ )
+ }
+
+ @Test
+ fun automaticStartsWhenStartupStateIsReady() {
+ assertEquals(
+ StartupProcessingDecision.Start(2),
+ StartupProcessingRules.decide(readyInput(policy = StartupProcessingPolicy.AUTOMATIC, pendingCount = 2)),
+ )
+ }
+
+ @Test
+ fun leaveQueuedFinishesWithoutProcessing() {
+ assertEquals(
+ StartupProcessingDecision.Finish(StartupProcessingFinishReason.LEAVE_QUEUED),
+ StartupProcessingRules.decide(readyInput(policy = StartupProcessingPolicy.LEAVE_QUEUED)),
+ )
+ }
+
+ @Test
+ fun incompleteReadinessWaits() {
+ val ready = readyInput()
+ val incomplete = listOf(
+ ready.copy(startupScanComplete = false),
+ ready.copy(catalogReady = false),
+ ready.copy(folderReady = false),
+ ready.copy(outputReady = false),
+ ready.copy(modelInstallationState = SpeechModelInstallationState.NOT_INSTALLED),
+ ready.copy(transcriptionStateKnown = false),
+ )
+
+ incomplete.forEach { input ->
+ assertEquals(StartupProcessingDecision.Wait, StartupProcessingRules.decide(input))
+ }
+ }
+
+ @Test
+ fun unavailablePrerequisiteCanBecomeReadyLater() {
+ val missingOutput = readyInput().copy(outputReady = false)
+ assertEquals(StartupProcessingDecision.Wait, StartupProcessingRules.decide(missingOutput))
+ assertEquals(
+ StartupProcessingDecision.Prompt(1),
+ StartupProcessingRules.decide(missingOutput.copy(outputReady = true)),
+ )
+ }
+
+ @Test
+ fun emptyAndFailedOnlyCatalogsFinishAsNoPending() {
+ val finish = StartupProcessingDecision.Finish(StartupProcessingFinishReason.NO_PENDING)
+ assertEquals(finish, StartupProcessingRules.decide(readyInput(pendingCount = 0)))
+ assertEquals(finish, StartupProcessingRules.decide(readyInput(pendingCount = 0, failedCount = 4)))
+ }
+
+ @Test
+ fun activeTranscriptionWaits() {
+ assertEquals(
+ StartupProcessingDecision.Wait,
+ StartupProcessingRules.decide(readyInput(transcriptionActive = true)),
+ )
+ }
+
+ @Test
+ fun scanFailureIsTerminal() {
+ assertEquals(
+ StartupProcessingDecision.Finish(StartupProcessingFinishReason.SCAN_FAILED),
+ StartupProcessingRules.decide(StartupProcessingInput(startupScanFailed = true)),
+ )
+ }
+
+ @Test
+ fun completedEvaluationIsIdempotent() {
+ assertEquals(
+ StartupProcessingDecision.Finish(StartupProcessingFinishReason.ALREADY_EVALUATED),
+ StartupProcessingRules.decide(readyInput(evaluationComplete = true)),
+ )
+ }
+
+ private fun readyInput(
+ policy: StartupProcessingPolicy = StartupProcessingPolicy.ASK,
+ pendingCount: Int = 1,
+ failedCount: Int = 0,
+ transcriptionActive: Boolean = false,
+ evaluationComplete: Boolean = false,
+ ): StartupProcessingInput =
+ StartupProcessingInput(
+ policy = policy,
+ evaluationComplete = evaluationComplete,
+ startupScanComplete = true,
+ catalogReady = true,
+ pendingCount = pendingCount,
+ failedCount = failedCount,
+ folderReady = true,
+ outputReady = true,
+ modelInstallationState = SpeechModelInstallationState.INSTALLED,
+ transcriptionStateKnown = true,
+ transcriptionActive = transcriptionActive,
+ )
+}
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt
new file mode 100644
index 0000000..228e1f3
--- /dev/null
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt
@@ -0,0 +1,261 @@
+package me.maxistar.voiceinbox.core
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class TaskListPresentationControllerTest {
+ @Test
+ fun setupTasksAreSynthesizedInKindOrderAndCompletedTasksDisappear() {
+ val state = state(
+ filter = TaskListFilter.NEW,
+ model = ModelSetupSnapshot(ModelSetupSnapshotState.REQUIRED, downloadAvailable = true),
+ output = OutputSetupSnapshot(OutputSetupSnapshotState.INVALID, "Access expired"),
+ folder = FolderSetupSnapshot(FolderSetupSnapshotState.SCANNING),
+ )
+
+ assertEquals(listOf("setup:model", "setup:output", "setup:folder"), state.tasks.map { it.stableId })
+ assertEquals(listOf(SetupTaskKind.MODEL, SetupTaskKind.OUTPUT, SetupTaskKind.FOLDER), state.tasks.map {
+ assertIs(it).kind
+ })
+
+ val completed = state(
+ filter = TaskListFilter.ALL,
+ model = ModelSetupSnapshot(ModelSetupSnapshotState.READY),
+ output = OutputSetupSnapshot(OutputSetupSnapshotState.READY),
+ folder = FolderSetupSnapshot(FolderSetupSnapshotState.READY),
+ )
+ assertTrue(completed.tasks.isEmpty())
+ }
+
+ @Test
+ fun unselectedOptionalFolderDoesNotCreateBlockingTask() {
+ val state = state(folder = FolderSetupSnapshot(FolderSetupSnapshotState.UNSELECTED))
+
+ assertTrue(state.tasks.none { it.stableId == "setup:folder" })
+ }
+
+ @Test
+ fun activeModelInstallationUsesSuppliedPhaseAndNeutralFallback() {
+ val download = assertIs(
+ state(
+ model = ModelSetupSnapshot(
+ state = ModelSetupSnapshotState.INSTALLING,
+ installationPhase = "Downloading encoder.onnx",
+ progressPercent = 42,
+ canCancel = true,
+ ),
+ ).tasks.single(),
+ )
+
+ assertEquals("Installing", download.badge)
+ assertEquals("Downloading encoder.onnx", download.progress?.phase)
+ assertEquals(42, download.progress?.percent)
+ assertEquals(listOf(TaskActionKind.CANCEL_MODEL_DOWNLOAD), download.actions.map { it.kind })
+
+ val localImport = assertIs(
+ state(
+ model = ModelSetupSnapshot(
+ state = ModelSetupSnapshotState.INSTALLING,
+ installationPhase = "Verifying local model",
+ progressPercent = 75,
+ ),
+ ).tasks.single(),
+ )
+ assertEquals("Verifying local model", localImport.progress?.phase)
+ assertTrue(localImport.actions.isEmpty())
+
+ val fallback = assertIs(
+ state(model = ModelSetupSnapshot(ModelSetupSnapshotState.INSTALLING)).tasks.single(),
+ )
+ assertEquals("Installing model", fallback.progress?.phase)
+ assertNull(fallback.progress?.percent)
+ }
+
+ @Test
+ fun invalidModelOffersOnlyCurrentlySupportedRecoveryActions() {
+ val task = assertIs(
+ state(
+ model = ModelSetupSnapshot(
+ state = ModelSetupSnapshotState.INVALID,
+ detail = "Verification failed",
+ downloadAvailable = false,
+ ),
+ ).tasks.single(),
+ )
+
+ assertEquals("Verification failed", task.errorMessage)
+ assertFalse(task.actions.single { it.kind == TaskActionKind.RETRY_MODEL_DOWNLOAD }.enabled)
+ assertTrue(task.actions.single { it.kind == TaskActionKind.IMPORT_MODEL }.enabled)
+ }
+
+ @Test
+ fun filtersApplyOpenTerminalAndAllRetentionRules() {
+ val audio = listOf(
+ audio(1, AudioFileState.PENDING),
+ audio(2, AudioFileState.PROCESSING),
+ audio(3, AudioFileState.PROCESSED),
+ audio(4, AudioFileState.FAILED),
+ audio(5, AudioFileState.MISSING),
+ )
+
+ assertEquals(listOf("audio:2", "audio:1"), state(TaskListFilter.NEW, audio = audio).tasks.map { it.stableId })
+ assertEquals(listOf("audio:4", "audio:3"), state(TaskListFilter.PROCESSED, audio = audio).tasks.map { it.stableId })
+ assertEquals(listOf("audio:5", "audio:4", "audio:3", "audio:2", "audio:1"), state(TaskListFilter.ALL, audio = audio).tasks.map { it.stableId })
+ }
+
+ @Test
+ fun noSpeechIsTerminalAndRetainsRetry() {
+ val task = state(
+ TaskListFilter.PROCESSED,
+ audio = listOf(audio(1, AudioFileState.FAILED, noSpeech = true, error = "No text was recognized")),
+ ).tasks.single()
+
+ val audioTask = assertIs(task)
+ assertEquals(AudioTaskState.NO_SPEECH, audioTask.state)
+ assertEquals(TaskActionKind.RETRY_TRANSCRIPTION, audioTask.actions.first().kind)
+ }
+
+ @Test
+ fun prerequisiteFailureLeavesPendingTaskInNew() {
+ val state = state(
+ filter = TaskListFilter.NEW,
+ audio = listOf(audio(7, AudioFileState.PENDING)),
+ transcription = TranscriptionTaskSnapshot(
+ active = false,
+ preparationOwnerEntryId = 7,
+ prerequisiteError = "Model could not be loaded",
+ ),
+ )
+
+ val task = assertIs(state.tasks.single())
+ assertEquals(AudioTaskState.PENDING, task.state)
+ assertEquals("Model could not be loaded", task.errorMessage)
+ assertTrue(state(TaskListFilter.PROCESSED, audio = listOf(audio(7, AudioFileState.PENDING))).tasks.isEmpty())
+ }
+
+ @Test
+ fun progressBelongsOnlyToStableActiveAudioTask() {
+ val state = state(
+ audio = listOf(audio(1, AudioFileState.PENDING), audio(2, AudioFileState.PENDING)),
+ transcription = TranscriptionTaskSnapshot(
+ active = true,
+ preparationOwnerEntryId = 1,
+ phase = "Preparing speech model",
+ ),
+ )
+
+ val byId = state.tasks.associateBy(TaskPresentation::stableId)
+ val active = assertIs(byId.getValue("audio:1"))
+ assertEquals(AudioTaskState.PROCESSING, active.state)
+ assertEquals("Preparing speech model", active.progress?.phase)
+ assertNull(assertIs(byId.getValue("audio:2")).progress)
+ assertEquals("audio:1", active.stableId)
+ }
+
+ @Test
+ fun preClaimPreparationUsesDeterministicNextEligiblePendingTask() {
+ val audio = listOf(
+ audio(3, AudioFileState.PENDING, importedAt = 300),
+ audio(1, AudioFileState.PENDING, importedAt = 100),
+ audio(2, AudioFileState.PENDING, importedAt = 50, eligible = false),
+ )
+
+ assertEquals(1L, TaskListPresentationController.preparationOwnerEntryId(audio))
+ val state = state(
+ audio = audio,
+ transcription = TranscriptionTaskSnapshot(active = true, phase = "Preparing speech model"),
+ )
+ assertEquals(
+ "audio:1",
+ state.tasks.single { it.progress != null }.stableId,
+ )
+ assertTrue(audio.all { it.state == AudioFileState.PENDING })
+ }
+
+ @Test
+ fun batchActionCountsOnlyEligiblePendingAudioAndOnlyAppearsInNew() {
+ val audio = listOf(
+ audio(1, AudioFileState.PENDING, eligible = true),
+ audio(2, AudioFileState.PENDING, eligible = false),
+ audio(3, AudioFileState.FAILED, eligible = true),
+ )
+
+ val newState = state(TaskListFilter.NEW, audio = audio)
+ assertTrue(newState.batchAction.visible)
+ assertTrue(newState.batchAction.enabled)
+ assertEquals(1, newState.batchAction.eligibleCount)
+ assertFalse(state(TaskListFilter.ALL, audio = audio).batchAction.visible)
+ assertFalse(state(TaskListFilter.PROCESSED, audio = audio).batchAction.visible)
+ }
+
+ @Test
+ fun orderingUsesImportTimeExceptProcessedUsesTerminalTime() {
+ val audio = listOf(
+ audio(1, AudioFileState.PROCESSED, importedAt = 300, terminalAt = 100),
+ audio(2, AudioFileState.FAILED, importedAt = 100, terminalAt = 400),
+ )
+
+ assertEquals(listOf("audio:2", "audio:1"), state(TaskListFilter.PROCESSED, audio = audio).tasks.map { it.stableId })
+ assertEquals(listOf("audio:1", "audio:2"), state(TaskListFilter.ALL, audio = audio).tasks.map { it.stableId })
+ }
+
+ @Test
+ fun emptyStateOffersFolderSelectionOnlyWhenFolderIsUnselected() {
+ val newState = state(TaskListFilter.NEW)
+ val withoutFolder = state(
+ filter = TaskListFilter.NEW,
+ folder = FolderSetupSnapshot(FolderSetupSnapshotState.UNSELECTED),
+ )
+ val processed = state(TaskListFilter.PROCESSED)
+
+ assertEquals("No new tasks", newState.emptyMessage)
+ assertEquals(listOf(TaskActionKind.IMPORT_AUDIO), newState.emptyActions.map { it.kind })
+ assertEquals(
+ listOf(TaskActionKind.IMPORT_AUDIO, TaskActionKind.SELECT_FOLDER),
+ withoutFolder.emptyActions.map { it.kind },
+ )
+ assertEquals("No processed audio files", processed.emptyMessage)
+ assertTrue(processed.emptyActions.isEmpty())
+ }
+
+ private fun state(
+ filter: TaskListFilter = TaskListFilter.NEW,
+ model: ModelSetupSnapshot = ModelSetupSnapshot(ModelSetupSnapshotState.READY),
+ output: OutputSetupSnapshot = OutputSetupSnapshot(OutputSetupSnapshotState.READY),
+ folder: FolderSetupSnapshot = FolderSetupSnapshot(FolderSetupSnapshotState.READY),
+ audio: List = emptyList(),
+ transcription: TranscriptionTaskSnapshot = TranscriptionTaskSnapshot(),
+ ): TaskListState = TaskListPresentationController.state(
+ TaskListInput(
+ filter = filter,
+ model = model,
+ output = output,
+ folder = folder,
+ audio = audio,
+ transcription = transcription,
+ ),
+ )
+
+ private fun audio(
+ id: Long,
+ state: AudioFileState,
+ importedAt: Long = id,
+ terminalAt: Long? = if (state == AudioFileState.PROCESSED || state == AudioFileState.FAILED) id else null,
+ noSpeech: Boolean = false,
+ error: String? = null,
+ eligible: Boolean = true,
+ ) = AudioTaskSnapshot(
+ entryId = id,
+ title = "$id.wav",
+ state = state,
+ importedAtMillis = importedAt,
+ terminalAtMillis = terminalAt,
+ lastError = error,
+ noSpeech = noSpeech,
+ eligibleForTranscription = eligible,
+ )
+}
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRulesTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRulesTest.kt
index 8991c3e..284b396 100644
--- a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRulesTest.kt
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRulesTest.kt
@@ -5,10 +5,27 @@ import kotlin.test.assertEquals
import kotlin.test.Test
class TranscriptionUiRulesTest {
+ @Test
+ fun importedAudioAllowsTranscriptionWithoutSelectedFolder() {
+ val controls = TranscriptionUiRules.catalogControls(
+ modelInstallationState = SpeechModelInstallationState.INSTALLED,
+ outputSelected = true,
+ folderSelected = false,
+ pendingCount = 1,
+ transcriptionState = TranscriptionObservationState.IDLE,
+ scanning = false,
+ audioInputAvailable = true,
+ )
+
+ assertEquals(true, controls.transcribeAllEnabled)
+ assertEquals(true, controls.retryEnabled)
+ assertEquals(false, controls.refreshEnabled)
+ }
+
@Test
fun controlsRequireModelSelectionsPendingWorkAndIdleState() {
assertEquals(
- CatalogControlState(false, false, true, true, false, false, false),
+ CatalogControlState(true, true, true, true, false, false, false),
controls(modelReady = false),
)
assertEquals(
@@ -39,12 +56,52 @@ class TranscriptionUiRulesTest {
)
}
+ @Test
+ fun storageSelectionDoesNotRequireAnInstalledModel() {
+ listOf(
+ SpeechModelInstallationState.NOT_INSTALLED,
+ SpeechModelInstallationState.INSTALLING,
+ SpeechModelInstallationState.INVALID,
+ SpeechModelInstallationState.INSTALLED,
+ ).forEach { modelState ->
+ val controls = TranscriptionUiRules.catalogControls(
+ modelInstallationState = modelState,
+ outputSelected = false,
+ folderSelected = false,
+ pendingCount = 1,
+ transcriptionState = TranscriptionObservationState.IDLE,
+ scanning = false,
+ )
+
+ assertEquals(true, controls.outputEnabled, modelState.name)
+ assertEquals(true, controls.folderEnabled, modelState.name)
+ assertEquals(false, controls.refreshEnabled, modelState.name)
+ assertEquals(false, controls.transcribeAllEnabled, modelState.name)
+ assertEquals(false, controls.retryEnabled, modelState.name)
+ }
+ }
+
+ @Test
+ fun activeWorkBlocksStorageSelectionWithoutAnInstalledModel() {
+ val controls = TranscriptionUiRules.catalogControls(
+ modelInstallationState = SpeechModelInstallationState.INSTALLING,
+ outputSelected = false,
+ folderSelected = false,
+ pendingCount = 0,
+ transcriptionState = TranscriptionObservationState.ACTIVE,
+ scanning = false,
+ )
+
+ assertEquals(false, controls.outputEnabled)
+ assertEquals(false, controls.folderEnabled)
+ }
+
@Test
fun selectionControlsAreDisabledWhileScanningOrTranscribing() {
assertEquals(
CatalogControlState(false, false, false, false, false, false, false),
TranscriptionUiRules.catalogControls(
- modelReady = true,
+ modelInstallationState = SpeechModelInstallationState.INSTALLED,
outputSelected = true,
folderSelected = true,
pendingCount = 2,
@@ -55,7 +112,7 @@ class TranscriptionUiRulesTest {
assertEquals(
CatalogControlState(false, false, false, false, false, false, false),
TranscriptionUiRules.catalogControls(
- modelReady = true,
+ modelInstallationState = SpeechModelInstallationState.INSTALLED,
outputSelected = true,
folderSelected = true,
pendingCount = 2,
@@ -70,7 +127,7 @@ class TranscriptionUiRulesTest {
assertEquals(
CatalogControlState(false, false, false, false, false, false, false),
TranscriptionUiRules.catalogControls(
- modelReady = true,
+ modelInstallationState = SpeechModelInstallationState.INSTALLED,
outputSelected = true,
folderSelected = true,
pendingCount = 2,
@@ -432,7 +489,7 @@ class TranscriptionUiRulesTest {
pending: Int = 0,
active: Boolean = false,
) = TranscriptionUiRules.catalogControls(
- modelReady,
+ if (modelReady) SpeechModelInstallationState.INSTALLED else SpeechModelInstallationState.NOT_INSTALLED,
output,
folder,
pending,
@@ -466,10 +523,14 @@ class TranscriptionUiRulesTest {
) = TranscriptionUiRules.statusProgressBlock(
StatusProgressInput(
modelMessage = modelMessage,
- modelLoading = modelLoading,
+ modelInstallationState = when {
+ modelLoading -> SpeechModelInstallationState.INSTALLING
+ modelReady -> SpeechModelInstallationState.INSTALLED
+ else -> SpeechModelInstallationState.NOT_INSTALLED
+ },
+ modelRuntimeState = SpeechModelRuntimeState.UNLOADED,
modelDownloadAvailable = modelDownloadAvailable,
modelDownloadProgress = modelDownloadProgress,
- modelReady = modelReady,
outputSelected = output,
folderSelected = folder,
pendingCount = pending,
diff --git a/shared/src/jvmTest/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepositoryTest.kt b/shared/src/jvmTest/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepositoryTest.kt
index b15eade..228901e 100644
--- a/shared/src/jvmTest/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepositoryTest.kt
+++ b/shared/src/jvmTest/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepositoryTest.kt
@@ -121,6 +121,31 @@ class SqlDelightAudioCatalogRepositoryTest {
assertEquals(listOf("new.wav", "processed.wav"), rows.map { it.displayName })
}
+ @Test
+ fun activeSourceScopeMergesListsCountsAndClaimsWithoutTouchingOtherSources() {
+ val repository = repository()
+ val folder = imported(repository, "folder.wav", modified = 10, folderUri = FOLDER)
+ val imported = imported(repository, "voice.ogg", modified = 20, folderUri = IMPORTS)
+ imported(repository, "inactive.wav", modified = 30, folderUri = OTHER_FOLDER)
+ val failed = imported(
+ repository,
+ "failed.opus",
+ modified = 40,
+ folderUri = IMPORTS,
+ state = AudioFileState.FAILED,
+ lastError = "decode failed",
+ )
+ val scope = AudioCatalogSourceScope.of(listOf(FOLDER, IMPORTS))
+
+ assertEquals(listOf(imported.id, folder.id), repository.newEntries(scope).map { it.id })
+ assertEquals(listOf(failed.id), repository.processedEntries(scope).map { it.id })
+ assertEquals(2, repository.pendingCount(scope))
+ assertEquals(folder.id, repository.claimPending(scope)?.id)
+ assertEquals(imported.id, repository.claimPending(scope)?.id)
+ assertNull(repository.claimPending(scope))
+ assertEquals(failed.id, repository.claimFailed(scope, failed.id)?.id)
+ }
+
private fun repository(): SqlDelightAudioCatalogRepository {
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
VoiceInboxDatabase.Schema.create(driver)
@@ -135,9 +160,10 @@ class SqlDelightAudioCatalogRepositoryTest {
lastError: String? = null,
processedAtMillis: Long? = null,
transcriptText: String? = null,
+ folderUri: String = FOLDER,
): SqlDelightAudioCatalogFile =
repository.upsertImportedFile(
- folderUri = FOLDER,
+ folderUri = folderUri,
documentUri = name,
displayName = name,
mimeType = "audio/wav",
@@ -161,5 +187,7 @@ class SqlDelightAudioCatalogRepositoryTest {
private companion object {
const val FOLDER = "ios-imported-audio"
+ const val IMPORTS = "android-imported-audio"
+ const val OTHER_FOLDER = "content://other-folder"
}
}
diff --git a/src/engine.rs b/src/engine.rs
index 69cde49..4a8e044 100644
--- a/src/engine.rs
+++ b/src/engine.rs
@@ -33,16 +33,77 @@ pub fn is_engine_loaded() -> bool {
}
pub fn configure_model_directory(path: PathBuf) {
+ let path = std::fs::canonicalize(&path).unwrap_or(path);
+ let unchanged = MODEL_DIRECTORY.lock().unwrap().as_ref() == Some(&path);
+ if unchanged {
+ return;
+ }
+ invalidate_loaded_model();
*MODEL_DIRECTORY.lock().unwrap() = Some(path);
+}
+
+pub fn invalidate_loaded_model() {
*GLOBAL_ENGINE.lock().unwrap() = None;
*LOAD_STATE.0.lock().unwrap() = LoadState::Idle;
+ LOAD_STATE.1.notify_all();
}
pub fn ensure_loaded_without_callback() -> Result<(), String> {
+ ensure_loaded_with_status(|_| {})
+}
+
+fn ensure_loaded_with_status(mut status: impl FnMut(&str)) -> Result<(), String> {
+ if is_engine_loaded() {
+ status("Ready");
+ return Ok(());
+ }
+
+ let (lock, cvar) = &*LOAD_STATE;
+ let mut state = lock.lock().unwrap();
if is_engine_loaded() {
+ status("Ready");
return Ok(());
}
+ if *state == LoadState::Loading {
+ status("Waiting for model...");
+ while *state == LoadState::Loading {
+ state = cvar.wait(state).unwrap();
+ }
+ return match &*state {
+ LoadState::Done if is_engine_loaded() => {
+ status("Ready");
+ Ok(())
+ }
+ LoadState::Failed(message) => {
+ status(&format!("Error: {message}"));
+ Err(message.clone())
+ }
+ _ => Err("Model loading was interrupted".to_string()),
+ };
+ }
+
+ *state = LoadState::Loading;
+ drop(state);
+ status("Loading model...");
+
+ let result = load_configured_engine();
+ let mut state = lock.lock().unwrap();
+ match &result {
+ Ok(()) => {
+ *state = LoadState::Done;
+ status("Ready");
+ }
+ Err(message) => {
+ *state = LoadState::Failed(message.clone());
+ status(&format!("Error: {message}"));
+ }
+ }
+ cvar.notify_all();
+ result
+}
+
+fn load_configured_engine() -> Result<(), String> {
let path = MODEL_DIRECTORY
.lock()
.unwrap()
@@ -56,7 +117,6 @@ pub fn ensure_loaded_without_callback() -> Result<(), String> {
)
.map_err(|error| format!("Model error: {error}"))?;
*GLOBAL_ENGINE.lock().unwrap() = Some(Arc::new(Mutex::new(engine)));
- *LOAD_STATE.0.lock().unwrap() = LoadState::Done;
Ok(())
}
@@ -74,55 +134,7 @@ fn notify_status(env: &mut JNIEnv, obj: &JObject, msg: &str) {
#[cfg(target_os = "android")]
pub fn ensure_loaded(env: &mut JNIEnv, context: &JObject) -> Result<(), String> {
- if is_engine_loaded() {
- notify_status(env, context, "Ready");
- return Ok(());
- }
-
- let (lock, cvar) = &*LOAD_STATE;
- let mut state = lock.lock().unwrap();
-
- if is_engine_loaded() {
- notify_status(env, context, "Ready");
- return Ok(());
- }
-
- match &*state {
- LoadState::Loading => {
- notify_status(env, context, "Waiting for model...");
- while *state == LoadState::Loading {
- state = cvar.wait(state).unwrap();
- }
- drop(state);
-
- if is_engine_loaded() {
- notify_status(env, context, "Ready");
- Ok(())
- } else {
- let msg = "Model failed to load".to_string();
- notify_status(env, context, &format!("Error: {}", msg));
- Err(msg)
- }
- }
- LoadState::Done => {
- notify_status(env, context, "Ready");
- Ok(())
- }
- LoadState::Idle | LoadState::Failed(_) => {
- *state = LoadState::Loading;
- drop(state);
-
- let result = do_load(env, context);
-
- let mut state = lock.lock().unwrap();
- match &result {
- Ok(()) => *state = LoadState::Done,
- Err(msg) => *state = LoadState::Failed(msg.clone()),
- }
- cvar.notify_all();
- result
- }
- }
+ ensure_loaded_with_status(|message| notify_status(env, context, message))
}
#[cfg(target_os = "android")]
@@ -130,98 +142,44 @@ pub fn ensure_loaded_from_thread(
jvm: &Arc,
target_ref: &GlobalRef,
) -> Result<(), String> {
- if is_engine_loaded() {
+ ensure_loaded_with_status(|message| {
if let Ok(mut env) = jvm.attach_current_thread() {
- notify_status(&mut env, target_ref.as_obj(), "Ready");
+ notify_status(&mut env, target_ref.as_obj(), message);
}
- return Ok(());
- }
+ })
+}
- let (lock, cvar) = &*LOAD_STATE;
- let mut state = lock.lock().unwrap();
+#[cfg(test)]
+mod tests {
+ use super::*;
- if is_engine_loaded() {
- if let Ok(mut env) = jvm.attach_current_thread() {
- notify_status(&mut env, target_ref.as_obj(), "Ready");
- }
- return Ok(());
- }
+ static TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(()));
- match &*state {
- LoadState::Loading => {
- if let Ok(mut env) = jvm.attach_current_thread() {
- notify_status(&mut env, target_ref.as_obj(), "Waiting for model...");
- }
- while *state == LoadState::Loading {
- state = cvar.wait(state).unwrap();
- }
- drop(state);
+ #[test]
+ fn configuring_same_directory_preserves_load_state() {
+ let _guard = TEST_LOCK.lock().unwrap();
+ invalidate_loaded_model();
+ configure_model_directory(PathBuf::from("test-model"));
+ *LOAD_STATE.0.lock().unwrap() = LoadState::Done;
- if is_engine_loaded() {
- if let Ok(mut env) = jvm.attach_current_thread() {
- notify_status(&mut env, target_ref.as_obj(), "Ready");
- }
- Ok(())
- } else {
- let msg = "Model failed to load".to_string();
- if let Ok(mut env) = jvm.attach_current_thread() {
- notify_status(&mut env, target_ref.as_obj(), &format!("Error: {}", msg));
- }
- Err(msg)
- }
- }
- LoadState::Done => {
- if let Ok(mut env) = jvm.attach_current_thread() {
- notify_status(&mut env, target_ref.as_obj(), "Ready");
- }
- Ok(())
- }
- LoadState::Idle | LoadState::Failed(_) => {
- *state = LoadState::Loading;
- drop(state);
-
- let result = if let Ok(mut env) = jvm.attach_current_thread() {
- let obj = target_ref.as_obj();
- do_load(&mut env, obj)
- } else {
- Err("Failed to attach JNI thread".to_string())
- };
-
- let mut state = lock.lock().unwrap();
- match &result {
- Ok(()) => *state = LoadState::Done,
- Err(msg) => *state = LoadState::Failed(msg.clone()),
- }
- cvar.notify_all();
- result
- }
+ configure_model_directory(PathBuf::from("test-model"));
+
+ assert_eq!(*LOAD_STATE.0.lock().unwrap(), LoadState::Done);
+ invalidate_loaded_model();
}
-}
-#[cfg(target_os = "android")]
-fn do_load(env: &mut JNIEnv, context: &JObject) -> Result<(), String> {
- let path = MODEL_DIRECTORY
- .lock()
- .unwrap()
- .clone()
- .ok_or_else(|| "Model directory was not configured".to_string())?;
+ #[test]
+ fn changing_directory_and_explicit_invalidation_reset_state() {
+ let _guard = TEST_LOCK.lock().unwrap();
+ invalidate_loaded_model();
+ configure_model_directory(PathBuf::from("first-model"));
+ *LOAD_STATE.0.lock().unwrap() = LoadState::Done;
- notify_status(env, context, "Loading model...");
-
- let mut eng = ParakeetEngine::new();
- match eng.load_model_with_params(
- &path,
- transcribe_rs::engines::parakeet::ParakeetModelParams::int8(),
- ) {
- Ok(_) => {
- *GLOBAL_ENGINE.lock().unwrap() = Some(Arc::new(Mutex::new(eng)));
- notify_status(env, context, "Ready");
- Ok(())
- }
- Err(e) => {
- let msg = format!("Model error: {}", e);
- notify_status(env, context, &format!("Error: {}", msg));
- Err(msg)
- }
+ configure_model_directory(PathBuf::from("second-model"));
+ assert_eq!(*LOAD_STATE.0.lock().unwrap(), LoadState::Idle);
+
+ *LOAD_STATE.0.lock().unwrap() = LoadState::Failed("failed".to_string());
+ invalidate_loaded_model();
+ assert_eq!(*LOAD_STATE.0.lock().unwrap(), LoadState::Idle);
}
}
diff --git a/src/lib.rs b/src/lib.rs
index 82ffa56..9725736 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -20,7 +20,7 @@ use jni::sys::{jboolean, jstring, JNI_FALSE, JNI_TRUE};
use jni::JNIEnv;
#[cfg(target_os = "android")]
use std::path::PathBuf;
-#[cfg(any(not(target_os = "ios"), all(target_os = "ios", feature = "ios-onnx")))]
+#[cfg(any(target_os = "android", all(target_os = "ios", feature = "ios-onnx")))]
use transcribe_rs::TranscriptionEngine;
#[cfg(any(not(target_os = "ios"), all(target_os = "ios", feature = "ios-onnx")))]
@@ -74,6 +74,15 @@ pub unsafe extern "system" fn Java_me_maxistar_voiceinbox_NativeTranscriptionBri
}
}
+#[cfg(target_os = "android")]
+#[no_mangle]
+pub extern "system" fn Java_me_maxistar_voiceinbox_NativeTranscriptionBridge_reset(
+ _env: JNIEnv,
+ _class: JClass,
+) {
+ engine::invalidate_loaded_model();
+}
+
#[cfg(target_os = "android")]
#[no_mangle]
pub unsafe extern "system" fn Java_me_maxistar_voiceinbox_NativeTranscriptionBridge_transcribeChunkJson(
@@ -86,7 +95,10 @@ pub unsafe extern "system" fn Java_me_maxistar_voiceinbox_NativeTranscriptionBri
Err(_) => return std::ptr::null_mut(),
};
let mut buffer = vec![0.0f32; length];
- if env.get_float_array_region(&samples, 0, &mut buffer).is_err() {
+ if env
+ .get_float_array_region(&samples, 0, &mut buffer)
+ .is_err()
+ {
return std::ptr::null_mut();
}
@@ -226,6 +238,16 @@ pub unsafe extern "C" fn voiceinbox_transcription_initialize(
true
}
+#[cfg(all(target_os = "ios", feature = "ios-onnx"))]
+#[no_mangle]
+pub extern "C" fn voiceinbox_transcription_reset() {
+ engine::invalidate_loaded_model();
+}
+
+#[cfg(all(target_os = "ios", not(feature = "ios-onnx")))]
+#[no_mangle]
+pub extern "C" fn voiceinbox_transcription_reset() {}
+
#[cfg(all(target_os = "ios", not(feature = "ios-onnx")))]
#[no_mangle]
pub unsafe extern "C" fn voiceinbox_transcription_initialize(
diff --git a/website/src/pages/faq.astro b/website/src/pages/faq.astro
index f2603de..101ce5b 100644
--- a/website/src/pages/faq.astro
+++ b/website/src/pages/faq.astro
@@ -55,8 +55,8 @@ import Layout from '../layouts/Layout.astro';
Is iOS supported?
iOS has an active MVP: import audio files, preview playback, speech model download, local transcription,
- transcript display, and output append are available in the development app. Folder automation, scheduled background transcription,
- and full Android parity are still in progress.
+ transcript display, selected-folder refresh, startup processing, and output append are available in the development app.
+ Scheduled background transcription and full Android parity are still in progress.
@@ -77,6 +77,14 @@ import Layout from '../layouts/Layout.astro';
+
+ What happens when Voice Inbox finds queued files at startup?
+
+ Startup processing defaults to Ask. In Settings, you can instead make the app transcribe automatically or leave files queued.
+ This foreground startup behavior is separate from Android's optional nightly background transcription.
+
+
+
Can scheduled transcription run exactly at my selected time?
diff --git a/website/src/pages/setup.astro b/website/src/pages/setup.astro
index 0138c7f..c696d02 100644
--- a/website/src/pages/setup.astro
+++ b/website/src/pages/setup.astro
@@ -88,12 +88,20 @@ const releaseUrl = `${githubUrl}/releases`;
-
6. Optional: scheduled transcription
+
6. Optional: startup processing
- Open Settings to enable scheduled transcription and choose a preferred time. Android may delay background work,
+ In Settings, choose whether Voice Inbox should ask, transcribe automatically, or leave files queued when
+ the app opens and finishes scanning. Ask is the default. Startup processing is available on Android and iOS.
+
+
+
+
+
7. Optional: nightly transcription
+
+ On Android, open Settings to enable nightly transcription and choose a preferred time. Android may delay background work,
so scheduled runs should be treated as best-effort rather than exact alarms.
-
Scheduled background transcription is not part of the current iOS MVP.
+
Nightly background transcription is independent from startup processing and is not part of the current iOS MVP.