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"> - + - - - - - - - - - - -