From 85c60bedbec081628143565a2fcf346b8f1737de Mon Sep 17 00:00:00 2001
From: Max Starikov
Date: Mon, 13 Jul 2026 19:25:33 +0200
Subject: [PATCH 01/10] add add shared extension
---
iosApp/VoiceInbox.xcodeproj/project.pbxproj | 169 ++++++++++++++++-
iosApp/VoiceInbox/ContentView.swift | 12 ++
iosApp/VoiceInbox/IosAudioImportStore.swift | 64 +++++++
.../VoiceInbox/IosSharedImportStaging.swift | 120 ++++++++++++
iosApp/VoiceInbox/VoiceInbox.entitlements | 10 +
iosApp/VoiceInboxShareExtension/Info.plist | 43 +++++
.../ShareViewController.swift | 172 ++++++++++++++++++
.../VoiceInboxShareExtension.entitlements | 10 +
8 files changed, 598 insertions(+), 2 deletions(-)
create mode 100644 iosApp/VoiceInbox/IosSharedImportStaging.swift
create mode 100644 iosApp/VoiceInbox/VoiceInbox.entitlements
create mode 100644 iosApp/VoiceInboxShareExtension/Info.plist
create mode 100644 iosApp/VoiceInboxShareExtension/ShareViewController.swift
create mode 100644 iosApp/VoiceInboxShareExtension/VoiceInboxShareExtension.entitlements
diff --git a/iosApp/VoiceInbox.xcodeproj/project.pbxproj b/iosApp/VoiceInbox.xcodeproj/project.pbxproj
index e9ff5f6..1a3cb12 100644
--- a/iosApp/VoiceInbox.xcodeproj/project.pbxproj
+++ b/iosApp/VoiceInbox.xcodeproj/project.pbxproj
@@ -19,8 +19,36 @@
101010101010101010101036 /* IosSingleFileTranscriptionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101010101010101010101037 /* IosSingleFileTranscriptionController.swift */; };
101010101010101010101039 /* IosSpeechModelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101010101010101010101041 /* IosSpeechModelStore.swift */; };
101010101010101010101040 /* IosSpeechModelDirectoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101010101010101010101042 /* IosSpeechModelDirectoryPicker.swift */; };
+ 18B94FCC65FAAF5B349BF28B /* IosSharedImportStaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF805AFB19C13EC1D442253 /* IosSharedImportStaging.swift */; };
+ 361EE03677F4524C001986C0 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2A88E9CFF2023E878C6EC22 /* ShareViewController.swift */; };
+ 5E50C86D75D247316B5953AB /* VoiceInboxShareExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 242890FE4BBC56636FBA9F10 /* VoiceInboxShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ 9FAC76B26E6BD4116D968475 /* IosSharedImportStaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD030301C0E14C9932DCBFE6 /* IosSharedImportStaging.swift */; };
/* End PBXBuildFile section */
+/* Begin PBXContainerItemProxy section */
+ B02BB42352D5BAE85F16E7A7 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 101010101010101010101010 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = D93524F1DBD24C87F8DF0EB9;
+ remoteInfo = VoiceInboxShareExtension;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 5885D5F7784EAD2E2CF6BB3D /* Embed App Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ 5E50C86D75D247316B5953AB /* VoiceInboxShareExtension.appex in Embed App Extensions */,
+ );
+ name = "Embed App Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
/* Begin PBXFileReference section */
101010101010101010101003 /* VoiceInbox.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoiceInbox.app; sourceTree = BUILT_PRODUCTS_DIR; };
101010101010101010101011 /* VoiceInboxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceInboxApp.swift; sourceTree = ""; };
@@ -35,6 +63,13 @@
101010101010101010101037 /* IosSingleFileTranscriptionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosSingleFileTranscriptionController.swift; sourceTree = ""; };
101010101010101010101041 /* IosSpeechModelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosSpeechModelStore.swift; sourceTree = ""; };
101010101010101010101042 /* IosSpeechModelDirectoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosSpeechModelDirectoryPicker.swift; sourceTree = ""; };
+ 1DF805AFB19C13EC1D442253 /* IosSharedImportStaging.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IosSharedImportStaging.swift; sourceTree = ""; };
+ 242890FE4BBC56636FBA9F10 /* VoiceInboxShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VoiceInboxShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ A2A88E9CFF2023E878C6EC22 /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; };
+ A3B22C5C663FBCBBFAB0A81F /* VoiceInboxShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = VoiceInboxShareExtension.entitlements; sourceTree = ""; };
+ CD030301C0E14C9932DCBFE6 /* IosSharedImportStaging.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = IosSharedImportStaging.swift; path = ../VoiceInbox/IosSharedImportStaging.swift; sourceTree = ""; };
+ EF9FEAB9A5BF00B4E66B996C /* VoiceInbox.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = VoiceInbox.entitlements; sourceTree = ""; };
+ F63411837D138D0E90F5BA1D /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -45,6 +80,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 9628A0A1DCA14071F9ADBFB8 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -53,6 +95,7 @@
children = (
101010101010101010101013 /* VoiceInbox */,
101010101010101010101006 /* Products */,
+ F139417D5AA7BD6EFD2ECADD /* VoiceInboxShareExtension */,
);
sourceTree = "";
};
@@ -60,6 +103,7 @@
isa = PBXGroup;
children = (
101010101010101010101003 /* VoiceInbox.app */,
+ 242890FE4BBC56636FBA9F10 /* VoiceInboxShareExtension.appex */,
);
name = Products;
sourceTree = "";
@@ -79,10 +123,23 @@
101010101010101010101026 /* IosMainScreenShellState.swift */,
101010101010101010101024 /* SettingsView.swift */,
101010101010101010101034 /* Assets.xcassets */,
+ 1DF805AFB19C13EC1D442253 /* IosSharedImportStaging.swift */,
+ EF9FEAB9A5BF00B4E66B996C /* VoiceInbox.entitlements */,
);
path = VoiceInbox;
sourceTree = "";
};
+ F139417D5AA7BD6EFD2ECADD /* VoiceInboxShareExtension */ = {
+ isa = PBXGroup;
+ children = (
+ A2A88E9CFF2023E878C6EC22 /* ShareViewController.swift */,
+ CD030301C0E14C9932DCBFE6 /* IosSharedImportStaging.swift */,
+ F63411837D138D0E90F5BA1D /* Info.plist */,
+ A3B22C5C663FBCBBFAB0A81F /* VoiceInboxShareExtension.entitlements */,
+ );
+ path = VoiceInboxShareExtension;
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -95,16 +152,35 @@
101010101010101010101009 /* Sources */,
101010101010101010101035 /* Resources */,
101010101010101010101004 /* Frameworks */,
+ 5885D5F7784EAD2E2CF6BB3D /* Embed App Extensions */,
);
buildRules = (
);
dependencies = (
+ 7AAEC850980B5408864DC85C /* PBXTargetDependency */,
);
name = VoiceInbox;
productName = VoiceInbox;
productReference = 101010101010101010101003 /* VoiceInbox.app */;
productType = "com.apple.product-type.application";
};
+ D93524F1DBD24C87F8DF0EB9 /* VoiceInboxShareExtension */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = AB4D7BF990566F2ADFD3580A /* Build configuration list for PBXNativeTarget "VoiceInboxShareExtension" */;
+ buildPhases = (
+ 76207A5A815CD15CCD17A632 /* Sources */,
+ 9628A0A1DCA14071F9ADBFB8 /* Frameworks */,
+ 84DEA1E9E8C3D8ECF1E3BF03 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = VoiceInboxShareExtension;
+ productName = VoiceInboxShareExtension;
+ productReference = 242890FE4BBC56636FBA9F10 /* VoiceInboxShareExtension.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -134,6 +210,7 @@
projectRoot = "";
targets = (
101010101010101010101007 /* VoiceInbox */,
+ D93524F1DBD24C87F8DF0EB9 /* VoiceInboxShareExtension */,
);
};
/* End PBXProject section */
@@ -147,6 +224,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 84DEA1E9E8C3D8ECF1E3BF03 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -210,11 +294,30 @@
101010101010101010101021 /* IosScheduledTranscriptionSettingsStore.swift in Sources */,
101010101010101010101025 /* IosMainScreenShellState.swift in Sources */,
101010101010101010101022 /* SettingsView.swift in Sources */,
+ 18B94FCC65FAAF5B349BF28B /* IosSharedImportStaging.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 76207A5A815CD15CCD17A632 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 361EE03677F4524C001986C0 /* ShareViewController.swift in Sources */,
+ 9FAC76B26E6BD4116D968475 /* IosSharedImportStaging.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
+/* Begin PBXTargetDependency section */
+ 7AAEC850980B5408864DC85C /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ name = VoiceInboxShareExtension;
+ target = D93524F1DBD24C87F8DF0EB9 /* VoiceInboxShareExtension */;
+ targetProxy = B02BB42352D5BAE85F16E7A7 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
/* Begin XCBuildConfiguration section */
101010101010101010101017 /* Debug */ = {
isa = XCBuildConfiguration;
@@ -330,8 +433,9 @@
101010101010101010101019 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
- ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = VoiceInbox/VoiceInbox.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 2835P3MJR8;
@@ -380,8 +484,9 @@
101010101010101010101020 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
- ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = VoiceInbox/VoiceInbox.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 2835P3MJR8;
@@ -425,6 +530,57 @@
};
name = Release;
};
+ 647FF692FFBAEA14F829FBEF /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ CLANG_ENABLE_OBJC_WEAK = NO;
+ CODE_SIGN_ENTITLEMENTS = VoiceInboxShareExtension/VoiceInboxShareExtension.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 2835P3MJR8;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = VoiceInboxShareExtension/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ MARKETING_VERSION = 0.1;
+ PRODUCT_BUNDLE_IDENTIFIER = me.maxistar.voiceinbox.ios.share;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SKIP_INSTALL = YES;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 912904D2DC8D23268FD953E8 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ CLANG_ENABLE_OBJC_WEAK = NO;
+ CODE_SIGN_ENTITLEMENTS = VoiceInboxShareExtension/VoiceInboxShareExtension.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 2835P3MJR8;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = VoiceInboxShareExtension/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ MARKETING_VERSION = 0.1;
+ PRODUCT_BUNDLE_IDENTIFIER = me.maxistar.voiceinbox.ios.share;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SKIP_INSTALL = YES;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -446,6 +602,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ AB4D7BF990566F2ADFD3580A /* Build configuration list for PBXNativeTarget "VoiceInboxShareExtension" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 912904D2DC8D23268FD953E8 /* Release */,
+ 647FF692FFBAEA14F829FBEF /* Debug */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
};
rootObject = 101010101010101010101010 /* Project object */;
diff --git a/iosApp/VoiceInbox/ContentView.swift b/iosApp/VoiceInbox/ContentView.swift
index cdc48d7..0ab12f0 100644
--- a/iosApp/VoiceInbox/ContentView.swift
+++ b/iosApp/VoiceInbox/ContentView.swift
@@ -4,6 +4,7 @@ import SwiftUI
struct ContentView: View {
private let shellState = IosMainScreenShellState()
+ @Environment(\.scenePhase) private var scenePhase
@StateObject private var importStore = IosAudioImportStore()
@StateObject private var outputStore = IosOutputDocumentStore()
@StateObject private var previewPlayer = IosAudioPreviewPlayer()
@@ -390,6 +391,17 @@ struct ContentView: View {
.onChange(of: importStore.files) { files in
previewPlayer.stopIfUnavailable(availableFileIds: Set(files.map(\.id)))
}
+ .onAppear {
+ if importStore.ingestSharedImports()?.imported ?? 0 > 0 {
+ selectedTab = .new
+ }
+ }
+ .onChange(of: scenePhase) { phase in
+ guard phase == .active else { return }
+ if importStore.ingestSharedImports()?.imported ?? 0 > 0 {
+ selectedTab = .new
+ }
+ }
.sheet(item: transcriptBinding) { transcript in
NavigationStack {
ScrollView {
diff --git a/iosApp/VoiceInbox/IosAudioImportStore.swift b/iosApp/VoiceInbox/IosAudioImportStore.swift
index 52faa0f..8dfdff3 100644
--- a/iosApp/VoiceInbox/IosAudioImportStore.swift
+++ b/iosApp/VoiceInbox/IosAudioImportStore.swift
@@ -166,6 +166,26 @@ final class IosAudioImportStore: ObservableObject {
importMessage = summary.message
}
+ @discardableResult
+ func ingestSharedImports() -> IosAudioImportSummary? {
+ guard let directory = try? IosSharedImportStaging.stagingDirectory(fileManager: fileManager) else { return nil }
+ guard let urls = try? fileManager.contentsOfDirectory(
+ at: directory,
+ includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
+ options: [.skipsHiddenFiles]
+ ) else {
+ return nil
+ }
+
+ let stagedFiles = urls.filter { $0.pathExtension.lowercased() != IosSharedImportStaging.manifestExtension }
+ guard !stagedFiles.isEmpty else { return nil }
+
+ let summary = copySupportedSharedFiles(from: stagedFiles)
+ load()
+ importMessage = summary.message
+ return summary
+ }
+
func selectInboxFolder(_ url: URL) {
let didStartAccessing = url.startAccessingSecurityScopedResource()
defer {
@@ -441,6 +461,50 @@ final class IosAudioImportStore: ObservableObject {
return IosAudioImportSummary(imported: imported, skipped: skipped, failed: failed)
}
+ private func copySupportedSharedFiles(from urls: [URL]) -> IosAudioImportSummary {
+ ensureImportDirectoryExists()
+ var imported = 0
+ var skipped = 0
+ var failed = 0
+
+ for stagedURL in urls {
+ guard isSupportedAudio(stagedURL) else {
+ IosSharedImportStaging.removeStagedFileAndMetadata(stagedURL, fileManager: fileManager)
+ skipped += 1
+ continue
+ }
+
+ let metadata = IosSharedImportStaging.readMetadata(for: stagedURL)
+ let displayName = metadata?.displayName ?? stagedURL.lastPathComponent
+ let importedAtMillis = metadata?.modifiedMillis ?? metadata?.stagedAtMillis ?? currentTimeMillis()
+
+ do {
+ let targetFileName = nextAvailableFileName(for: displayName)
+ let targetURL = importDirectory.appendingPathComponent(targetFileName)
+ try fileManager.copyItem(at: stagedURL, to: targetURL)
+ _ = catalog.upsertImportedFile(
+ folderUri: IosAudioCatalogConstants.importedFolderUri,
+ documentUri: targetFileName,
+ displayName: displayName,
+ mimeType: nil,
+ sizeBytes: KotlinLong(longLong: fileSize(at: targetURL)),
+ importedAtMillis: KotlinLong(longLong: importedAtMillis),
+ state: AudioFileState.pending,
+ lastError: nil,
+ processedAtMillis: nil,
+ transcriptText: nil,
+ durationUs: nil
+ )
+ IosSharedImportStaging.removeStagedFileAndMetadata(stagedURL, fileManager: fileManager)
+ imported += 1
+ } catch {
+ failed += 1
+ }
+ }
+
+ return IosAudioImportSummary(imported: imported, skipped: skipped, failed: failed)
+ }
+
private func load() {
files = catalog.importedFiles(folderUri: IosAudioCatalogConstants.importedFolderUri).map(Self.importedFile)
sortFiles()
diff --git a/iosApp/VoiceInbox/IosSharedImportStaging.swift b/iosApp/VoiceInbox/IosSharedImportStaging.swift
new file mode 100644
index 0000000..b30fc04
--- /dev/null
+++ b/iosApp/VoiceInbox/IosSharedImportStaging.swift
@@ -0,0 +1,120 @@
+import Foundation
+import UniformTypeIdentifiers
+
+enum IosSharedImportStaging {
+ static let appGroupIdentifier = "group.me.maxistar.voiceinbox.ios"
+ static let directoryName = "SharedImports"
+ static let manifestExtension = "json"
+
+ static let supportedExtensions: Set = [
+ "aac", "aif", "aiff", "caf", "flac", "m4a", "mp3", "mp4", "wav"
+ ]
+
+ static let supportedTypeIdentifiers: [String] = [
+ UTType.audio.identifier,
+ UTType.movie.identifier,
+ UTType.mpeg4Audio.identifier,
+ "com.microsoft.waveform-audio",
+ "public.mp3",
+ "public.aiff-audio",
+ "org.xiph.flac",
+ ]
+
+ struct Metadata: Codable {
+ let stagedFileName: String
+ let displayName: String
+ let originalPathHint: String?
+ let sizeBytes: Int64
+ let modifiedMillis: Int64?
+ let stagedAtMillis: Int64
+ }
+
+ static func stagingDirectory(fileManager: FileManager = .default) throws -> URL {
+ guard let container = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) else {
+ throw StagingError.appGroupUnavailable
+ }
+ let directory = container.appendingPathComponent(directoryName, isDirectory: true)
+ try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
+ return directory
+ }
+
+ static func isSupportedAudio(_ url: URL) -> Bool {
+ supportedExtensions.contains(url.pathExtension.lowercased())
+ }
+
+ static func uniqueStagedFileName(for displayName: String, in directory: URL, fileManager: FileManager = .default) -> String {
+ let sanitized = sanitizedFileName(displayName)
+ let base = (sanitized as NSString).deletingPathExtension
+ let ext = (sanitized as NSString).pathExtension
+ var candidate = sanitized
+ var suffix = 2
+
+ while fileManager.fileExists(atPath: directory.appendingPathComponent(candidate).path) ||
+ fileManager.fileExists(atPath: directory.appendingPathComponent(manifestFileName(for: candidate)).path) {
+ candidate = ext.isEmpty ? "\(base)-\(suffix)" : "\(base)-\(suffix).\(ext)"
+ suffix += 1
+ }
+
+ return candidate
+ }
+
+ static func manifestFileName(for stagedFileName: String) -> String {
+ "\(stagedFileName).\(manifestExtension)"
+ }
+
+ static func writeMetadata(_ metadata: Metadata, in directory: URL, fileManager: FileManager = .default) throws {
+ let data = try JSONEncoder().encode(metadata)
+ let metadataURL = directory.appendingPathComponent(manifestFileName(for: metadata.stagedFileName))
+ try data.write(to: metadataURL, options: [.atomic])
+ }
+
+ static func readMetadata(for stagedFileURL: URL) -> Metadata? {
+ let metadataURL = stagedFileURL
+ .deletingLastPathComponent()
+ .appendingPathComponent(manifestFileName(for: stagedFileURL.lastPathComponent))
+ guard let data = try? Data(contentsOf: metadataURL) else { return nil }
+ return try? JSONDecoder().decode(Metadata.self, from: data)
+ }
+
+ static func removeStagedFileAndMetadata(_ stagedFileURL: URL, fileManager: FileManager = .default) {
+ try? fileManager.removeItem(at: stagedFileURL)
+ let metadataURL = stagedFileURL
+ .deletingLastPathComponent()
+ .appendingPathComponent(manifestFileName(for: stagedFileURL.lastPathComponent))
+ try? fileManager.removeItem(at: metadataURL)
+ }
+
+ static func fileSize(at url: URL) -> Int64 {
+ let values = try? url.resourceValues(forKeys: [.fileSizeKey])
+ return Int64(values?.fileSize ?? 0)
+ }
+
+ static func fileModifiedMillis(at url: URL) -> Int64? {
+ let values = try? url.resourceValues(forKeys: [.contentModificationDateKey])
+ return values?.contentModificationDate.map { Int64($0.timeIntervalSince1970 * 1000) }
+ }
+
+ static func currentTimeMillis() -> Int64 {
+ Int64(Date().timeIntervalSince1970 * 1000)
+ }
+
+ static func sanitizedFileName(_ name: String) -> String {
+ let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._- "))
+ let scalars = name.unicodeScalars.map { scalar in
+ allowed.contains(scalar) ? Character(scalar) : "-"
+ }
+ let sanitized = String(scalars).trimmingCharacters(in: .whitespacesAndNewlines)
+ return sanitized.isEmpty ? "audio-file" : sanitized
+ }
+
+ enum StagingError: LocalizedError {
+ case appGroupUnavailable
+
+ var errorDescription: String? {
+ switch self {
+ case .appGroupUnavailable:
+ "Shared import storage is not available. Check the App Group entitlement."
+ }
+ }
+ }
+}
diff --git a/iosApp/VoiceInbox/VoiceInbox.entitlements b/iosApp/VoiceInbox/VoiceInbox.entitlements
new file mode 100644
index 0000000..087647e
--- /dev/null
+++ b/iosApp/VoiceInbox/VoiceInbox.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.me.maxistar.voiceinbox.ios
+
+
+
diff --git a/iosApp/VoiceInboxShareExtension/Info.plist b/iosApp/VoiceInboxShareExtension/Info.plist
new file mode 100644
index 0000000..4beb2e5
--- /dev/null
+++ b/iosApp/VoiceInboxShareExtension/Info.plist
@@ -0,0 +1,43 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Voice Inbox
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ NSExtension
+
+ NSExtensionAttributes
+
+ NSExtensionActivationRule
+
+ NSExtensionActivationSupportsAudioWithMaxCount
+ 20
+ NSExtensionActivationSupportsFileWithMaxCount
+ 20
+ NSExtensionActivationSupportsMovieWithMaxCount
+ 20
+
+
+ NSExtensionPointIdentifier
+ com.apple.share-services
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).ShareViewController
+
+
+
diff --git a/iosApp/VoiceInboxShareExtension/ShareViewController.swift b/iosApp/VoiceInboxShareExtension/ShareViewController.swift
new file mode 100644
index 0000000..8e93de4
--- /dev/null
+++ b/iosApp/VoiceInboxShareExtension/ShareViewController.swift
@@ -0,0 +1,172 @@
+import UIKit
+import UniformTypeIdentifiers
+
+final class ShareViewController: UIViewController {
+ private let statusLabel = UILabel()
+ private let doneButton = UIButton(type: .system)
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ configureView()
+ importSharedItems()
+ }
+
+ private func configureView() {
+ view.backgroundColor = .systemBackground
+
+ statusLabel.translatesAutoresizingMaskIntoConstraints = false
+ statusLabel.numberOfLines = 0
+ statusLabel.textAlignment = .center
+ statusLabel.text = "Importing audio..."
+
+ doneButton.translatesAutoresizingMaskIntoConstraints = false
+ doneButton.setTitle("Done", for: .normal)
+ doneButton.addTarget(self, action: #selector(closeExtension), for: .touchUpInside)
+ doneButton.isHidden = true
+
+ view.addSubview(statusLabel)
+ view.addSubview(doneButton)
+
+ NSLayoutConstraint.activate([
+ statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
+ statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
+ statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -24),
+
+ doneButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 20),
+ doneButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+ ])
+ }
+
+ private func importSharedItems() {
+ Task {
+ let result = await stageSharedItems()
+ await MainActor.run {
+ statusLabel.text = result.message
+ doneButton.isHidden = false
+ }
+ }
+ }
+
+ private func stageSharedItems() async -> ShareImportResult {
+ guard let extensionItems = extensionContext?.inputItems as? [NSExtensionItem] else {
+ return ShareImportResult(imported: 0, skipped: 0, failed: 1)
+ }
+
+ var imported = 0
+ var skipped = 0
+ var failed = 0
+
+ for item in extensionItems {
+ for provider in item.attachments ?? [] {
+ do {
+ if try await stageItemProvider(provider) {
+ imported += 1
+ } else {
+ skipped += 1
+ }
+ } catch {
+ failed += 1
+ }
+ }
+ }
+
+ return ShareImportResult(imported: imported, skipped: skipped, failed: failed)
+ }
+
+ private func stageItemProvider(_ provider: NSItemProvider) async throws -> Bool {
+ if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier),
+ let url = try await loadURL(from: provider, typeIdentifier: UTType.fileURL.identifier) {
+ return try stageFile(at: url)
+ }
+
+ for typeIdentifier in IosSharedImportStaging.supportedTypeIdentifiers {
+ if provider.hasItemConformingToTypeIdentifier(typeIdentifier),
+ let url = try await loadFileRepresentation(from: provider, typeIdentifier: typeIdentifier) {
+ return try stageFile(at: url)
+ }
+ }
+
+ return false
+ }
+
+ private func loadURL(from provider: NSItemProvider, typeIdentifier: String) async throws -> URL? {
+ try await withCheckedThrowingContinuation { continuation in
+ provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, error in
+ if let error {
+ continuation.resume(throwing: error)
+ return
+ }
+ if let url = item as? URL {
+ continuation.resume(returning: url)
+ return
+ }
+ if let data = item as? Data,
+ let url = URL(dataRepresentation: data, relativeTo: nil) {
+ continuation.resume(returning: url)
+ return
+ }
+ continuation.resume(returning: nil)
+ }
+ }
+ }
+
+ private func loadFileRepresentation(from provider: NSItemProvider, typeIdentifier: String) async throws -> URL? {
+ try await withCheckedThrowingContinuation { continuation in
+ provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in
+ if let error {
+ continuation.resume(throwing: error)
+ return
+ }
+ continuation.resume(returning: url)
+ }
+ }
+ }
+
+ private func stageFile(at sourceURL: URL) throws -> Bool {
+ guard IosSharedImportStaging.isSupportedAudio(sourceURL) else { return false }
+
+ let fileManager = FileManager.default
+ let directory = try IosSharedImportStaging.stagingDirectory(fileManager: fileManager)
+ let displayName = sourceURL.lastPathComponent
+ let stagedFileName = IosSharedImportStaging.uniqueStagedFileName(
+ for: displayName,
+ in: directory,
+ fileManager: fileManager
+ )
+ let stagedURL = directory.appendingPathComponent(stagedFileName)
+
+ if fileManager.fileExists(atPath: stagedURL.path) {
+ try fileManager.removeItem(at: stagedURL)
+ }
+ try fileManager.copyItem(at: sourceURL, to: stagedURL)
+
+ let metadata = IosSharedImportStaging.Metadata(
+ stagedFileName: stagedFileName,
+ displayName: displayName,
+ originalPathHint: sourceURL.lastPathComponent,
+ sizeBytes: IosSharedImportStaging.fileSize(at: stagedURL),
+ modifiedMillis: IosSharedImportStaging.fileModifiedMillis(at: sourceURL),
+ stagedAtMillis: IosSharedImportStaging.currentTimeMillis()
+ )
+ try IosSharedImportStaging.writeMetadata(metadata, in: directory, fileManager: fileManager)
+ return true
+ }
+
+ @objc private func closeExtension() {
+ extensionContext?.completeRequest(returningItems: nil)
+ }
+}
+
+private struct ShareImportResult {
+ let imported: Int
+ let skipped: Int
+ let failed: Int
+
+ var message: String {
+ var parts: [String] = []
+ if imported > 0 { parts.append("\(imported) staged for Voice Inbox") }
+ if skipped > 0 { parts.append("\(skipped) skipped") }
+ if failed > 0 { parts.append("\(failed) failed") }
+ return parts.isEmpty ? "No supported audio files found." : parts.joined(separator: ", ")
+ }
+}
diff --git a/iosApp/VoiceInboxShareExtension/VoiceInboxShareExtension.entitlements b/iosApp/VoiceInboxShareExtension/VoiceInboxShareExtension.entitlements
new file mode 100644
index 0000000..087647e
--- /dev/null
+++ b/iosApp/VoiceInboxShareExtension/VoiceInboxShareExtension.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.me.maxistar.voiceinbox.ios
+
+
+
From 7fb2a775ea02ccd34e527760f495f5a209f7ef0d Mon Sep 17 00:00:00 2001
From: Max Starikov
Date: Mon, 13 Jul 2026 20:35:51 +0200
Subject: [PATCH 02/10] add confirm dialog
---
.../xcschemes/VoiceInbox.xcscheme | 79 +++++++++
.../VoiceInboxShareExtension.xcscheme | 98 +++++++++++
iosApp/VoiceInbox/ContentView.swift | 154 +++++++++++++++++-
iosApp/VoiceInbox/IosAudioImportStore.swift | 45 ++++-
iosApp/VoiceInbox/SettingsView.swift | 66 ++++++++
5 files changed, 430 insertions(+), 12 deletions(-)
create mode 100644 iosApp/VoiceInbox.xcodeproj/xcshareddata/xcschemes/VoiceInbox.xcscheme
create mode 100644 iosApp/VoiceInbox.xcodeproj/xcshareddata/xcschemes/VoiceInboxShareExtension.xcscheme
diff --git a/iosApp/VoiceInbox.xcodeproj/xcshareddata/xcschemes/VoiceInbox.xcscheme b/iosApp/VoiceInbox.xcodeproj/xcshareddata/xcschemes/VoiceInbox.xcscheme
new file mode 100644
index 0000000..a3ade6b
--- /dev/null
+++ b/iosApp/VoiceInbox.xcodeproj/xcshareddata/xcschemes/VoiceInbox.xcscheme
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/iosApp/VoiceInbox.xcodeproj/xcshareddata/xcschemes/VoiceInboxShareExtension.xcscheme b/iosApp/VoiceInbox.xcodeproj/xcshareddata/xcschemes/VoiceInboxShareExtension.xcscheme
new file mode 100644
index 0000000..3924b37
--- /dev/null
+++ b/iosApp/VoiceInbox.xcodeproj/xcshareddata/xcschemes/VoiceInboxShareExtension.xcscheme
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/iosApp/VoiceInbox/ContentView.swift b/iosApp/VoiceInbox/ContentView.swift
index 0ab12f0..35173a1 100644
--- a/iosApp/VoiceInbox/ContentView.swift
+++ b/iosApp/VoiceInbox/ContentView.swift
@@ -10,12 +10,16 @@ struct ContentView: View {
@StateObject private var previewPlayer = IosAudioPreviewPlayer()
@StateObject private var speechModelStore = IosSpeechModelStore()
@StateObject private var transcriber = IosSingleFileTranscriptionController()
+ @StateObject private var startupPolicyStore = IosStartupProcessingPolicyStore()
@State private var selectedTab = IosShellCatalogSelection.new
@State private var showingImporter = false
@State private var showingInboxFolderPicker = false
@State private var showingOutputPicker = false
@State private var showingModelImporter = false
@State private var shownTranscript: String?
+ @State private var startupProcessingChecked = false
+ @State private var startupFolderRefreshChecked = false
+ @State private var startupProcessingPrompt: IosStartupProcessingPrompt?
var body: some View {
let transcriptionBackendConfigured = transcriber.backendConfigured
@@ -349,6 +353,7 @@ struct ContentView: View {
SettingsView(
importStore: importStore,
outputStore: outputStore,
+ startupPolicyStore: startupPolicyStore,
selectInboxFolder: {
showingInboxFolderPicker = true
},
@@ -392,15 +397,13 @@ struct ContentView: View {
previewPlayer.stopIfUnavailable(availableFileIds: Set(files.map(\.id)))
}
.onAppear {
- if importStore.ingestSharedImports()?.imported ?? 0 > 0 {
- selectedTab = .new
- }
+ refreshStartupSources()
+ evaluateStartupProcessingIfNeeded()
}
.onChange(of: scenePhase) { phase in
guard phase == .active else { return }
- if importStore.ingestSharedImports()?.imported ?? 0 > 0 {
- selectedTab = .new
- }
+ refreshStartupSources()
+ evaluateStartupProcessingIfNeeded()
}
.sheet(item: transcriptBinding) { transcript in
NavigationStack {
@@ -419,6 +422,24 @@ struct ContentView: View {
}
}
}
+ .sheet(item: $startupProcessingPrompt) { prompt in
+ StartupProcessingPromptView(
+ pendingCount: prompt.pendingCount,
+ onYes: { alwaysAtStartup in
+ startupProcessingPrompt = nil
+ if alwaysAtStartup {
+ startupPolicyStore.policy = .yes
+ }
+ startStartupProcessing()
+ },
+ onNo: { alwaysAtStartup in
+ startupProcessingPrompt = nil
+ if alwaysAtStartup {
+ startupPolicyStore.policy = .no
+ }
+ }
+ )
+ }
}
}
@@ -438,6 +459,73 @@ struct ContentView: View {
return importStore.files.first { $0.id == row.id }
}
+ private func refreshStartupSources() {
+ var foundNewWork = false
+ if importStore.ingestSharedImports()?.imported ?? 0 > 0 {
+ foundNewWork = true
+ }
+
+ if !startupFolderRefreshChecked,
+ !importStore.inboxFolderStatus.needsSelection {
+ startupFolderRefreshChecked = true
+ let pendingBeforeRefresh = importStore.pendingCount
+ importStore.refreshInboxFolder()
+ foundNewWork = foundNewWork || importStore.pendingCount > pendingBeforeRefresh
+ }
+
+ if foundNewWork {
+ selectedTab = .new
+ startupProcessingChecked = false
+ }
+ }
+
+ private func evaluateStartupProcessingIfNeeded() {
+ guard !startupProcessingChecked else { return }
+ startupProcessingChecked = true
+ guard importStore.pendingCount > 0 else { return }
+
+ switch startupPolicyStore.policy {
+ case .ask:
+ startupProcessingPrompt = IosStartupProcessingPrompt(pendingCount: importStore.pendingCount)
+ case .yes:
+ startStartupProcessing()
+ case .no:
+ break
+ }
+ }
+
+ private func startStartupProcessing() {
+ guard importStore.pendingCount > 0 else { return }
+ guard !transcriber.isActive else {
+ importStore.importMessage = "Found files to process, but transcription is already running."
+ return
+ }
+ guard transcriber.backendConfigured else {
+ importStore.importMessage = "Found files to process, but the iOS transcription backend is not configured."
+ return
+ }
+ guard speechModelStore.isReady, !speechModelStore.isBusy else {
+ importStore.importMessage = "Found files to process, but the speech model is not ready."
+ return
+ }
+ guard let outputDocument = outputStore.currentDocument() else {
+ outputStore.refreshAccess()
+ importStore.importMessage = "Found files to process, but the output file is not ready."
+ return
+ }
+
+ previewPlayer.stop()
+ selectedTab = .new
+ transcriber.transcribeAll(
+ modelDirectory: speechModelStore.modelDirectory,
+ outputDocument: outputDocument,
+ store: importStore,
+ onFinished: {
+ selectedTab = .processed
+ }
+ )
+ }
+
private func iOSModelStatusMessage(
transcriptionBackendConfigured: Bool,
speechModelReady: Bool
@@ -469,3 +557,57 @@ private struct IosDisplayedTranscript: Identifiable {
let id = UUID()
let text: String
}
+
+private struct IosStartupProcessingPrompt: Identifiable {
+ let id = UUID()
+ let pendingCount: Int
+}
+
+private struct StartupProcessingPromptView: View {
+ let pendingCount: Int
+ let onYes: (Bool) -> Void
+ let onNo: (Bool) -> Void
+
+ @State private var alwaysAtStartup = false
+
+ var body: some View {
+ NavigationStack {
+ VStack(alignment: .leading, spacing: 20) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Found files to process. Process now?")
+ .font(.title3)
+ .fontWeight(.semibold)
+ Text(summary)
+ .foregroundStyle(.secondary)
+ }
+
+ Toggle("Always do this at startup", isOn: $alwaysAtStartup)
+
+ Spacer()
+ }
+ .padding()
+ .navigationTitle("Process Files")
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("No") {
+ onNo(alwaysAtStartup)
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Yes") {
+ onYes(alwaysAtStartup)
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ }
+ .presentationDetents([.medium])
+ }
+
+ private var summary: String {
+ if pendingCount == 1 {
+ return "1 file is waiting in New."
+ }
+ return "\(pendingCount) files are waiting in New."
+ }
+}
diff --git a/iosApp/VoiceInbox/IosAudioImportStore.swift b/iosApp/VoiceInbox/IosAudioImportStore.swift
index 8dfdff3..e7fa3e1 100644
--- a/iosApp/VoiceInbox/IosAudioImportStore.swift
+++ b/iosApp/VoiceInbox/IosAudioImportStore.swift
@@ -112,6 +112,14 @@ struct IosAudioImportSummary {
let imported: Int
let skipped: Int
let failed: Int
+ let importedFileIds: [Int64]
+
+ init(imported: Int, skipped: Int, failed: Int, importedFileIds: [Int64] = []) {
+ self.imported = imported
+ self.skipped = skipped
+ self.failed = failed
+ self.importedFileIds = importedFileIds
+ }
var message: String {
var parts: [String] = []
@@ -241,6 +249,10 @@ final class IosAudioImportStore: ObservableObject {
importDirectory.appendingPathComponent(documentUri)
}
+ var pendingCount: Int {
+ files.count { $0.status == .pending }
+ }
+
func refresh() {
load()
}
@@ -365,6 +377,7 @@ final class IosAudioImportStore: ObservableObject {
var imported = 0
var skipped = 0
var failed = 0
+ var importedFileIds: [Int64] = []
let existingByLocalName = Dictionary(uniqueKeysWithValues: files.map { ($0.localFileName, $0) })
for sourceURL in urls {
@@ -394,7 +407,7 @@ final class IosAudioImportStore: ObservableObject {
try fileManager.removeItem(at: targetURL)
}
try fileManager.copyItem(at: sourceURL, to: targetURL)
- _ = catalog.upsertImportedFile(
+ let importedFile = catalog.upsertImportedFile(
folderUri: IosAudioCatalogConstants.importedFolderUri,
documentUri: targetFileName,
displayName: sourceURL.lastPathComponent,
@@ -407,13 +420,19 @@ final class IosAudioImportStore: ObservableObject {
transcriptText: nil,
durationUs: nil
)
+ importedFileIds.append(importedFile.id)
imported += 1
} catch {
failed += 1
}
}
- return IosAudioImportSummary(imported: imported, skipped: skipped, failed: failed)
+ return IosAudioImportSummary(
+ imported: imported,
+ skipped: skipped,
+ failed: failed,
+ importedFileIds: importedFileIds
+ )
}
private func copySupportedFiles(from urls: [URL]) -> IosAudioImportSummary {
@@ -421,6 +440,7 @@ final class IosAudioImportStore: ObservableObject {
var imported = 0
var skipped = 0
var failed = 0
+ var importedFileIds: [Int64] = []
for sourceURL in urls {
guard isSupportedAudio(sourceURL) else {
@@ -439,7 +459,7 @@ final class IosAudioImportStore: ObservableObject {
let targetFileName = nextAvailableFileName(for: sourceURL.lastPathComponent)
let targetURL = importDirectory.appendingPathComponent(targetFileName)
try fileManager.copyItem(at: sourceURL, to: targetURL)
- _ = catalog.upsertImportedFile(
+ let importedFile = catalog.upsertImportedFile(
folderUri: IosAudioCatalogConstants.importedFolderUri,
documentUri: targetFileName,
displayName: sourceURL.lastPathComponent,
@@ -452,13 +472,19 @@ final class IosAudioImportStore: ObservableObject {
transcriptText: nil,
durationUs: nil
)
+ importedFileIds.append(importedFile.id)
imported += 1
} catch {
failed += 1
}
}
- return IosAudioImportSummary(imported: imported, skipped: skipped, failed: failed)
+ return IosAudioImportSummary(
+ imported: imported,
+ skipped: skipped,
+ failed: failed,
+ importedFileIds: importedFileIds
+ )
}
private func copySupportedSharedFiles(from urls: [URL]) -> IosAudioImportSummary {
@@ -466,6 +492,7 @@ final class IosAudioImportStore: ObservableObject {
var imported = 0
var skipped = 0
var failed = 0
+ var importedFileIds: [Int64] = []
for stagedURL in urls {
guard isSupportedAudio(stagedURL) else {
@@ -482,7 +509,7 @@ final class IosAudioImportStore: ObservableObject {
let targetFileName = nextAvailableFileName(for: displayName)
let targetURL = importDirectory.appendingPathComponent(targetFileName)
try fileManager.copyItem(at: stagedURL, to: targetURL)
- _ = catalog.upsertImportedFile(
+ let importedFile = catalog.upsertImportedFile(
folderUri: IosAudioCatalogConstants.importedFolderUri,
documentUri: targetFileName,
displayName: displayName,
@@ -496,13 +523,19 @@ final class IosAudioImportStore: ObservableObject {
durationUs: nil
)
IosSharedImportStaging.removeStagedFileAndMetadata(stagedURL, fileManager: fileManager)
+ importedFileIds.append(importedFile.id)
imported += 1
} catch {
failed += 1
}
}
- return IosAudioImportSummary(imported: imported, skipped: skipped, failed: failed)
+ return IosAudioImportSummary(
+ imported: imported,
+ skipped: skipped,
+ failed: failed,
+ importedFileIds: importedFileIds
+ )
}
private func load() {
diff --git a/iosApp/VoiceInbox/SettingsView.swift b/iosApp/VoiceInbox/SettingsView.swift
index 3cc6cfb..c348ee5 100644
--- a/iosApp/VoiceInbox/SettingsView.swift
+++ b/iosApp/VoiceInbox/SettingsView.swift
@@ -1,9 +1,63 @@
import Shared
import SwiftUI
+enum IosStartupProcessingPolicy: String, CaseIterable, Identifiable {
+ case ask
+ case yes
+ case no
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .ask:
+ "Ask"
+ case .yes:
+ "Yes"
+ case .no:
+ "No"
+ }
+ }
+
+ var detail: String {
+ switch self {
+ case .ask:
+ "Ask before processing queued files when Voice Inbox starts."
+ case .yes:
+ "Automatically process queued files when Voice Inbox starts."
+ case .no:
+ "Do not process queued files automatically at startup."
+ }
+ }
+}
+
+@MainActor
+final class IosStartupProcessingPolicyStore: ObservableObject {
+ @Published var policy: IosStartupProcessingPolicy {
+ didSet {
+ defaults.set(policy.rawValue, forKey: Self.policyKey)
+ }
+ }
+
+ private let defaults: UserDefaults
+
+ init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ if let rawValue = defaults.string(forKey: Self.policyKey),
+ let storedPolicy = IosStartupProcessingPolicy(rawValue: rawValue) {
+ policy = storedPolicy
+ } else {
+ policy = .ask
+ }
+ }
+
+ private static let policyKey = "iosStartupProcessingPolicy"
+}
+
struct SettingsView: View {
@ObservedObject var importStore: IosAudioImportStore
@ObservedObject var outputStore: IosOutputDocumentStore
+ @ObservedObject var startupPolicyStore: IosStartupProcessingPolicyStore
let selectInboxFolder: () -> Void
let selectOutputFile: () -> Void
@@ -56,6 +110,18 @@ struct SettingsView: View {
}
}
+ Section("Startup Processing") {
+ Picker("When queued files are found", selection: $startupPolicyStore.policy) {
+ ForEach(IosStartupProcessingPolicy.allCases) { policy in
+ Text(policy.title).tag(policy)
+ }
+ }
+
+ Text(startupPolicyStore.policy.detail)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
Section("About") {
LabeledContent("Version", value: appVersion)
Link("Website", destination: websiteURL)
From 2d5ef86e1bcf1d174e133104d62b27a0d1dcf596 Mon Sep 17 00:00:00 2001
From: Max Starikov
Date: Wed, 15 Jul 2026 20:59:28 +0200
Subject: [PATCH 03/10] autostart feature
---
.gitignore | 1 +
README.md | 54 ++++--
.../MainActivityInstrumentedTest.kt | 68 ++++++-
.../SettingsActivityInstrumentedTest.kt | 66 +++++++
.../me/maxistar/voiceinbox/MainActivity.kt | 116 +++++++++++-
.../maxistar/voiceinbox/SettingsActivity.kt | 21 +++
.../StartupProcessingCoordinator.kt | 143 ++++++++++++++
.../StartupProcessingDialogFragment.kt | 71 +++++++
.../StartupProcessingPolicyStore.kt | 56 ++++++
app/src/main/res/layout/activity_settings.xml | 48 +++++
app/src/main/res/values/strings.xml | 14 ++
.../StartupProcessingCoordinatorTest.kt | 178 ++++++++++++++++++
.../StartupProcessingPolicyStoreTest.kt | 64 +++++++
.../voiceinbox/core/StartupProcessingRules.kt | 67 +++++++
.../core/StartupProcessingRulesTest.kt | 109 +++++++++++
website/src/pages/faq.astro | 12 +-
website/src/pages/setup.astro | 14 +-
17 files changed, 1076 insertions(+), 26 deletions(-)
create mode 100644 app/src/androidTest/java/me/maxistar/voiceinbox/SettingsActivityInstrumentedTest.kt
create mode 100644 app/src/main/java/me/maxistar/voiceinbox/StartupProcessingCoordinator.kt
create mode 100644 app/src/main/java/me/maxistar/voiceinbox/StartupProcessingDialogFragment.kt
create mode 100644 app/src/main/java/me/maxistar/voiceinbox/StartupProcessingPolicyStore.kt
create mode 100644 app/src/test/java/me/maxistar/voiceinbox/StartupProcessingCoordinatorTest.kt
create mode 100644 app/src/test/java/me/maxistar/voiceinbox/StartupProcessingPolicyStoreTest.kt
create mode 100644 shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRules.kt
create mode 100644 shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRulesTest.kt
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/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
index 7cbdd92..84e611c 100644
--- a/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
+++ b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
@@ -19,13 +19,14 @@ 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 org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MainActivityInstrumentedTest {
@Test
- fun selectionSummariesAndMenuActionsAreVisible() {
+ fun selectionSummariesAndCurrentMenuActionsAreVisible() {
clearActivityState()
ActivityScenario.launch(MainActivity::class.java).use {
onView(withId(R.id.statusTitle)).check(matches(isDisplayed()))
@@ -39,8 +40,7 @@ class MainActivityInstrumentedTest {
openActionBarOverflowOrOptionsMenu(
InstrumentationRegistry.getInstrumentation().targetContext,
)
- onView(withText(R.string.menu_select_output)).check(matches(isDisplayed()))
- onView(withText(R.string.menu_select_folder)).check(matches(isDisplayed()))
+ onView(withText(R.string.menu_settings)).check(matches(isDisplayed()))
}
}
@@ -92,6 +92,44 @@ class MainActivityInstrumentedTest {
}
}
+ @Test
+ fun rememberedStartupPromptActionsPersistTheirPolicies() {
+ clearActivityState()
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ showStartupPrompt(scenario, pendingCount = 2)
+ onView(withText(R.string.startup_processing_prompt_title)).check(matches(isDisplayed()))
+ onView(withText(R.string.startup_processing_remember_choice)).perform(click())
+ onView(withText(R.string.startup_processing_process_now)).perform(click())
+ }
+
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val store = StartupProcessingPolicyStore(
+ context.getSharedPreferences(StartupProcessingPolicyStore.PREFERENCES_NAME, Context.MODE_PRIVATE),
+ )
+ assertEquals(StartupProcessingPolicy.AUTOMATIC, store.load())
+
+ clearActivityState()
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ showStartupPrompt(scenario, pendingCount = 1)
+ onView(withText(R.string.startup_processing_remember_choice)).perform(click())
+ onView(withText(R.string.startup_processing_leave_queued)).perform(click())
+ }
+
+ assertEquals(StartupProcessingPolicy.LEAVE_QUEUED, store.load())
+ }
+
+ @Test
+ fun startupPromptSurvivesActivityRecreationWithoutASecondDecision() {
+ clearActivityState()
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ showStartupPrompt(scenario, pendingCount = 2)
+ scenario.recreate()
+
+ onView(withText(R.string.startup_processing_prompt_title)).check(matches(isDisplayed()))
+ onView(withText(R.string.startup_processing_leave_queued)).perform(click())
+ }
+ }
+
@Test
fun failedRowsShowErrorAndRetryAction() {
clearActivityState()
@@ -169,9 +207,33 @@ class MainActivityInstrumentedTest {
.edit()
.clear()
.commit()
+ context.getSharedPreferences(StartupProcessingPolicyStore.PREFERENCES_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .clear()
+ .commit()
context.deleteDatabase(AndroidSqlDelightAudioCatalogFactory.DATABASE_NAME)
}
+ private fun showStartupPrompt(
+ scenario: ActivityScenario,
+ pendingCount: Int,
+ ) {
+ scenario.onActivity { activity ->
+ val coordinator = MainActivity::class.java.getDeclaredField("startupCoordinator")
+ .apply { isAccessible = true }
+ .get(activity) as StartupProcessingCoordinator
+ coordinator.beginStartupScan(99)
+ coordinator.setFolderReady(true)
+ coordinator.setOutputReady(true)
+ coordinator.setModelReady(true)
+ coordinator.setTranscriptionState(known = true, active = false)
+ coordinator.onStartupCatalogReady(99, pendingCount, failedCount = 0)
+ MainActivity::class.java.getDeclaredMethod("evaluateStartupProcessing")
+ .apply { isAccessible = true }
+ .invoke(activity)
+ }
+ }
+
private fun rowState(entry: AudioCatalogEntry): MainScreenRowState =
MainScreenStateController.rowState(
row = MainScreenRowInput(
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/main/java/me/maxistar/voiceinbox/MainActivity.kt b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
index c78beab..aa53522 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
@@ -27,7 +27,7 @@ import androidx.work.WorkManager
import java.util.concurrent.Executors
import java.util.UUID
-class MainActivity : AppCompatActivity() {
+class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listener {
private lateinit var statusTitle: TextView
private lateinit var statusDetail: TextView
private lateinit var statusProgress: ProgressBar
@@ -48,6 +48,8 @@ class MainActivity : AppCompatActivity() {
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 var modelReady = false
@@ -82,6 +84,7 @@ class MainActivity : AppCompatActivity() {
private var previewEntryId: Long? = null
private var previewState = PreviewPlaybackState.IDLE
private var activityDestroyed = false
+ private var scanGeneration = 0L
private val outputPicker = registerForActivityResult(
ActivityResultContracts.OpenDocument(),
@@ -114,6 +117,12 @@ class MainActivity : AppCompatActivity() {
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),
+ )
ScheduledTranscriptionScheduler.sync(
this,
ScheduledTranscriptionSettingsStore(
@@ -140,6 +149,11 @@ class MainActivity : AppCompatActivity() {
refreshModel()
}
+ 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)
updateMenu(menu)
@@ -200,11 +214,18 @@ class MainActivity : AppCompatActivity() {
runOnUiThread {
if (activityDestroyed) return@runOnUiThread
outputUri = stored
+ startupCoordinator.setOutputReady(true)
updateOutputSummary(metadata.displayName)
updateControls()
+ evaluateStartupProcessing()
}
}.onFailure {
selectionStore.clearOutputUri()
+ runOnUiThread {
+ if (activityDestroyed) return@runOnUiThread
+ startupCoordinator.setOutputReady(false)
+ evaluateStartupProcessing()
+ }
}
}
}
@@ -224,16 +245,19 @@ class MainActivity : AppCompatActivity() {
if (activityDestroyed) return@runOnUiThread
folderChecking = false
folderUri = stored
+ startupCoordinator.setFolderReady(true)
updateFolderSummary(name)
updateControls()
refreshCatalog()
- scanFolder()
+ scanFolder(FolderScanOrigin.STARTUP)
}
}.onFailure {
selectionStore.clearFolderUri()
runOnUiThread {
if (activityDestroyed) return@runOnUiThread
folderChecking = false
+ startupCoordinator.setFolderReady(false)
+ evaluateStartupProcessing()
}
showError(it.message ?: "Audio folder is not readable")
}
@@ -257,12 +281,21 @@ class MainActivity : AppCompatActivity() {
runOnUiThread {
if (activityDestroyed) return@runOnUiThread
outputUri = uri
+ startupCoordinator.setOutputReady(true)
updateOutputSummary(metadata.displayName)
statusError = null
renderStatusBlock()
updateControls()
+ evaluateStartupProcessing()
+ }
+ }.onFailure {
+ runOnUiThread {
+ if (activityDestroyed) return@runOnUiThread
+ startupCoordinator.setOutputReady(false)
+ evaluateStartupProcessing()
}
- }.onFailure { showError(it.message ?: "Output file is not writable") }
+ showError(it.message ?: "Output file is not writable")
+ }
}
}
@@ -286,6 +319,7 @@ class MainActivity : AppCompatActivity() {
if (activityDestroyed) return@runOnUiThread
folderChecking = false
folderUri = uri
+ startupCoordinator.setFolderReady(true)
updateFolderSummary(name)
statusError = null
renderStatusBlock()
@@ -297,15 +331,21 @@ class MainActivity : AppCompatActivity() {
runOnUiThread {
if (activityDestroyed) return@runOnUiThread
folderChecking = false
+ startupCoordinator.setFolderReady(false)
+ evaluateStartupProcessing()
}
showError(it.message ?: "Audio folder is not readable")
}
}
}
- private fun scanFolder() {
+ private fun scanFolder(origin: FolderScanOrigin = FolderScanOrigin.USER) {
val folder = folderUri ?: return
if (folderChecking || folderScanQueued || scanning || transcriptionActive()) return
+ val generation = ++scanGeneration
+ val startupGeneration = generation.takeIf {
+ origin == FolderScanOrigin.STARTUP && startupCoordinator.beginStartupScan(generation)
+ }
folderScanQueued = true
transcriptionFinished = false
transcriptionPhase = null
@@ -338,20 +378,22 @@ class MainActivity : AppCompatActivity() {
folderScanQueued = false
scanning = false
scanMessage = "Scan complete: $count audio files"
- refreshCatalog()
+ refreshCatalog(startupGeneration)
}
}.onFailure { error ->
runOnUiThread {
if (activityDestroyed) return@runOnUiThread
folderScanQueued = false
scanning = false
+ startupGeneration?.let(startupCoordinator::onStartupScanFailed)
+ evaluateStartupProcessing()
showError(error.message ?: "Folder scan failed")
}
}
}
}
- private fun refreshCatalog() {
+ private fun refreshCatalog(startupGeneration: Long? = null) {
if (activityDestroyed) return
val folder = folderUri?.toString()
if (folder == null) {
@@ -371,9 +413,20 @@ class MainActivity : AppCompatActivity() {
runOnUiThread {
if (activityDestroyed) return@runOnUiThread
pendingCount = newEntries.count { it.state == AudioFileState.PENDING }
+ val catalogFailedCount = newEntries.count { it.state == AudioFileState.FAILED }
renderEntries(entries)
renderStatusBlock()
updateControls()
+ if (startupGeneration != null) {
+ startupCoordinator.onStartupCatalogReady(
+ startupGeneration,
+ pendingCount,
+ catalogFailedCount,
+ )
+ } else {
+ startupCoordinator.onCatalogRefreshed(pendingCount, catalogFailedCount)
+ }
+ evaluateStartupProcessing()
}
}
}
@@ -566,6 +619,8 @@ class MainActivity : AppCompatActivity() {
setModelUi(state.message, loading = false, canDownload = true)
}
}
+ startupCoordinator.setModelReady(modelReady)
+ evaluateStartupProcessing()
}
private fun setModelUi(message: String, loading: Boolean, canDownload: Boolean) {
@@ -587,6 +642,7 @@ class MainActivity : AppCompatActivity() {
WorkInfo.State.RUNNING,
-> {
modelReady = false
+ startupCoordinator.setModelReady(false)
val bytes = info.progress.getLong(SpeechModelDownloadWorker.KEY_BYTES_DOWNLOADED, 0)
val total = info.progress.getLong(
SpeechModelDownloadWorker.KEY_TOTAL_BYTES,
@@ -600,6 +656,7 @@ class MainActivity : AppCompatActivity() {
modelDownloadProgress = ((bytes * 100) / total.coerceAtLeast(1)).toInt()
renderStatusBlock()
updateControls()
+ evaluateStartupProcessing()
}
WorkInfo.State.SUCCEEDED -> {
if (shouldHandleModelInstallSuccess(info.id.toString())) {
@@ -609,6 +666,7 @@ class MainActivity : AppCompatActivity() {
}
WorkInfo.State.FAILED -> {
modelReady = false
+ startupCoordinator.setModelReady(false)
setModelUi(
info.outputData.getString(SpeechModelDownloadWorker.KEY_ERROR)
?: "Speech model download failed",
@@ -616,6 +674,7 @@ class MainActivity : AppCompatActivity() {
true,
)
updateControls()
+ evaluateStartupProcessing()
}
else -> Unit
}
@@ -627,6 +686,11 @@ class MainActivity : AppCompatActivity() {
.getWorkInfosForUniqueWorkLiveData(TranscriptionWorker.UNIQUE_WORK_NAME)
.observe(this) { infos ->
transcriptionState = classifyTranscriptionState(infos)
+ startupCoordinator.setTranscriptionState(
+ known = true,
+ active = transcriptionActive(),
+ )
+ evaluateStartupProcessing()
if (infos.isEmpty()) {
transcriptionFinished = false
renderStatusBlock()
@@ -711,6 +775,40 @@ class MainActivity : AppCompatActivity() {
currentSessionTranscriptionWorkId = TranscriptionWorker.enqueueAll(this, folder, 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
private fun classifyTranscriptionState(infos: List): TranscriptionObservationState {
@@ -942,10 +1040,16 @@ class MainActivity : AppCompatActivity() {
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"
val ACTIVE_WORK_STATES = setOf(
WorkInfo.State.ENQUEUED,
WorkInfo.State.BLOCKED,
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/StartupProcessingCoordinator.kt b/app/src/main/java/me/maxistar/voiceinbox/StartupProcessingCoordinator.kt
new file mode 100644
index 0000000..a2adfa6
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/StartupProcessingCoordinator.kt
@@ -0,0 +1,143 @@
+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
+
+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,
+ modelReady = modelReady,
+ 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/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml
index f9391d7..aae5355 100644
--- a/app/src/main/res/layout/activity_settings.xml
+++ b/app/src/main/res/layout/activity_settings.xml
@@ -80,6 +80,54 @@
android:text="@string/output_not_selected" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Scheduled time: %1$02d:%2$02d
Change scheduled time
Android may delay background work to save battery. The feature is off by default.
+ Startup processing
+ Choose what happens when Voice Inbox opens and finds queued notes.
+ Ask every time
+ Transcribe automatically
+ Leave files queued
+ Startup processing runs only when you open the app. Nightly transcription is a separate background option.
+ Process queued notes?
+
+ - %1$d file is ready to transcribe.
+ - %1$d files are ready to transcribe.
+
+ Always do this when Voice Inbox starts
+ Process now
+ Leave queued
Output: not selected
Output: %1$s
Select output file
diff --git a/app/src/test/java/me/maxistar/voiceinbox/StartupProcessingCoordinatorTest.kt b/app/src/test/java/me/maxistar/voiceinbox/StartupProcessingCoordinatorTest.kt
new file mode 100644
index 0000000..e8c42fd
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/StartupProcessingCoordinatorTest.kt
@@ -0,0 +1,178 @@
+package me.maxistar.voiceinbox
+
+import me.maxistar.voiceinbox.core.StartupProcessingDecision
+import me.maxistar.voiceinbox.core.StartupProcessingPolicy
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class StartupProcessingCoordinatorTest {
+ @Test
+ fun callbackOrderingWaitsUntilEveryInputIsReady() {
+ val coordinator = StartupProcessingCoordinator()
+ coordinator.setFolderReady(true)
+ coordinator.setOutputReady(true)
+ coordinator.setModelReady(true)
+ coordinator.setTranscriptionState(known = true, active = false)
+
+ assertEquals(StartupProcessingDecision.Wait, coordinator.evaluate(StartupProcessingPolicy.ASK))
+ assertTrue(coordinator.beginStartupScan(1))
+ assertEquals(StartupProcessingDecision.Wait, coordinator.evaluate(StartupProcessingPolicy.ASK))
+ coordinator.onStartupCatalogReady(1, pendingCount = 2, failedCount = 0)
+
+ assertEquals(
+ StartupProcessingDecision.Prompt(2),
+ coordinator.evaluate(StartupProcessingPolicy.ASK),
+ )
+ }
+
+ @Test
+ fun staleStartupGenerationIsIgnored() {
+ val coordinator = readyCoordinator(generation = 2, completeCatalog = false)
+ coordinator.onStartupCatalogReady(1, pendingCount = 3, failedCount = 0)
+
+ assertEquals(
+ StartupProcessingDecision.Wait,
+ coordinator.evaluate(StartupProcessingPolicy.AUTOMATIC),
+ )
+ }
+
+ @Test
+ fun delayedPrerequisiteReevaluatesToAutomaticStart() {
+ val coordinator = readyCoordinator()
+ coordinator.setOutputReady(false)
+ assertEquals(
+ StartupProcessingDecision.Wait,
+ coordinator.evaluate(StartupProcessingPolicy.AUTOMATIC),
+ )
+
+ coordinator.setOutputReady(true)
+ assertEquals(
+ StartupProcessingDecision.Start(1),
+ coordinator.evaluate(StartupProcessingPolicy.AUTOMATIC),
+ )
+ assertTrue(coordinator.isComplete())
+ }
+
+ @Test
+ fun failedStartupScanCompletesEvaluation() {
+ val coordinator = StartupProcessingCoordinator()
+ assertTrue(coordinator.beginStartupScan(4))
+ coordinator.onStartupScanFailed(4)
+
+ coordinator.evaluate(StartupProcessingPolicy.ASK)
+
+ assertTrue(coordinator.isComplete())
+ }
+
+ @Test
+ fun restoredPromptDoesNotEmitAnotherPrompt() {
+ val original = readyCoordinator()
+ assertEquals(
+ StartupProcessingDecision.Prompt(1),
+ original.evaluate(StartupProcessingPolicy.ASK),
+ )
+
+ val restored = StartupProcessingCoordinator.restore(original.savedStage())
+
+ assertEquals(StartupProcessingDecision.Wait, restored.evaluate(StartupProcessingPolicy.ASK))
+ }
+
+ @Test
+ fun confirmedPromptWaitsForRecreatedReadinessThenStarts() {
+ val original = readyCoordinator()
+ original.evaluate(StartupProcessingPolicy.ASK)
+ val restored = StartupProcessingCoordinator.restore(original.savedStage())
+ assertTrue(restored.beginStartupScan(7))
+ assertTrue(restored.confirmPrompt())
+ assertEquals(StartupProcessingDecision.Wait, restored.evaluate(StartupProcessingPolicy.ASK))
+
+ prepareReady(restored, generation = 7)
+
+ assertEquals(
+ StartupProcessingDecision.Start(1),
+ restored.evaluate(StartupProcessingPolicy.ASK),
+ )
+ }
+
+ @Test
+ fun declinedPromptIsTerminalAndManualRefreshCannotReopenIt() {
+ val coordinator = readyCoordinator()
+ coordinator.evaluate(StartupProcessingPolicy.ASK)
+ assertTrue(coordinator.declinePrompt())
+
+ coordinator.onCatalogRefreshed(pendingCount = 5, failedCount = 0)
+
+ assertEquals(StartupProcessingDecision.Wait, coordinator.evaluate(StartupProcessingPolicy.ASK))
+ assertFalse(coordinator.beginStartupScan(8))
+ }
+
+ @Test
+ fun aFreshCoordinatorEvaluatesANewLaunch() {
+ val previous = readyCoordinator()
+ previous.evaluate(StartupProcessingPolicy.LEAVE_QUEUED)
+ assertTrue(previous.isComplete())
+
+ val nextLaunch = readyCoordinator()
+
+ assertEquals(
+ StartupProcessingDecision.Start(1),
+ nextLaunch.evaluate(StartupProcessingPolicy.AUTOMATIC),
+ )
+ }
+
+ @Test
+ fun activeWorkSuppressesHandoffUntilCatalogRefreshes() {
+ val coordinator = readyCoordinator()
+ coordinator.setTranscriptionState(known = true, active = true)
+ assertEquals(
+ StartupProcessingDecision.Wait,
+ coordinator.evaluate(StartupProcessingPolicy.AUTOMATIC),
+ )
+
+ coordinator.setTranscriptionState(known = true, active = false)
+ assertEquals(
+ StartupProcessingDecision.Wait,
+ coordinator.evaluate(StartupProcessingPolicy.AUTOMATIC),
+ )
+ coordinator.onCatalogRefreshed(pendingCount = 0, failedCount = 0)
+
+ coordinator.evaluate(StartupProcessingPolicy.AUTOMATIC)
+ assertTrue(coordinator.isComplete())
+ }
+
+ @Test
+ fun failedOnlyCatalogDoesNotStart() {
+ val coordinator = readyCoordinator(pendingCount = 0, failedCount = 3)
+
+ coordinator.evaluate(StartupProcessingPolicy.AUTOMATIC)
+
+ assertTrue(coordinator.isComplete())
+ }
+
+ private fun readyCoordinator(
+ generation: Long = 1,
+ pendingCount: Int = 1,
+ failedCount: Int = 0,
+ completeCatalog: Boolean = true,
+ ): StartupProcessingCoordinator =
+ StartupProcessingCoordinator().also { coordinator ->
+ coordinator.beginStartupScan(generation)
+ coordinator.setFolderReady(true)
+ coordinator.setOutputReady(true)
+ coordinator.setModelReady(true)
+ coordinator.setTranscriptionState(known = true, active = false)
+ if (completeCatalog) {
+ coordinator.onStartupCatalogReady(generation, pendingCount, failedCount)
+ }
+ }
+
+ private fun prepareReady(coordinator: StartupProcessingCoordinator, generation: Long) {
+ coordinator.setFolderReady(true)
+ coordinator.setOutputReady(true)
+ coordinator.setModelReady(true)
+ coordinator.setTranscriptionState(known = true, active = false)
+ coordinator.onStartupCatalogReady(generation, pendingCount = 1, failedCount = 0)
+ }
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/StartupProcessingPolicyStoreTest.kt b/app/src/test/java/me/maxistar/voiceinbox/StartupProcessingPolicyStoreTest.kt
new file mode 100644
index 0000000..be1c0f0
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/StartupProcessingPolicyStoreTest.kt
@@ -0,0 +1,64 @@
+package me.maxistar.voiceinbox
+
+import me.maxistar.voiceinbox.core.ScheduledTranscriptionSettings
+import me.maxistar.voiceinbox.core.ScheduledTranscriptionSettingsStorage
+import me.maxistar.voiceinbox.core.StartupProcessingPolicy
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class StartupProcessingPolicyStoreTest {
+ @Test
+ fun missingAndUnknownValuesDefaultToAsk() {
+ val storage = FakeStartupStorage()
+ val store = StartupProcessingPolicyStore(storage)
+
+ assertEquals(StartupProcessingPolicy.ASK, store.load())
+ storage.raw = "future-value"
+ assertEquals(StartupProcessingPolicy.ASK, store.load())
+ }
+
+ @Test
+ fun policiesRoundTripThroughStableValues() {
+ val storage = FakeStartupStorage()
+ val store = StartupProcessingPolicyStore(storage)
+
+ StartupProcessingPolicy.entries.forEach { policy ->
+ store.save(policy)
+ assertEquals(policy, store.load())
+ }
+ }
+
+ @Test
+ fun startupPolicyDoesNotChangeScheduledSettings() {
+ val scheduledStorage = FakeScheduledStorage(
+ ScheduledTranscriptionSettings(enabled = true, hour = 4, minute = 30),
+ )
+ val scheduledStore = ScheduledTranscriptionSettingsStore(scheduledStorage)
+ val startupStore = StartupProcessingPolicyStore(FakeStartupStorage())
+
+ startupStore.save(StartupProcessingPolicy.AUTOMATIC)
+
+ assertTrue(scheduledStore.load().enabled)
+ assertEquals(4, scheduledStore.load().hour)
+ assertEquals(30, scheduledStore.load().minute)
+ }
+
+ private class FakeStartupStorage(var raw: String? = null) : StartupProcessingPolicyStorage {
+ override fun loadRaw(): String? = raw
+
+ override fun saveRaw(value: String) {
+ raw = value
+ }
+ }
+
+ private class FakeScheduledStorage(
+ private var settings: ScheduledTranscriptionSettings,
+ ) : ScheduledTranscriptionSettingsStorage {
+ override fun load(): ScheduledTranscriptionSettings = settings
+
+ override fun save(settings: ScheduledTranscriptionSettings) {
+ this.settings = settings
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRules.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRules.kt
new file mode 100644
index 0000000..1b587fc
--- /dev/null
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRules.kt
@@ -0,0 +1,67 @@
+package me.maxistar.voiceinbox.core
+
+enum class StartupProcessingPolicy {
+ ASK,
+ AUTOMATIC,
+ LEAVE_QUEUED,
+}
+
+data class StartupProcessingInput(
+ val policy: StartupProcessingPolicy = StartupProcessingPolicy.ASK,
+ val evaluationComplete: Boolean = false,
+ val startupScanComplete: Boolean = false,
+ val startupScanFailed: Boolean = false,
+ val catalogReady: Boolean = false,
+ val pendingCount: Int = 0,
+ val failedCount: Int = 0,
+ val folderReady: Boolean = false,
+ val outputReady: Boolean = false,
+ val modelReady: Boolean = false,
+ val transcriptionStateKnown: Boolean = false,
+ val transcriptionActive: Boolean = false,
+)
+
+sealed interface StartupProcessingDecision {
+ data object Wait : StartupProcessingDecision
+
+ data class Prompt(val pendingCount: Int) : StartupProcessingDecision
+
+ data class Start(val pendingCount: Int) : StartupProcessingDecision
+
+ data class Finish(val reason: StartupProcessingFinishReason) : StartupProcessingDecision
+}
+
+enum class StartupProcessingFinishReason {
+ ALREADY_EVALUATED,
+ SCAN_FAILED,
+ NO_PENDING,
+ LEAVE_QUEUED,
+}
+
+object StartupProcessingRules {
+ fun decide(input: StartupProcessingInput): StartupProcessingDecision {
+ if (input.evaluationComplete) {
+ return StartupProcessingDecision.Finish(StartupProcessingFinishReason.ALREADY_EVALUATED)
+ }
+ if (input.startupScanFailed) {
+ return StartupProcessingDecision.Finish(StartupProcessingFinishReason.SCAN_FAILED)
+ }
+ if (!input.startupScanComplete || !input.catalogReady || !input.transcriptionStateKnown) {
+ return StartupProcessingDecision.Wait
+ }
+ if (input.pendingCount <= 0) {
+ return StartupProcessingDecision.Finish(StartupProcessingFinishReason.NO_PENDING)
+ }
+ if (input.policy == StartupProcessingPolicy.LEAVE_QUEUED) {
+ return StartupProcessingDecision.Finish(StartupProcessingFinishReason.LEAVE_QUEUED)
+ }
+ if (!input.folderReady || !input.outputReady || !input.modelReady || input.transcriptionActive) {
+ return StartupProcessingDecision.Wait
+ }
+ return when (input.policy) {
+ StartupProcessingPolicy.ASK -> StartupProcessingDecision.Prompt(input.pendingCount)
+ StartupProcessingPolicy.AUTOMATIC -> StartupProcessingDecision.Start(input.pendingCount)
+ StartupProcessingPolicy.LEAVE_QUEUED -> error("Leave-queued policy is handled above")
+ }
+ }
+}
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRulesTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRulesTest.kt
new file mode 100644
index 0000000..e25534e
--- /dev/null
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRulesTest.kt
@@ -0,0 +1,109 @@
+package me.maxistar.voiceinbox.core
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class StartupProcessingRulesTest {
+ @Test
+ fun askPromptsWhenStartupStateIsReady() {
+ assertEquals(
+ StartupProcessingDecision.Prompt(3),
+ StartupProcessingRules.decide(readyInput(policy = StartupProcessingPolicy.ASK, pendingCount = 3)),
+ )
+ }
+
+ @Test
+ fun automaticStartsWhenStartupStateIsReady() {
+ assertEquals(
+ StartupProcessingDecision.Start(2),
+ StartupProcessingRules.decide(readyInput(policy = StartupProcessingPolicy.AUTOMATIC, pendingCount = 2)),
+ )
+ }
+
+ @Test
+ fun leaveQueuedFinishesWithoutProcessing() {
+ assertEquals(
+ StartupProcessingDecision.Finish(StartupProcessingFinishReason.LEAVE_QUEUED),
+ StartupProcessingRules.decide(readyInput(policy = StartupProcessingPolicy.LEAVE_QUEUED)),
+ )
+ }
+
+ @Test
+ fun incompleteReadinessWaits() {
+ val ready = readyInput()
+ val incomplete = listOf(
+ ready.copy(startupScanComplete = false),
+ ready.copy(catalogReady = false),
+ ready.copy(folderReady = false),
+ ready.copy(outputReady = false),
+ ready.copy(modelReady = false),
+ ready.copy(transcriptionStateKnown = false),
+ )
+
+ incomplete.forEach { input ->
+ assertEquals(StartupProcessingDecision.Wait, StartupProcessingRules.decide(input))
+ }
+ }
+
+ @Test
+ fun unavailablePrerequisiteCanBecomeReadyLater() {
+ val missingOutput = readyInput().copy(outputReady = false)
+ assertEquals(StartupProcessingDecision.Wait, StartupProcessingRules.decide(missingOutput))
+ assertEquals(
+ StartupProcessingDecision.Prompt(1),
+ StartupProcessingRules.decide(missingOutput.copy(outputReady = true)),
+ )
+ }
+
+ @Test
+ fun emptyAndFailedOnlyCatalogsFinishAsNoPending() {
+ val finish = StartupProcessingDecision.Finish(StartupProcessingFinishReason.NO_PENDING)
+ assertEquals(finish, StartupProcessingRules.decide(readyInput(pendingCount = 0)))
+ assertEquals(finish, StartupProcessingRules.decide(readyInput(pendingCount = 0, failedCount = 4)))
+ }
+
+ @Test
+ fun activeTranscriptionWaits() {
+ assertEquals(
+ StartupProcessingDecision.Wait,
+ StartupProcessingRules.decide(readyInput(transcriptionActive = true)),
+ )
+ }
+
+ @Test
+ fun scanFailureIsTerminal() {
+ assertEquals(
+ StartupProcessingDecision.Finish(StartupProcessingFinishReason.SCAN_FAILED),
+ StartupProcessingRules.decide(StartupProcessingInput(startupScanFailed = true)),
+ )
+ }
+
+ @Test
+ fun completedEvaluationIsIdempotent() {
+ assertEquals(
+ StartupProcessingDecision.Finish(StartupProcessingFinishReason.ALREADY_EVALUATED),
+ StartupProcessingRules.decide(readyInput(evaluationComplete = true)),
+ )
+ }
+
+ private fun readyInput(
+ policy: StartupProcessingPolicy = StartupProcessingPolicy.ASK,
+ pendingCount: Int = 1,
+ failedCount: Int = 0,
+ transcriptionActive: Boolean = false,
+ evaluationComplete: Boolean = false,
+ ): StartupProcessingInput =
+ StartupProcessingInput(
+ policy = policy,
+ evaluationComplete = evaluationComplete,
+ startupScanComplete = true,
+ catalogReady = true,
+ pendingCount = pendingCount,
+ failedCount = failedCount,
+ folderReady = true,
+ outputReady = true,
+ modelReady = true,
+ transcriptionStateKnown = true,
+ transcriptionActive = transcriptionActive,
+ )
+}
diff --git a/website/src/pages/faq.astro b/website/src/pages/faq.astro
index f2603de..101ce5b 100644
--- a/website/src/pages/faq.astro
+++ b/website/src/pages/faq.astro
@@ -55,8 +55,8 @@ import Layout from '../layouts/Layout.astro';
Is iOS supported?
iOS has an active MVP: import audio files, preview playback, speech model download, local transcription,
- transcript display, and output append are available in the development app. Folder automation, scheduled background transcription,
- and full Android parity are still in progress.
+ transcript display, selected-folder refresh, startup processing, and output append are available in the development app.
+ Scheduled background transcription and full Android parity are still in progress.
@@ -77,6 +77,14 @@ import Layout from '../layouts/Layout.astro';
+
+ What happens when Voice Inbox finds queued files at startup?
+
+ Startup processing defaults to Ask. In Settings, you can instead make the app transcribe automatically or leave files queued.
+ This foreground startup behavior is separate from Android's optional nightly background transcription.
+
+
+
Can scheduled transcription run exactly at my selected time?
diff --git a/website/src/pages/setup.astro b/website/src/pages/setup.astro
index 0138c7f..c696d02 100644
--- a/website/src/pages/setup.astro
+++ b/website/src/pages/setup.astro
@@ -88,12 +88,20 @@ const releaseUrl = `${githubUrl}/releases`;
-
6. Optional: scheduled transcription
+
6. Optional: startup processing
- Open Settings to enable scheduled transcription and choose a preferred time. Android may delay background work,
+ In Settings, choose whether Voice Inbox should ask, transcribe automatically, or leave files queued when
+ the app opens and finishes scanning. Ask is the default. Startup processing is available on Android and iOS.
+
+
+
+
+
7. Optional: nightly transcription
+
+ On Android, open Settings to enable nightly transcription and choose a preferred time. Android may delay background work,
so scheduled runs should be treated as best-effort rather than exact alarms.
-
Scheduled background transcription is not part of the current iOS MVP.
+
Nightly background transcription is independent from startup processing and is not part of the current iOS MVP.
From b6914c1f73d83733456e2a86de8efb96472ff17c Mon Sep 17 00:00:00 2001
From: Max Starikov
Date: Thu, 16 Jul 2026 07:02:35 +0200
Subject: [PATCH 04/10] defer model loading
---
.../me/maxistar/voiceinbox/MainActivity.kt | 28 ++-
.../voiceinbox/NativeTranscriptionBridge.kt | 2 +
.../ScheduledTranscriptionWorker.kt | 5 +-
.../voiceinbox/SpeechModelDownloadWorker.kt | 1 +
.../voiceinbox/SpeechModelPreparation.kt | 38 +++
.../voiceinbox/SpeechModelReadinessManager.kt | 13 +-
.../voiceinbox/SpeechModelRepository.kt | 56 ++++-
.../StartupProcessingCoordinator.kt | 7 +-
.../voiceinbox/TranscriptionWorker.kt | 13 +-
.../voiceinbox/SpeechModelPreparationTest.kt | 97 ++++++++
.../SpeechModelReadinessManagerTest.kt | 12 -
.../voiceinbox/SpeechModelRepositoryTest.kt | 32 +++
iosApp/VoiceInbox.xcodeproj/project.pbxproj | 112 +++++++++
.../xcschemes/VoiceInbox.xcscheme | 27 +++
iosApp/VoiceInbox/ContentView.swift | 4 +
.../VoiceInbox/IosMainScreenShellState.swift | 13 +-
...IosSingleFileTranscriptionController.swift | 84 +++++--
iosApp/VoiceInbox/IosSpeechModelStore.swift | 212 +++++++++++++++-
.../DeferredSpeechModelLoadingTests.swift | 144 +++++++++++
.../core/MainScreenStateController.kt | 10 +-
.../voiceinbox/core/SpeechModelLifecycle.kt | 21 ++
.../voiceinbox/core/StartupProcessingRules.kt | 4 +-
.../voiceinbox/core/TranscriptionUiRules.kt | 19 +-
.../core/MainScreenStateControllerTest.kt | 32 ++-
.../core/StartupProcessingRulesTest.kt | 4 +-
.../core/TranscriptionUiRulesTest.kt | 16 +-
src/engine.rs | 226 +++++++-----------
src/lib.rs | 26 +-
28 files changed, 1018 insertions(+), 240 deletions(-)
create mode 100644 app/src/main/java/me/maxistar/voiceinbox/SpeechModelPreparation.kt
create mode 100644 app/src/test/java/me/maxistar/voiceinbox/SpeechModelPreparationTest.kt
create mode 100644 iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift
create mode 100644 shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/SpeechModelLifecycle.kt
diff --git a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
index aa53522..9448031 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
@@ -659,8 +659,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
evaluateStartupProcessing()
}
WorkInfo.State.SUCCEEDED -> {
- if (shouldHandleModelInstallSuccess(info.id.toString())) {
- modelReadiness.invalidate()
+ if (shouldHandleModelInstallSuccess(info.id.toString())) {
+ modelReadiness.invalidate()
+ SpeechModelPreparation.invalidate(NativeTranscriptionBridge::reset)
}
refreshModel()
}
@@ -736,6 +737,10 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
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
@@ -1000,10 +1005,18 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
MainScreenStateController.state(
MainScreenInput(
modelMessage = modelMessage,
- modelLoading = modelLoading,
+ modelInstallationState = when {
+ modelLoading -> SpeechModelInstallationState.INSTALLING
+ modelReady -> SpeechModelInstallationState.INSTALLED
+ modelDownloadAvailable -> SpeechModelInstallationState.NOT_INSTALLED
+ else -> SpeechModelInstallationState.INVALID
+ },
+ modelRuntimeState = if (
+ transcriptionState == TranscriptionObservationState.ACTIVE &&
+ transcriptionPhase?.contains("model", ignoreCase = true) == true
+ ) SpeechModelRuntimeState.LOADING else SpeechModelRuntimeState.UNLOADED,
modelDownloadAvailable = modelDownloadAvailable,
modelDownloadProgress = modelDownloadProgress,
- modelReady = modelReady,
outputSelected = outputUri != null,
folderSelected = folderUri != null,
pendingCount = pendingCount,
@@ -1059,19 +1072,20 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
@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/ScheduledTranscriptionWorker.kt b/app/src/main/java/me/maxistar/voiceinbox/ScheduledTranscriptionWorker.kt
index a8ce7f4..155cd7a 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/ScheduledTranscriptionWorker.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/ScheduledTranscriptionWorker.kt
@@ -51,10 +51,9 @@ 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)
diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt
index 0183b63..ed5a918 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt
@@ -85,6 +85,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))
}
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..a25bba7 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
}
@@ -23,6 +31,7 @@ class SpeechModelRepository(
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")
val stagingDirectory: File
get() = File(stagingRoot, manifest.version)
@@ -30,18 +39,39 @@ class SpeechModelRepository(
val installedDirectory: File
get() = File(installedRoot, manifest.version)
+ fun inspectLightweight(): InstalledSpeechModelState {
+ 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 {
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
}
}
@@ -99,6 +129,7 @@ class SpeechModelRepository(
"Failed to activate downloaded model"
}
writeActiveVersion(manifest.version)
+ invalidModelFile.delete()
installedRoot.listFiles()
?.filter { it.name != manifest.version }
@@ -134,7 +165,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 +191,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) {
diff --git a/app/src/main/java/me/maxistar/voiceinbox/StartupProcessingCoordinator.kt b/app/src/main/java/me/maxistar/voiceinbox/StartupProcessingCoordinator.kt
index a2adfa6..596710a 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/StartupProcessingCoordinator.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/StartupProcessingCoordinator.kt
@@ -4,6 +4,7 @@ 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,
@@ -94,7 +95,11 @@ class StartupProcessingCoordinator private constructor(
failedCount = failedCount,
folderReady = folderReady,
outputReady = outputReady,
- modelReady = modelReady,
+ modelInstallationState = if (modelReady) {
+ SpeechModelInstallationState.INSTALLED
+ } else {
+ SpeechModelInstallationState.NOT_INSTALLED
+ },
transcriptionStateKnown = transcriptionStateKnown,
transcriptionActive = transcriptionActive,
),
diff --git a/app/src/main/java/me/maxistar/voiceinbox/TranscriptionWorker.kt b/app/src/main/java/me/maxistar/voiceinbox/TranscriptionWorker.kt
index a140343..65fdbcb 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/TranscriptionWorker.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/TranscriptionWorker.kt
@@ -34,14 +34,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, 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,
diff --git a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelPreparationTest.kt b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelPreparationTest.kt
new file mode 100644
index 0000000..1b38d88
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelPreparationTest.kt
@@ -0,0 +1,97 @@
+package me.maxistar.voiceinbox
+
+import me.maxistar.voiceinbox.core.SpeechModelFile
+import me.maxistar.voiceinbox.core.SpeechModelManifest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.File
+import java.security.MessageDigest
+
+class SpeechModelPreparationTest {
+ @get:Rule
+ val temporaryFolder = TemporaryFolder()
+
+ @Test
+ fun preparationVerifiesAndInitializesOnceUntilInvalidated() {
+ val repository = readyRepository()
+ var initializations = 0
+ SpeechModelPreparation.invalidate()
+
+ repeat(2) {
+ assertTrue(
+ SpeechModelPreparation.prepare(repository) {
+ initializations += 1
+ true
+ }.isSuccess,
+ )
+ }
+ assertEquals(1, initializations)
+
+ SpeechModelPreparation.invalidate()
+ assertTrue(
+ SpeechModelPreparation.prepare(repository) {
+ initializations += 1
+ true
+ }.isSuccess,
+ )
+ assertEquals(2, initializations)
+ }
+
+ @Test
+ fun invalidModelFailsBeforeNativeInitialization() {
+ val repository = readyRepository()
+ repository.installedDirectory.resolve("model.bin").writeText("other")
+ var initialized = false
+ SpeechModelPreparation.invalidate()
+
+ val result = SpeechModelPreparation.prepare(repository) {
+ initialized = true
+ true
+ }
+
+ assertTrue(result.isFailure)
+ assertTrue(!initialized)
+ }
+
+ private fun readyRepository(): SpeechModelRepository {
+ val repository = SpeechModelRepository(
+ File(temporaryFolder.root, "models"),
+ testManifest,
+ usableSpace = { Long.MAX_VALUE },
+ )
+ repository.prepareForInstall().getOrThrow()
+ testFiles.forEach { (name, contents) ->
+ repository.stagingDirectory.resolve(name).apply {
+ parentFile?.mkdirs()
+ writeBytes(contents)
+ }
+ }
+ repository.activate().getOrThrow()
+ return repository
+ }
+
+ companion object {
+ private val testFiles = linkedMapOf(
+ "model.bin" to "model".toByteArray(),
+ "config.json" to "{}".toByteArray(),
+ )
+ private val testManifest = SpeechModelManifest(
+ modelId = "example/model",
+ version = "test-version",
+ repositoryRevision = "0123456789abcdef",
+ files = testFiles.map { (name, contents) ->
+ SpeechModelFile(
+ name,
+ contents.size.toLong(),
+ MessageDigest.getInstance("SHA-256")
+ .digest(contents)
+ .joinToString("") { "%02x".format(it) },
+ )
+ },
+ safetyMarginBytes = 8,
+ )
+ }
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelReadinessManagerTest.kt b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelReadinessManagerTest.kt
index ec95103..8669454 100644
--- a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelReadinessManagerTest.kt
+++ b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelReadinessManagerTest.kt
@@ -19,13 +19,8 @@ class SpeechModelReadinessManagerTest {
fun duplicateRefreshesAreCoalescedWhileCheckIsRunning() {
val executor = QueueingExecutor()
val repository = readyRepository()
- var initializations = 0
val manager = SpeechModelReadinessManager(
repository = repository,
- initializeModel = {
- initializations += 1
- true
- },
executor = executor,
)
val firstStates = mutableListOf()
@@ -40,7 +35,6 @@ class SpeechModelReadinessManagerTest {
executor.runNext()
- assertEquals(1, initializations)
assertTrue(firstStates.last() is SpeechModelReadinessState.Ready)
assertTrue(secondStates.last() is SpeechModelReadinessState.Ready)
}
@@ -49,13 +43,8 @@ class SpeechModelReadinessManagerTest {
fun readyStateIsCachedForLaterRefreshes() {
val executor = QueueingExecutor()
val repository = readyRepository()
- var initializations = 0
val manager = SpeechModelReadinessManager(
repository = repository,
- initializeModel = {
- initializations += 1
- true
- },
executor = executor,
)
val firstStates = mutableListOf()
@@ -65,7 +54,6 @@ class SpeechModelReadinessManagerTest {
executor.runNext()
manager.refresh { cachedStates += it }
- assertEquals(1, initializations)
assertEquals(0, executor.pendingCount)
assertTrue(firstStates.last() is SpeechModelReadinessState.Ready)
assertEquals(1, cachedStates.size)
diff --git a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelRepositoryTest.kt b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelRepositoryTest.kt
index 1e668b1..51889d5 100644
--- a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelRepositoryTest.kt
+++ b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelRepositoryTest.kt
@@ -37,6 +37,38 @@ class SpeechModelRepositoryTest {
)
}
+ @Test
+ fun lightweightInspectionDoesNotHashInstalledPayloads() {
+ val repository = repository()
+ repository.prepareForInstall().getOrThrow()
+ writeValidStaging(repository)
+ val installed = repository.activate().getOrThrow()
+ installed.resolve("model.bin").writeText("other")
+
+ val lightweight = repository.inspectLightweight() as InstalledSpeechModelState.Ready
+ assertEquals(InstalledSpeechModelState.Ready.Verification.VERIFIED, lightweight.verification)
+ assertTrue(repository.inspect() is InstalledSpeechModelState.Invalid)
+ assertTrue(repository.inspectLightweight() is InstalledSpeechModelState.Invalid)
+ }
+
+ @Test
+ fun legacyInstallationWithoutReceiptIsAvailableLightweight() {
+ val repository = repository()
+ repository.prepareForInstall().getOrThrow()
+ writeValidStaging(repository)
+ repository.activate().getOrThrow()
+ File(temporaryFolder.root, "models/active-model").delete()
+
+ val legacy = repository.inspectLightweight() as InstalledSpeechModelState.Ready
+ assertEquals(InstalledSpeechModelState.Ready.Verification.LEGACY_UNVERIFIED, legacy.verification)
+ assertTrue(repository.inspect() is InstalledSpeechModelState.Ready)
+ assertEquals(
+ InstalledSpeechModelState.Ready.Verification.VERIFIED,
+ (repository.inspectLightweight() as InstalledSpeechModelState.Ready).verification,
+ )
+ assertTrue(File(temporaryFolder.root, "models/active-model").isFile)
+ }
+
@Test
fun corruptAndIncompleteModelsAreRejected() {
val repository = repository()
diff --git a/iosApp/VoiceInbox.xcodeproj/project.pbxproj b/iosApp/VoiceInbox.xcodeproj/project.pbxproj
index 1a3cb12..559a8da 100644
--- a/iosApp/VoiceInbox.xcodeproj/project.pbxproj
+++ b/iosApp/VoiceInbox.xcodeproj/project.pbxproj
@@ -23,6 +23,7 @@
361EE03677F4524C001986C0 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2A88E9CFF2023E878C6EC22 /* ShareViewController.swift */; };
5E50C86D75D247316B5953AB /* VoiceInboxShareExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 242890FE4BBC56636FBA9F10 /* VoiceInboxShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
9FAC76B26E6BD4116D968475 /* IosSharedImportStaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD030301C0E14C9932DCBFE6 /* IosSharedImportStaging.swift */; };
+ A10000000000000000000001 /* DeferredSpeechModelLoadingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000002 /* DeferredSpeechModelLoadingTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -33,6 +34,13 @@
remoteGlobalIDString = D93524F1DBD24C87F8DF0EB9;
remoteInfo = VoiceInboxShareExtension;
};
+ A1000000000000000000000A /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 101010101010101010101010 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 101010101010101010101007;
+ remoteInfo = VoiceInbox;
+ };
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -70,6 +78,8 @@
CD030301C0E14C9932DCBFE6 /* IosSharedImportStaging.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = IosSharedImportStaging.swift; path = ../VoiceInbox/IosSharedImportStaging.swift; sourceTree = ""; };
EF9FEAB9A5BF00B4E66B996C /* VoiceInbox.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = VoiceInbox.entitlements; sourceTree = ""; };
F63411837D138D0E90F5BA1D /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ A10000000000000000000002 /* DeferredSpeechModelLoadingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredSpeechModelLoadingTests.swift; sourceTree = ""; };
+ A10000000000000000000003 /* VoiceInboxTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = VoiceInboxTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -87,6 +97,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ A10000000000000000000004 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -96,6 +113,7 @@
101010101010101010101013 /* VoiceInbox */,
101010101010101010101006 /* Products */,
F139417D5AA7BD6EFD2ECADD /* VoiceInboxShareExtension */,
+ A10000000000000000000005 /* VoiceInboxTests */,
);
sourceTree = "";
};
@@ -104,6 +122,7 @@
children = (
101010101010101010101003 /* VoiceInbox.app */,
242890FE4BBC56636FBA9F10 /* VoiceInboxShareExtension.appex */,
+ A10000000000000000000003 /* VoiceInboxTests.xctest */,
);
name = Products;
sourceTree = "";
@@ -140,6 +159,14 @@
path = VoiceInboxShareExtension;
sourceTree = "";
};
+ A10000000000000000000005 /* VoiceInboxTests */ = {
+ isa = PBXGroup;
+ children = (
+ A10000000000000000000002 /* DeferredSpeechModelLoadingTests.swift */,
+ );
+ path = VoiceInboxTests;
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -181,6 +208,24 @@
productReference = 242890FE4BBC56636FBA9F10 /* VoiceInboxShareExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
+ A10000000000000000000006 /* VoiceInboxTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = A1000000000000000000000D /* Build configuration list for PBXNativeTarget "VoiceInboxTests" */;
+ buildPhases = (
+ A10000000000000000000008 /* Sources */,
+ A10000000000000000000004 /* Frameworks */,
+ A10000000000000000000007 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ A10000000000000000000009 /* PBXTargetDependency */,
+ );
+ name = VoiceInboxTests;
+ productName = VoiceInboxTests;
+ productReference = A10000000000000000000003 /* VoiceInboxTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -194,6 +239,9 @@
101010101010101010101007 = {
CreatedOnToolsVersion = 16.0;
};
+ A10000000000000000000006 = {
+ CreatedOnToolsVersion = 16.0;
+ };
};
};
buildConfigurationList = 101010101010101010101016 /* Build configuration list for PBXProject "VoiceInbox" */;
@@ -211,6 +259,7 @@
targets = (
101010101010101010101007 /* VoiceInbox */,
D93524F1DBD24C87F8DF0EB9 /* VoiceInboxShareExtension */,
+ A10000000000000000000006 /* VoiceInboxTests */,
);
};
/* End PBXProject section */
@@ -231,6 +280,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ A10000000000000000000007 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -307,6 +363,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ A10000000000000000000008 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A10000000000000000000001 /* DeferredSpeechModelLoadingTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -316,6 +380,11 @@
target = D93524F1DBD24C87F8DF0EB9 /* VoiceInboxShareExtension */;
targetProxy = B02BB42352D5BAE85F16E7A7 /* PBXContainerItemProxy */;
};
+ A10000000000000000000009 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 101010101010101010101007 /* VoiceInbox */;
+ targetProxy = A1000000000000000000000A /* PBXContainerItemProxy */;
+ };
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@@ -581,6 +650,40 @@
};
name = Release;
};
+ A1000000000000000000000B /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_TEAM = 2835P3MJR8;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ PRODUCT_BUNDLE_IDENTIFIER = me.maxistar.voiceinbox.ios.tests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VoiceInbox.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VoiceInbox";
+ };
+ name = Debug;
+ };
+ A1000000000000000000000C /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_TEAM = 2835P3MJR8;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ PRODUCT_BUNDLE_IDENTIFIER = me.maxistar.voiceinbox.ios.tests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VoiceInbox.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VoiceInbox";
+ };
+ name = Release;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -611,6 +714,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ A1000000000000000000000D /* Build configuration list for PBXNativeTarget "VoiceInboxTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A1000000000000000000000B /* Debug */,
+ A1000000000000000000000C /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
};
rootObject = 101010101010101010101010 /* Project object */;
diff --git a/iosApp/VoiceInbox.xcodeproj/xcshareddata/xcschemes/VoiceInbox.xcscheme b/iosApp/VoiceInbox.xcodeproj/xcshareddata/xcschemes/VoiceInbox.xcscheme
index a3ade6b..ea1695e 100644
--- a/iosApp/VoiceInbox.xcodeproj/xcshareddata/xcschemes/VoiceInbox.xcscheme
+++ b/iosApp/VoiceInbox.xcodeproj/xcshareddata/xcschemes/VoiceInbox.xcscheme
@@ -21,6 +21,20 @@
ReferencedContainer = "container:VoiceInbox.xcodeproj">
+
+
+
+
+
+
+
+
+
+
Bool,
+ claim: () -> Void
+ ) async -> Bool {
+ guard await prepare() else { return false }
+ claim()
+ return true
+ }
+}
+
@_silgen_name("voiceinbox_transcription_initialize")
private func voiceinbox_transcription_initialize(_ modelDirectory: UnsafePointer) -> Bool
@@ -20,6 +32,9 @@ private func voiceinbox_transcription_string_free(_ value: UnsafeMutablePointer<
@_silgen_name("voiceinbox_transcription_backend_configured")
private func voiceinbox_transcription_backend_configured() -> Bool
+@_silgen_name("voiceinbox_transcription_reset")
+private func voiceinbox_transcription_reset()
+
struct IosSingleFileTranscriptionState {
let active: Bool
let fileId: Int64?
@@ -80,17 +95,17 @@ final class IosSingleFileTranscriptionController: ObservableObject {
file: IosImportedAudioFile,
localURL: URL,
modelDirectory: URL,
+ modelStore: IosSpeechModelStore,
outputDocument: IosSelectedOutputDocument,
store: IosAudioImportStore,
onSuccess: ((String) -> Void)? = nil
) {
task?.cancel()
- store.markProcessing(fileId: file.id)
state = IosSingleFileTranscriptionState(
active: true,
fileId: file.id,
fileName: file.displayName,
- phase: SingleFileTranscriptionUseCase.companion.PHASE_DECODING_AUDIO,
+ phase: "Preparing speech model",
processedUs: 0,
durationUs: 0,
completedFiles: 0,
@@ -102,12 +117,26 @@ final class IosSingleFileTranscriptionController: ObservableObject {
message = nil
task = Task {
+ let prepared = await IosTranscriptionPreparationGate.prepareAndClaim(
+ prepare: { await modelStore.prepareForTranscription() != nil },
+ claim: { store.markProcessing(fileId: file.id) }
+ )
+ guard prepared else {
+ message = modelStore.message ?? "Speech model preparation failed."
+ state = .idle
+ return
+ }
+ guard !Task.isCancelled else {
+ state = .idle
+ return
+ }
let outcome = await Task.detached(priority: .userInitiated) {
Self.runSharedTranscription(
file: file,
localURL: localURL,
modelDirectory: modelDirectory.path,
- outputDocument: outputDocument
+ outputDocument: outputDocument,
+ modelPrepared: true
) { progress in
Task { @MainActor in
self.state = IosSingleFileTranscriptionState(
@@ -152,6 +181,7 @@ final class IosSingleFileTranscriptionController: ObservableObject {
func transcribeAll(
modelDirectory: URL,
+ modelStore: IosSpeechModelStore,
outputDocument: IosSelectedOutputDocument,
store: IosAudioImportStore,
onFinished: (() -> Void)? = nil
@@ -162,7 +192,7 @@ final class IosSingleFileTranscriptionController: ObservableObject {
active: true,
fileId: nil,
fileName: nil,
- phase: "Preparing transcription",
+ phase: "Preparing speech model",
processedUs: 0,
durationUs: 0,
completedFiles: 0,
@@ -174,6 +204,15 @@ final class IosSingleFileTranscriptionController: ObservableObject {
message = nil
task = Task {
+ guard await modelStore.prepareForTranscription() != nil else {
+ message = modelStore.message ?? "Speech model preparation failed."
+ state = .idle
+ return
+ }
+ guard !Task.isCancelled else {
+ state = .idle
+ return
+ }
let outcome = await Task.detached(priority: .userInitiated) {
Self.runSharedBatchTranscription(
modelDirectory: modelDirectory.path,
@@ -225,6 +264,7 @@ final class IosSingleFileTranscriptionController: ObservableObject {
file: IosImportedAudioFile,
localURL: URL,
modelDirectory: URL,
+ modelStore: IosSpeechModelStore,
outputDocument: IosSelectedOutputDocument,
store: IosAudioImportStore,
onSuccess: ((String) -> Void)? = nil
@@ -234,6 +274,7 @@ final class IosSingleFileTranscriptionController: ObservableObject {
file: file,
localURL: localURL,
modelDirectory: modelDirectory,
+ modelStore: modelStore,
outputDocument: outputDocument,
store: store,
onSuccess: onSuccess
@@ -245,11 +286,12 @@ final class IosSingleFileTranscriptionController: ObservableObject {
localURL: URL,
modelDirectory: String,
outputDocument: IosSelectedOutputDocument,
+ modelPrepared: Bool = false,
onProgress: @escaping (SingleFileTranscriptionProgress) -> Void
) -> SingleFileTranscriptionOutcome {
let decoder = IosPlatformAudioDecoder(localURL: localURL)
let transcriber = IosNativeTranscriber(modelDirectory: modelDirectory)
- guard transcriber.initialize(modelDirectory: transcriber.modelDirectory) else {
+ guard modelPrepared || transcriber.initialize(modelDirectory: transcriber.modelDirectory) else {
return SingleFileTranscriptionOutcome(
success: false,
result: nil,
@@ -345,6 +387,7 @@ private final class IosBatchEntryOutcomeTranscriber: OutcomeBatchEntryTranscribe
localURL: Self.localURL(documentUri: entry.documentUri),
modelDirectory: modelDirectory,
outputDocument: outputDocument,
+ modelPrepared: true,
onProgress: onProgress
)
}
@@ -477,7 +520,7 @@ private final class IosPlatformAudioDecoder: PlatformAudioDecoder {
}
}
-private final class IosNativeTranscriber: PlatformNativeTranscriber {
+final class IosNativeTranscriber: PlatformNativeTranscriber {
let modelDirectory: String
private(set) var lastError: String?
@@ -489,12 +532,24 @@ private final class IosNativeTranscriber: PlatformNativeTranscriber {
voiceinbox_transcription_backend_configured()
}
+ static func prepare(modelDirectory: String) -> Bool {
+ modelDirectory.withCString { voiceinbox_transcription_initialize($0) }
+ }
+
+ static func resetModel() {
+ voiceinbox_transcription_reset()
+ }
+
+ static func consumeLastError() -> String? {
+ guard let pointer = voiceinbox_transcription_last_error() else { return nil }
+ defer { voiceinbox_transcription_string_free(pointer) }
+ return String(cString: pointer)
+ }
+
func initialize(modelDirectory: String) -> Bool {
- let ok = modelDirectory.withCString { pointer in
- voiceinbox_transcription_initialize(pointer)
- }
+ let ok = Self.prepare(modelDirectory: modelDirectory)
if !ok {
- lastError = Self.consumeNativeError()
+ lastError = Self.consumeLastError() ?? "iOS native transcription failed."
}
return ok
}
@@ -510,7 +565,7 @@ private final class IosNativeTranscriber: PlatformNativeTranscriber {
voiceinbox_transcription_transcribe_chunk_json(pointer.baseAddress, pointer.count)
}
guard let result else {
- lastError = Self.consumeNativeError()
+ lastError = Self.consumeLastError() ?? "iOS native transcription failed."
return nil
}
defer { voiceinbox_transcription_string_free(result) }
@@ -526,13 +581,6 @@ private final class IosNativeTranscriber: PlatformNativeTranscriber {
return text
}
- private static func consumeNativeError() -> String {
- guard let pointer = voiceinbox_transcription_last_error() else {
- return "iOS native transcription failed."
- }
- defer { voiceinbox_transcription_string_free(pointer) }
- return String(cString: pointer)
- }
}
private final class IosTranscriptStaging: PlatformTranscriptStaging {
diff --git a/iosApp/VoiceInbox/IosSpeechModelStore.swift b/iosApp/VoiceInbox/IosSpeechModelStore.swift
index 3a404b0..e70e70d 100644
--- a/iosApp/VoiceInbox/IosSpeechModelStore.swift
+++ b/iosApp/VoiceInbox/IosSpeechModelStore.swift
@@ -3,11 +3,22 @@ import CryptoKit
import Foundation
import Shared
+enum IosSpeechModelInstallationState: Equatable {
+ case missing
+ case installedVerified
+ case installedLegacy
+ case invalid
+}
+
struct IosSpeechModelStatus {
let directory: URL
- let isReady: Bool
+ let installationState: IosSpeechModelInstallationState
let missingFiles: [String]
+ var isReady: Bool {
+ installationState == .installedVerified || installationState == .installedLegacy
+ }
+
var summary: String {
if isReady {
return "Speech model installed"
@@ -48,6 +59,14 @@ enum IosSpeechModelPaths {
static var backupDirectory: URL {
applicationSupportDirectory.appendingPathComponent("SpeechModel.previous", isDirectory: true)
}
+
+ static var receiptFile: URL {
+ applicationSupportDirectory.appendingPathComponent("SpeechModel.receipt")
+ }
+
+ static var invalidFile: URL {
+ applicationSupportDirectory.appendingPathComponent("SpeechModel.invalid")
+ }
}
struct IosSpeechModelDownloadProgress {
@@ -66,13 +85,52 @@ final class IosSpeechModelStore: ObservableObject {
@Published private(set) var status: IosSpeechModelStatus
@Published private(set) var isInstalling = false
@Published private(set) var downloadProgress: IosSpeechModelDownloadProgress?
+ @Published private(set) var runtimeState = SpeechModelRuntimeState.unloaded
@Published var message: String?
private var downloadTask: Task?
+ private var preparationTask: Task?
+ private let installationDirectory: URL
+ private let inspectInstallation: @Sendable (URL) -> IosSpeechModelStatus
+ private let validateInstallation: @Sendable (URL) -> [String]
+ private let prepareNative: @Sendable (String) -> Bool
+ private let nativeError: @Sendable () -> String?
+ private let recordVerified: @Sendable () -> Void
+ private let recordInvalid: @Sendable (String) -> Void
+ private let resetNative: @Sendable () -> Void
+
+ convenience init() {
+ self.init(
+ directory: IosSpeechModelPaths.modelDirectory,
+ inspectInstallation: { Self.inspectLightweight(directory: $0) },
+ validateInstallation: { Self.validateModelFiles(in: $0).missingFiles },
+ prepareNative: { IosNativeTranscriber.prepare(modelDirectory: $0) },
+ nativeError: { IosNativeTranscriber.consumeLastError() },
+ recordVerified: { Self.recordVerifiedInstallation() },
+ recordInvalid: { Self.recordInvalidInstallation($0) },
+ resetNative: { IosNativeTranscriber.resetModel() }
+ )
+ }
- init() {
- let directory = IosSpeechModelPaths.modelDirectory
- status = Self.validate(directory: directory)
+ init(
+ directory: URL,
+ inspectInstallation: @escaping @Sendable (URL) -> IosSpeechModelStatus,
+ validateInstallation: @escaping @Sendable (URL) -> [String],
+ prepareNative: @escaping @Sendable (String) -> Bool,
+ nativeError: @escaping @Sendable () -> String?,
+ recordVerified: @escaping @Sendable () -> Void,
+ recordInvalid: @escaping @Sendable (String) -> Void,
+ resetNative: @escaping @Sendable () -> Void
+ ) {
+ installationDirectory = directory
+ self.inspectInstallation = inspectInstallation
+ self.validateInstallation = validateInstallation
+ self.prepareNative = prepareNative
+ self.nativeError = nativeError
+ self.recordVerified = recordVerified
+ self.recordInvalid = recordInvalid
+ self.resetNative = resetNative
+ status = inspectInstallation(directory)
}
var isReady: Bool {
@@ -88,7 +146,7 @@ final class IosSpeechModelStore: ObservableObject {
}
func reload() {
- status = Self.validate(directory: IosSpeechModelPaths.modelDirectory)
+ status = inspectInstallation(installationDirectory)
}
func installModel(from sourceURL: URL) {
@@ -104,7 +162,8 @@ final class IosSpeechModelStore: ObservableObject {
}.value
isInstalling = false
- status = Self.validate(directory: IosSpeechModelPaths.modelDirectory)
+ status = Self.inspectLightweight(directory: IosSpeechModelPaths.modelDirectory)
+ runtimeState = .unloaded
message = result.message
}
}
@@ -134,7 +193,8 @@ final class IosSpeechModelStore: ObservableObject {
isInstalling = false
downloadTask = nil
- status = Self.validate(directory: IosSpeechModelPaths.modelDirectory)
+ status = Self.inspectLightweight(directory: IosSpeechModelPaths.modelDirectory)
+ runtimeState = .unloaded
downloadProgress = nil
message = result.message
}
@@ -148,13 +208,130 @@ final class IosSpeechModelStore: ObservableObject {
message = "Speech model download cancelled."
}
- nonisolated private static func validate(directory: URL) -> IosSpeechModelStatus {
- let result = validateModelFiles(in: directory)
+ func prepareForTranscription() async -> URL? {
+ if runtimeState == .loaded { return modelDirectory }
+ let task: Task
+ if let existing = preparationTask {
+ task = existing
+ } else {
+ runtimeState = .loading
+ message = "Preparing speech model..."
+ let directory = modelDirectory
+ let validateInstallation = validateInstallation
+ let prepareNative = prepareNative
+ let nativeError = nativeError
+ let recordVerified = recordVerified
+ let recordInvalid = recordInvalid
+ task = Task.detached(priority: .userInitiated) {
+ let validationIssues = validateInstallation(directory)
+ guard validationIssues.isEmpty else {
+ let reason = validationIssues.joined(separator: ", ")
+ recordInvalid(reason)
+ return PreparationResult(
+ directory: nil,
+ invalidInstallation: true,
+ message: "Speech model is invalid: \(reason)"
+ )
+ }
+ guard prepareNative(directory.path) else {
+ return PreparationResult(
+ directory: nil,
+ invalidInstallation: false,
+ message: nativeError()
+ ?? "Speech model failed to load."
+ )
+ }
+ recordVerified()
+ return PreparationResult(directory: directory, invalidInstallation: false, message: nil)
+ }
+ preparationTask = task
+ }
+
+ let result = await task.value
+ preparationTask = nil
+ if let directory = result.directory {
+ status = inspectInstallation(directory)
+ runtimeState = .loaded
+ message = nil
+ return directory
+ }
+ runtimeState = .failed
+ if result.invalidInstallation {
+ status = inspectInstallation(modelDirectory)
+ }
+ message = result.message
+ return nil
+ }
+
+ func invalidateRuntimeAfterReplacement() {
+ preparationTask?.cancel()
+ preparationTask = nil
+ runtimeState = .unloaded
+ resetNative()
+ }
+
+ nonisolated static func inspectLightweight(
+ directory: URL,
+ receiptFile: URL = IosSpeechModelPaths.receiptFile,
+ invalidFile: URL = IosSpeechModelPaths.invalidFile,
+ requiredFileNames: [String]? = nil
+ ) -> IosSpeechModelStatus {
+ if let reason = try? String(contentsOf: invalidFile, encoding: .utf8),
+ !reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ return IosSpeechModelStatus(
+ directory: directory,
+ installationState: .invalid,
+ missingFiles: [reason]
+ )
+ }
+ let fileManager = FileManager.default
+ var isDirectory: ObjCBool = false
+ guard fileManager.fileExists(atPath: directory.path, isDirectory: &isDirectory), isDirectory.boolValue else {
+ return IosSpeechModelStatus(
+ directory: directory,
+ installationState: .missing,
+ missingFiles: ["model directory"]
+ )
+ }
+ let missing = (requiredFileNames ?? manifestFiles().map(\.name)).compactMap { fileName in
+ fileManager.isReadableFile(atPath: directory.appendingPathComponent(fileName).path)
+ ? nil
+ : fileName
+ }
+ guard missing.isEmpty else {
+ return IosSpeechModelStatus(
+ directory: directory,
+ installationState: .invalid,
+ missingFiles: missing
+ )
+ }
+ let receipt = try? String(contentsOf: receiptFile, encoding: .utf8)
+ let state: IosSpeechModelInstallationState = receipt?.trimmingCharacters(in: .whitespacesAndNewlines)
+ == EmbeddedSpeechModel.shared.manifest.version
+ ? .installedVerified
+ : .installedLegacy
return IosSpeechModelStatus(
directory: directory,
- isReady: result.missingFiles.isEmpty,
- missingFiles: result.missingFiles
+ installationState: state,
+ missingFiles: []
+ )
+ }
+
+ nonisolated private static func recordVerifiedInstallation() {
+ try? FileManager.default.createDirectory(
+ at: IosSpeechModelPaths.applicationSupportDirectory,
+ withIntermediateDirectories: true
+ )
+ try? EmbeddedSpeechModel.shared.manifest.version.write(
+ to: IosSpeechModelPaths.receiptFile,
+ atomically: true,
+ encoding: .utf8
)
+ try? removeItemIfExists(IosSpeechModelPaths.invalidFile)
+ }
+
+ nonisolated private static func recordInvalidInstallation(_ reason: String) {
+ try? reason.write(to: IosSpeechModelPaths.invalidFile, atomically: true, encoding: .utf8)
}
nonisolated private static func validateModelFiles(in directory: URL) -> ModelValidationResult {
@@ -221,6 +398,8 @@ final class IosSpeechModelStore: ObservableObject {
do {
try fileManager.moveItem(at: IosSpeechModelPaths.installDirectory, to: IosSpeechModelPaths.modelDirectory)
try removeItemIfExists(IosSpeechModelPaths.backupDirectory)
+ recordVerifiedInstallation()
+ IosNativeTranscriber.resetModel()
return InstallResult(message: "Speech model installed.")
} catch {
if fileManager.fileExists(atPath: IosSpeechModelPaths.backupDirectory.path) {
@@ -311,6 +490,8 @@ final class IosSpeechModelStore: ObservableObject {
}
try activateStagedModel()
+ recordVerifiedInstallation()
+ IosNativeTranscriber.resetModel()
return InstallResult(message: "Speech model downloaded and installed.")
} catch is CancellationError {
try? removeItemIfExists(IosSpeechModelPaths.stagingDirectory)
@@ -461,8 +642,7 @@ final class IosSpeechModelStore: ObservableObject {
nonisolated private static func manifestFiles() -> [IosSpeechModelManifestFile] {
let manifest = EmbeddedSpeechModel.shared.manifest
- return manifest.files.compactMap { item in
- guard let file = item as? SpeechModelFile else { return nil }
+ return manifest.files.map { file in
return IosSpeechModelManifestFile(
name: file.name,
sizeBytes: file.sizeBytes,
@@ -484,6 +664,12 @@ final class IosSpeechModelStore: ObservableObject {
}
}
+ private struct PreparationResult {
+ let directory: URL?
+ let invalidInstallation: Bool
+ let message: String?
+ }
+
private struct IosSpeechModelManifestFile {
let name: String
let sizeBytes: Int64
diff --git a/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift b/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift
new file mode 100644
index 0000000..5b710de
--- /dev/null
+++ b/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift
@@ -0,0 +1,144 @@
+import Foundation
+import Shared
+import XCTest
+@testable import VoiceInbox
+
+final class DeferredSpeechModelLoadingTests: XCTestCase {
+ func testLightweightInspectionDistinguishesMissingLegacyVerifiedAndKnownInvalidWithoutReadingPayloads() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ let model = root.appendingPathComponent("SpeechModel", isDirectory: true)
+ let receipt = root.appendingPathComponent("SpeechModel.receipt")
+ let invalid = root.appendingPathComponent("SpeechModel.invalid")
+ defer { try? FileManager.default.removeItem(at: root) }
+
+ let missing = IosSpeechModelStore.inspectLightweight(
+ directory: model,
+ receiptFile: receipt,
+ invalidFile: invalid,
+ requiredFileNames: ["large-model.onnx"]
+ )
+ XCTAssertEqual(missing.installationState, .missing)
+
+ try FileManager.default.createDirectory(at: model, withIntermediateDirectories: true)
+ // Deliberately invalid payload contents prove the startup probe only checks presence/readability.
+ try Data("not an ONNX model".utf8).write(to: model.appendingPathComponent("large-model.onnx"))
+ let legacy = IosSpeechModelStore.inspectLightweight(
+ directory: model,
+ receiptFile: receipt,
+ invalidFile: invalid,
+ requiredFileNames: ["large-model.onnx"]
+ )
+ XCTAssertEqual(legacy.installationState, .installedLegacy)
+ XCTAssertTrue(legacy.isReady)
+
+ try EmbeddedSpeechModel.shared.manifest.version.write(to: receipt, atomically: true, encoding: .utf8)
+ let verified = IosSpeechModelStore.inspectLightweight(
+ directory: model,
+ receiptFile: receipt,
+ invalidFile: invalid,
+ requiredFileNames: ["large-model.onnx"]
+ )
+ XCTAssertEqual(verified.installationState, .installedVerified)
+
+ try "checksum mismatch".write(to: invalid, atomically: true, encoding: .utf8)
+ let knownInvalid = IosSpeechModelStore.inspectLightweight(
+ directory: model,
+ receiptFile: receipt,
+ invalidFile: invalid,
+ requiredFileNames: ["large-model.onnx"]
+ )
+ XCTAssertEqual(knownInvalid.installationState, .invalid)
+ XCTAssertFalse(knownInvalid.isReady)
+ }
+
+ @MainActor
+ func testLegacyPreparationWritesReceiptAndConcurrentAndLaterCallsReuseSingleLoad() async throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ let receipt = root.appendingPathComponent("SpeechModel.receipt")
+ defer { try? FileManager.default.removeItem(at: root) }
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ let loads = LockedCounter()
+
+ let store = IosSpeechModelStore(
+ directory: root,
+ inspectInstallation: { directory in
+ IosSpeechModelStatus(
+ directory: directory,
+ installationState: FileManager.default.fileExists(atPath: receipt.path)
+ ? .installedVerified
+ : .installedLegacy,
+ missingFiles: []
+ )
+ },
+ validateInstallation: { _ in [] },
+ prepareNative: { _ in
+ loads.increment()
+ Thread.sleep(forTimeInterval: 0.05)
+ return true
+ },
+ nativeError: { nil },
+ recordVerified: {
+ try? EmbeddedSpeechModel.shared.manifest.version.write(
+ to: receipt,
+ atomically: true,
+ encoding: .utf8
+ )
+ },
+ recordInvalid: { _ in },
+ resetNative: {}
+ )
+
+ async let first = store.prepareForTranscription()
+ async let second = store.prepareForTranscription()
+ let results = await [first, second]
+
+ XCTAssertEqual(results.compactMap { $0 }.count, 2)
+ XCTAssertEqual(loads.value, 1)
+ XCTAssertEqual(store.status.installationState, .installedVerified)
+ XCTAssertTrue(FileManager.default.fileExists(atPath: receipt.path))
+
+ _ = await store.prepareForTranscription()
+ XCTAssertEqual(loads.value, 1)
+
+ store.invalidateRuntimeAfterReplacement()
+ XCTAssertEqual(store.runtimeState, .unloaded)
+ _ = await store.prepareForTranscription()
+ XCTAssertEqual(loads.value, 2)
+ }
+
+ @MainActor
+ func testPreparationFailureDoesNotClaimAudioAndDeferredHandoffDoesNotPrepareEarly() async {
+ let prepares = LockedCounter()
+ let claims = LockedCounter()
+
+ XCTAssertEqual(prepares.value, 0)
+ XCTAssertEqual(claims.value, 0)
+
+ let handedOff = await IosTranscriptionPreparationGate.prepareAndClaim(
+ prepare: {
+ prepares.increment()
+ return false
+ },
+ claim: { claims.increment() }
+ )
+
+ XCTAssertFalse(handedOff)
+ XCTAssertEqual(prepares.value, 1)
+ XCTAssertEqual(claims.value, 0)
+ }
+}
+
+private final class LockedCounter: @unchecked Sendable {
+ private let lock = NSLock()
+ private var count = 0
+
+ var value: Int {
+ lock.withLock { count }
+ }
+
+ func increment() {
+ lock.withLock { count += 1 }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/MainScreenStateController.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/MainScreenStateController.kt
index 752cf3a..fd6c9b8 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/MainScreenStateController.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/MainScreenStateController.kt
@@ -13,10 +13,10 @@ data class MainScreenRowInput(
data class MainScreenInput(
val modelMessage: String,
- val modelLoading: Boolean,
+ val modelInstallationState: SpeechModelInstallationState,
+ val modelRuntimeState: SpeechModelRuntimeState,
val modelDownloadAvailable: Boolean,
val modelDownloadProgress: Int?,
- val modelReady: Boolean,
val outputSelected: Boolean,
val folderSelected: Boolean,
val pendingCount: Int,
@@ -77,7 +77,7 @@ object MainScreenStateController {
fun state(input: MainScreenInput): MainScreenState {
val busy = input.folderChecking || input.folderScanQueued || input.scanning
val controls = TranscriptionUiRules.catalogControls(
- modelReady = input.modelReady,
+ modelInstallationState = input.modelInstallationState,
outputSelected = input.outputSelected,
folderSelected = input.folderSelected,
pendingCount = input.pendingCount,
@@ -132,10 +132,10 @@ object MainScreenStateController {
private fun MainScreenInput.toStatusInput(): StatusProgressInput =
StatusProgressInput(
modelMessage = modelMessage,
- modelLoading = modelLoading,
+ modelInstallationState = modelInstallationState,
+ modelRuntimeState = modelRuntimeState,
modelDownloadAvailable = modelDownloadAvailable,
modelDownloadProgress = modelDownloadProgress,
- modelReady = modelReady,
outputSelected = outputSelected,
folderSelected = folderSelected,
pendingCount = pendingCount,
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/SpeechModelLifecycle.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/SpeechModelLifecycle.kt
new file mode 100644
index 0000000..f8888cd
--- /dev/null
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/SpeechModelLifecycle.kt
@@ -0,0 +1,21 @@
+package me.maxistar.voiceinbox.core
+
+enum class SpeechModelInstallationState {
+ NOT_INSTALLED,
+ INSTALLING,
+ INSTALLED,
+ INVALID,
+}
+
+enum class SpeechModelRuntimeState {
+ UNLOADED,
+ LOADING,
+ LOADED,
+ FAILED,
+}
+
+val SpeechModelInstallationState.isAvailable: Boolean
+ get() = this == SpeechModelInstallationState.INSTALLED
+
+val SpeechModelRuntimeState.isPreparing: Boolean
+ get() = this == SpeechModelRuntimeState.LOADING
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRules.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRules.kt
index 1b587fc..472be6d 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRules.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRules.kt
@@ -16,7 +16,7 @@ data class StartupProcessingInput(
val failedCount: Int = 0,
val folderReady: Boolean = false,
val outputReady: Boolean = false,
- val modelReady: Boolean = false,
+ val modelInstallationState: SpeechModelInstallationState = SpeechModelInstallationState.NOT_INSTALLED,
val transcriptionStateKnown: Boolean = false,
val transcriptionActive: Boolean = false,
)
@@ -55,7 +55,7 @@ object StartupProcessingRules {
if (input.policy == StartupProcessingPolicy.LEAVE_QUEUED) {
return StartupProcessingDecision.Finish(StartupProcessingFinishReason.LEAVE_QUEUED)
}
- if (!input.folderReady || !input.outputReady || !input.modelReady || input.transcriptionActive) {
+ if (!input.folderReady || !input.outputReady || !input.modelInstallationState.isAvailable || input.transcriptionActive) {
return StartupProcessingDecision.Wait
}
return when (input.policy) {
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRules.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRules.kt
index 3366394..988989c 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRules.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRules.kt
@@ -12,10 +12,10 @@ data class CatalogControlState(
data class StatusProgressInput(
val modelMessage: String,
- val modelLoading: Boolean,
+ val modelInstallationState: SpeechModelInstallationState,
+ val modelRuntimeState: SpeechModelRuntimeState,
val modelDownloadAvailable: Boolean,
val modelDownloadProgress: Int?,
- val modelReady: Boolean,
val outputSelected: Boolean,
val folderSelected: Boolean,
val pendingCount: Int,
@@ -77,7 +77,7 @@ object TranscriptionUiRules {
}
fun catalogControls(
- modelReady: Boolean,
+ modelInstallationState: SpeechModelInstallationState,
outputSelected: Boolean,
folderSelected: Boolean,
pendingCount: Int,
@@ -87,13 +87,14 @@ object TranscriptionUiRules {
val workReady = transcriptionState == TranscriptionObservationState.IDLE ||
transcriptionState == TranscriptionObservationState.FINISHED
val idle = workReady && !scanning
- val prerequisites = modelReady && outputSelected && folderSelected && idle
+ val modelAvailable = modelInstallationState.isAvailable
+ val prerequisites = modelAvailable && outputSelected && folderSelected && idle
return CatalogControlState(
- outputEnabled = modelReady && idle,
- folderEnabled = modelReady && idle,
+ outputEnabled = modelAvailable && idle,
+ folderEnabled = modelAvailable && idle,
outputSetupVisible = !outputSelected,
folderSetupVisible = !folderSelected,
- refreshEnabled = modelReady && folderSelected && idle,
+ refreshEnabled = modelAvailable && folderSelected && idle,
transcribeAllEnabled = prerequisites && pendingCount > 0,
retryEnabled = prerequisites,
)
@@ -126,7 +127,7 @@ object TranscriptionUiRules {
fun statusProgressBlock(input: StatusProgressInput): StatusProgressBlockState =
when {
input.transcriptionState == TranscriptionObservationState.ACTIVE -> activeTranscription(input)
- input.modelLoading -> StatusProgressBlockState(
+ input.modelInstallationState == SpeechModelInstallationState.INSTALLING -> StatusProgressBlockState(
title = input.modelMessage,
progressVisible = true,
progressIndeterminate = input.modelDownloadProgress == null,
@@ -142,7 +143,7 @@ object TranscriptionUiRules {
progressVisible = true,
progressIndeterminate = true,
)
- !input.modelReady -> StatusProgressBlockState(
+ !input.modelInstallationState.isAvailable -> StatusProgressBlockState(
title = input.modelMessage,
downloadVisible = input.modelDownloadAvailable,
)
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/MainScreenStateControllerTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/MainScreenStateControllerTest.kt
index d1c1d97..9ac38a3 100644
--- a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/MainScreenStateControllerTest.kt
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/MainScreenStateControllerTest.kt
@@ -48,6 +48,29 @@ class MainScreenStateControllerTest {
)
}
+ @Test
+ fun installedModelDoesNotRequireLoadedRuntimeForActions() {
+ val unloaded = state(
+ input(
+ outputSelected = true,
+ folderSelected = true,
+ pendingCount = 1,
+ modelRuntimeState = SpeechModelRuntimeState.UNLOADED,
+ ),
+ )
+ val retryableFailure = state(
+ input(
+ outputSelected = true,
+ folderSelected = true,
+ pendingCount = 1,
+ modelRuntimeState = SpeechModelRuntimeState.FAILED,
+ ),
+ )
+
+ assertTrue(unloaded.controls.transcribeAllEnabled)
+ assertTrue(retryableFailure.controls.transcribeAllEnabled)
+ }
+
@Test
fun controlsAreDisabledDuringActiveWorkOrQueuedScan() {
assertFalse(
@@ -256,6 +279,7 @@ class MainScreenStateControllerTest {
modelDownloadAvailable: Boolean = false,
modelDownloadProgress: Int? = null,
modelReady: Boolean = true,
+ modelRuntimeState: SpeechModelRuntimeState = SpeechModelRuntimeState.UNLOADED,
outputSelected: Boolean = false,
folderSelected: Boolean = false,
pendingCount: Int = 0,
@@ -281,10 +305,14 @@ class MainScreenStateControllerTest {
rows: List = emptyList(),
) = MainScreenInput(
modelMessage = modelMessage,
- modelLoading = modelLoading,
+ modelInstallationState = when {
+ modelLoading -> SpeechModelInstallationState.INSTALLING
+ modelReady -> SpeechModelInstallationState.INSTALLED
+ else -> SpeechModelInstallationState.NOT_INSTALLED
+ },
+ modelRuntimeState = modelRuntimeState,
modelDownloadAvailable = modelDownloadAvailable,
modelDownloadProgress = modelDownloadProgress,
- modelReady = modelReady,
outputSelected = outputSelected,
folderSelected = folderSelected,
pendingCount = pendingCount,
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRulesTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRulesTest.kt
index e25534e..cbe133b 100644
--- a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRulesTest.kt
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/StartupProcessingRulesTest.kt
@@ -36,7 +36,7 @@ class StartupProcessingRulesTest {
ready.copy(catalogReady = false),
ready.copy(folderReady = false),
ready.copy(outputReady = false),
- ready.copy(modelReady = false),
+ ready.copy(modelInstallationState = SpeechModelInstallationState.NOT_INSTALLED),
ready.copy(transcriptionStateKnown = false),
)
@@ -102,7 +102,7 @@ class StartupProcessingRulesTest {
failedCount = failedCount,
folderReady = true,
outputReady = true,
- modelReady = true,
+ modelInstallationState = SpeechModelInstallationState.INSTALLED,
transcriptionStateKnown = true,
transcriptionActive = transcriptionActive,
)
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRulesTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRulesTest.kt
index 8991c3e..89d6a59 100644
--- a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRulesTest.kt
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRulesTest.kt
@@ -44,7 +44,7 @@ class TranscriptionUiRulesTest {
assertEquals(
CatalogControlState(false, false, false, false, false, false, false),
TranscriptionUiRules.catalogControls(
- modelReady = true,
+ modelInstallationState = SpeechModelInstallationState.INSTALLED,
outputSelected = true,
folderSelected = true,
pendingCount = 2,
@@ -55,7 +55,7 @@ class TranscriptionUiRulesTest {
assertEquals(
CatalogControlState(false, false, false, false, false, false, false),
TranscriptionUiRules.catalogControls(
- modelReady = true,
+ modelInstallationState = SpeechModelInstallationState.INSTALLED,
outputSelected = true,
folderSelected = true,
pendingCount = 2,
@@ -70,7 +70,7 @@ class TranscriptionUiRulesTest {
assertEquals(
CatalogControlState(false, false, false, false, false, false, false),
TranscriptionUiRules.catalogControls(
- modelReady = true,
+ modelInstallationState = SpeechModelInstallationState.INSTALLED,
outputSelected = true,
folderSelected = true,
pendingCount = 2,
@@ -432,7 +432,7 @@ class TranscriptionUiRulesTest {
pending: Int = 0,
active: Boolean = false,
) = TranscriptionUiRules.catalogControls(
- modelReady,
+ if (modelReady) SpeechModelInstallationState.INSTALLED else SpeechModelInstallationState.NOT_INSTALLED,
output,
folder,
pending,
@@ -466,10 +466,14 @@ class TranscriptionUiRulesTest {
) = TranscriptionUiRules.statusProgressBlock(
StatusProgressInput(
modelMessage = modelMessage,
- modelLoading = modelLoading,
+ modelInstallationState = when {
+ modelLoading -> SpeechModelInstallationState.INSTALLING
+ modelReady -> SpeechModelInstallationState.INSTALLED
+ else -> SpeechModelInstallationState.NOT_INSTALLED
+ },
+ modelRuntimeState = SpeechModelRuntimeState.UNLOADED,
modelDownloadAvailable = modelDownloadAvailable,
modelDownloadProgress = modelDownloadProgress,
- modelReady = modelReady,
outputSelected = output,
folderSelected = folder,
pendingCount = pending,
diff --git a/src/engine.rs b/src/engine.rs
index 69cde49..4a8e044 100644
--- a/src/engine.rs
+++ b/src/engine.rs
@@ -33,16 +33,77 @@ pub fn is_engine_loaded() -> bool {
}
pub fn configure_model_directory(path: PathBuf) {
+ let path = std::fs::canonicalize(&path).unwrap_or(path);
+ let unchanged = MODEL_DIRECTORY.lock().unwrap().as_ref() == Some(&path);
+ if unchanged {
+ return;
+ }
+ invalidate_loaded_model();
*MODEL_DIRECTORY.lock().unwrap() = Some(path);
+}
+
+pub fn invalidate_loaded_model() {
*GLOBAL_ENGINE.lock().unwrap() = None;
*LOAD_STATE.0.lock().unwrap() = LoadState::Idle;
+ LOAD_STATE.1.notify_all();
}
pub fn ensure_loaded_without_callback() -> Result<(), String> {
+ ensure_loaded_with_status(|_| {})
+}
+
+fn ensure_loaded_with_status(mut status: impl FnMut(&str)) -> Result<(), String> {
+ if is_engine_loaded() {
+ status("Ready");
+ return Ok(());
+ }
+
+ let (lock, cvar) = &*LOAD_STATE;
+ let mut state = lock.lock().unwrap();
if is_engine_loaded() {
+ status("Ready");
return Ok(());
}
+ if *state == LoadState::Loading {
+ status("Waiting for model...");
+ while *state == LoadState::Loading {
+ state = cvar.wait(state).unwrap();
+ }
+ return match &*state {
+ LoadState::Done if is_engine_loaded() => {
+ status("Ready");
+ Ok(())
+ }
+ LoadState::Failed(message) => {
+ status(&format!("Error: {message}"));
+ Err(message.clone())
+ }
+ _ => Err("Model loading was interrupted".to_string()),
+ };
+ }
+
+ *state = LoadState::Loading;
+ drop(state);
+ status("Loading model...");
+
+ let result = load_configured_engine();
+ let mut state = lock.lock().unwrap();
+ match &result {
+ Ok(()) => {
+ *state = LoadState::Done;
+ status("Ready");
+ }
+ Err(message) => {
+ *state = LoadState::Failed(message.clone());
+ status(&format!("Error: {message}"));
+ }
+ }
+ cvar.notify_all();
+ result
+}
+
+fn load_configured_engine() -> Result<(), String> {
let path = MODEL_DIRECTORY
.lock()
.unwrap()
@@ -56,7 +117,6 @@ pub fn ensure_loaded_without_callback() -> Result<(), String> {
)
.map_err(|error| format!("Model error: {error}"))?;
*GLOBAL_ENGINE.lock().unwrap() = Some(Arc::new(Mutex::new(engine)));
- *LOAD_STATE.0.lock().unwrap() = LoadState::Done;
Ok(())
}
@@ -74,55 +134,7 @@ fn notify_status(env: &mut JNIEnv, obj: &JObject, msg: &str) {
#[cfg(target_os = "android")]
pub fn ensure_loaded(env: &mut JNIEnv, context: &JObject) -> Result<(), String> {
- if is_engine_loaded() {
- notify_status(env, context, "Ready");
- return Ok(());
- }
-
- let (lock, cvar) = &*LOAD_STATE;
- let mut state = lock.lock().unwrap();
-
- if is_engine_loaded() {
- notify_status(env, context, "Ready");
- return Ok(());
- }
-
- match &*state {
- LoadState::Loading => {
- notify_status(env, context, "Waiting for model...");
- while *state == LoadState::Loading {
- state = cvar.wait(state).unwrap();
- }
- drop(state);
-
- if is_engine_loaded() {
- notify_status(env, context, "Ready");
- Ok(())
- } else {
- let msg = "Model failed to load".to_string();
- notify_status(env, context, &format!("Error: {}", msg));
- Err(msg)
- }
- }
- LoadState::Done => {
- notify_status(env, context, "Ready");
- Ok(())
- }
- LoadState::Idle | LoadState::Failed(_) => {
- *state = LoadState::Loading;
- drop(state);
-
- let result = do_load(env, context);
-
- let mut state = lock.lock().unwrap();
- match &result {
- Ok(()) => *state = LoadState::Done,
- Err(msg) => *state = LoadState::Failed(msg.clone()),
- }
- cvar.notify_all();
- result
- }
- }
+ ensure_loaded_with_status(|message| notify_status(env, context, message))
}
#[cfg(target_os = "android")]
@@ -130,98 +142,44 @@ pub fn ensure_loaded_from_thread(
jvm: &Arc,
target_ref: &GlobalRef,
) -> Result<(), String> {
- if is_engine_loaded() {
+ ensure_loaded_with_status(|message| {
if let Ok(mut env) = jvm.attach_current_thread() {
- notify_status(&mut env, target_ref.as_obj(), "Ready");
+ notify_status(&mut env, target_ref.as_obj(), message);
}
- return Ok(());
- }
+ })
+}
- let (lock, cvar) = &*LOAD_STATE;
- let mut state = lock.lock().unwrap();
+#[cfg(test)]
+mod tests {
+ use super::*;
- if is_engine_loaded() {
- if let Ok(mut env) = jvm.attach_current_thread() {
- notify_status(&mut env, target_ref.as_obj(), "Ready");
- }
- return Ok(());
- }
+ static TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(()));
- match &*state {
- LoadState::Loading => {
- if let Ok(mut env) = jvm.attach_current_thread() {
- notify_status(&mut env, target_ref.as_obj(), "Waiting for model...");
- }
- while *state == LoadState::Loading {
- state = cvar.wait(state).unwrap();
- }
- drop(state);
+ #[test]
+ fn configuring_same_directory_preserves_load_state() {
+ let _guard = TEST_LOCK.lock().unwrap();
+ invalidate_loaded_model();
+ configure_model_directory(PathBuf::from("test-model"));
+ *LOAD_STATE.0.lock().unwrap() = LoadState::Done;
- if is_engine_loaded() {
- if let Ok(mut env) = jvm.attach_current_thread() {
- notify_status(&mut env, target_ref.as_obj(), "Ready");
- }
- Ok(())
- } else {
- let msg = "Model failed to load".to_string();
- if let Ok(mut env) = jvm.attach_current_thread() {
- notify_status(&mut env, target_ref.as_obj(), &format!("Error: {}", msg));
- }
- Err(msg)
- }
- }
- LoadState::Done => {
- if let Ok(mut env) = jvm.attach_current_thread() {
- notify_status(&mut env, target_ref.as_obj(), "Ready");
- }
- Ok(())
- }
- LoadState::Idle | LoadState::Failed(_) => {
- *state = LoadState::Loading;
- drop(state);
-
- let result = if let Ok(mut env) = jvm.attach_current_thread() {
- let obj = target_ref.as_obj();
- do_load(&mut env, obj)
- } else {
- Err("Failed to attach JNI thread".to_string())
- };
-
- let mut state = lock.lock().unwrap();
- match &result {
- Ok(()) => *state = LoadState::Done,
- Err(msg) => *state = LoadState::Failed(msg.clone()),
- }
- cvar.notify_all();
- result
- }
+ configure_model_directory(PathBuf::from("test-model"));
+
+ assert_eq!(*LOAD_STATE.0.lock().unwrap(), LoadState::Done);
+ invalidate_loaded_model();
}
-}
-#[cfg(target_os = "android")]
-fn do_load(env: &mut JNIEnv, context: &JObject) -> Result<(), String> {
- let path = MODEL_DIRECTORY
- .lock()
- .unwrap()
- .clone()
- .ok_or_else(|| "Model directory was not configured".to_string())?;
+ #[test]
+ fn changing_directory_and_explicit_invalidation_reset_state() {
+ let _guard = TEST_LOCK.lock().unwrap();
+ invalidate_loaded_model();
+ configure_model_directory(PathBuf::from("first-model"));
+ *LOAD_STATE.0.lock().unwrap() = LoadState::Done;
- notify_status(env, context, "Loading model...");
-
- let mut eng = ParakeetEngine::new();
- match eng.load_model_with_params(
- &path,
- transcribe_rs::engines::parakeet::ParakeetModelParams::int8(),
- ) {
- Ok(_) => {
- *GLOBAL_ENGINE.lock().unwrap() = Some(Arc::new(Mutex::new(eng)));
- notify_status(env, context, "Ready");
- Ok(())
- }
- Err(e) => {
- let msg = format!("Model error: {}", e);
- notify_status(env, context, &format!("Error: {}", msg));
- Err(msg)
- }
+ configure_model_directory(PathBuf::from("second-model"));
+ assert_eq!(*LOAD_STATE.0.lock().unwrap(), LoadState::Idle);
+
+ *LOAD_STATE.0.lock().unwrap() = LoadState::Failed("failed".to_string());
+ invalidate_loaded_model();
+ assert_eq!(*LOAD_STATE.0.lock().unwrap(), LoadState::Idle);
}
}
diff --git a/src/lib.rs b/src/lib.rs
index 82ffa56..9725736 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -20,7 +20,7 @@ use jni::sys::{jboolean, jstring, JNI_FALSE, JNI_TRUE};
use jni::JNIEnv;
#[cfg(target_os = "android")]
use std::path::PathBuf;
-#[cfg(any(not(target_os = "ios"), all(target_os = "ios", feature = "ios-onnx")))]
+#[cfg(any(target_os = "android", all(target_os = "ios", feature = "ios-onnx")))]
use transcribe_rs::TranscriptionEngine;
#[cfg(any(not(target_os = "ios"), all(target_os = "ios", feature = "ios-onnx")))]
@@ -74,6 +74,15 @@ pub unsafe extern "system" fn Java_me_maxistar_voiceinbox_NativeTranscriptionBri
}
}
+#[cfg(target_os = "android")]
+#[no_mangle]
+pub extern "system" fn Java_me_maxistar_voiceinbox_NativeTranscriptionBridge_reset(
+ _env: JNIEnv,
+ _class: JClass,
+) {
+ engine::invalidate_loaded_model();
+}
+
#[cfg(target_os = "android")]
#[no_mangle]
pub unsafe extern "system" fn Java_me_maxistar_voiceinbox_NativeTranscriptionBridge_transcribeChunkJson(
@@ -86,7 +95,10 @@ pub unsafe extern "system" fn Java_me_maxistar_voiceinbox_NativeTranscriptionBri
Err(_) => return std::ptr::null_mut(),
};
let mut buffer = vec![0.0f32; length];
- if env.get_float_array_region(&samples, 0, &mut buffer).is_err() {
+ if env
+ .get_float_array_region(&samples, 0, &mut buffer)
+ .is_err()
+ {
return std::ptr::null_mut();
}
@@ -226,6 +238,16 @@ pub unsafe extern "C" fn voiceinbox_transcription_initialize(
true
}
+#[cfg(all(target_os = "ios", feature = "ios-onnx"))]
+#[no_mangle]
+pub extern "C" fn voiceinbox_transcription_reset() {
+ engine::invalidate_loaded_model();
+}
+
+#[cfg(all(target_os = "ios", not(feature = "ios-onnx")))]
+#[no_mangle]
+pub extern "C" fn voiceinbox_transcription_reset() {}
+
#[cfg(all(target_os = "ios", not(feature = "ios-onnx")))]
#[no_mangle]
pub unsafe extern "C" fn voiceinbox_transcription_initialize(
From 4ee86e0884aaa914245461205a8a32245a1361ce Mon Sep 17 00:00:00 2001
From: Max Starikov
Date: Thu, 16 Jul 2026 07:50:42 +0200
Subject: [PATCH 05/10] add list style ui
---
iosApp/VoiceInbox/ContentView.swift | 546 ++++++++----------
iosApp/VoiceInbox/IosAudioImportStore.swift | 47 +-
.../VoiceInbox/IosMainScreenShellState.swift | 335 +++++------
...IosSingleFileTranscriptionController.swift | 28 +-
iosApp/VoiceInbox/IosSpeechModelStore.swift | 4 +
.../DeferredSpeechModelLoadingTests.swift | 113 ++++
.../maxistar/voiceinbox/core/AudioCatalog.kt | 4 +
.../core/BatchTranscriptionUseCase.kt | 9 +-
.../core/SqlDelightAudioCatalogRepository.kt | 6 +-
.../core/TaskListPresentationController.kt | 431 ++++++++++++++
.../me/maxistar/voiceinbox/db/AudioFiles.sq | 2 +-
.../core/BatchTranscriptionUseCaseTest.kt | 9 +
.../TaskListPresentationControllerTest.kt | 199 +++++++
13 files changed, 1236 insertions(+), 497 deletions(-)
create mode 100644 shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt
create mode 100644 shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt
diff --git a/iosApp/VoiceInbox/ContentView.swift b/iosApp/VoiceInbox/ContentView.swift
index 9ccecd2..e1d0826 100644
--- a/iosApp/VoiceInbox/ContentView.swift
+++ b/iosApp/VoiceInbox/ContentView.swift
@@ -25,329 +25,100 @@ struct ContentView: View {
let transcriptionBackendConfigured = transcriber.backendConfigured
let speechModelReady = speechModelStore.isReady
let outputReady = outputStore.isReady
- let storageSetupComplete = outputReady && !importStore.inboxFolderStatus.needsSelection
let transcriptionReady = transcriptionBackendConfigured && speechModelReady && !speechModelStore.isBusy && outputReady
- let modelStatusMessage = iOSModelStatusMessage(
- transcriptionBackendConfigured: transcriptionBackendConfigured,
- speechModelReady: speechModelReady
- )
let modelDownloadAvailable = !speechModelReady && !speechModelStore.isBusy
- let modelSetupVisible = !transcriptionBackendConfigured || !speechModelReady || speechModelStore.isBusy
let screen = shellState.screen(
selection: selectedTab,
importedFiles: importStore.files,
- runtimeReady: transcriptionBackendConfigured,
- modelReady: speechModelReady,
+ modelStatus: speechModelStore.status,
+ modelMessage: speechModelStore.message,
modelInstalling: speechModelStore.isInstalling,
modelDownloadAvailable: modelDownloadAvailable,
modelDownloadProgress: speechModelStore.downloadProgress?.percent,
- modelMessage: modelStatusMessage,
- outputReady: outputReady,
+ modelCanCancel: speechModelStore.canCancelDownload,
+ outputStatus: outputStore.status,
+ folderStatus: importStore.inboxFolderStatus,
+ folderScanning: importStore.isScanningFolder,
activePreviewEntryId: previewPlayer.playingFileId,
previewState: previewPlayer.playingFileId == nil ? PreviewPlaybackState.idle : PreviewPlaybackState.playing,
- transcription: transcriber.state
+ transcription: transcriber.state,
+ preparationOwnerEntryId: transcriber.preparationOwnerFileId,
+ prerequisiteError: transcriber.prerequisiteError,
+ actionsEnabled: transcriptionReady && !transcriber.isActive && !importStore.isScanningFolder
)
NavigationStack {
List {
-
- Section("Status") {
- VStack(alignment: .leading, spacing: 8) {
- Text(screen.state.status.title)
- .font(.headline)
- if let detail = screen.state.status.detail {
- Text(detail)
- .foregroundStyle(.secondary)
- }
- if let meta = screen.state.status.meta {
- Text(meta)
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
- }
-
+ Section {
Picker("Catalog", selection: $selectedTab) {
ForEach(IosShellCatalogSelection.allCases) { tab in
Text(tab.title).tag(tab)
}
}
.pickerStyle(.segmented)
-
- if screen.state.list.transcribeAllVisible {
- Button {
- guard let outputDocument = outputStore.currentDocument() else {
- outputStore.refreshAccess()
- return
- }
- previewPlayer.stop()
- transcriber.transcribeAll(
- modelDirectory: speechModelStore.modelDirectory,
- modelStore: speechModelStore,
- outputDocument: outputDocument,
- store: importStore,
- onFinished: {
- selectedTab = .processed
- }
- )
- } label: {
- Label("Transcribe All", systemImage: "text.badge.checkmark")
- }
- .disabled(!screen.state.list.transcribeAllEnabled)
- }
-
- Button {
- showingImporter = true
- } label: {
- Label("Import Audio Files", systemImage: "square.and.arrow.down")
- }
-
- if !outputReady {
- VStack(alignment: .leading, spacing: 6) {
- Text(outputStore.status.title)
- .font(.headline)
- Text(outputStore.status.message)
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
-
- Button {
- showingOutputPicker = true
- } label: {
- Label("Select Output File", systemImage: "doc.badge.plus")
- }
- }
-
- if importStore.inboxFolderStatus.needsSelection {
- VStack(alignment: .leading, spacing: 6) {
- Text(importStore.inboxFolderStatus.title)
- .font(.headline)
- if let message = importStore.inboxFolderStatus.message {
- Text(message)
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
- }
-
- Button {
- showingInboxFolderPicker = true
- } label: {
- Label("Select Audio Folder", systemImage: "folder")
- }
- .disabled(importStore.isScanningFolder)
- }
-
- if !importStore.inboxFolderStatus.needsSelection {
- Button {
- importStore.refreshInboxFolder()
- selectedTab = .new
- } label: {
- Label("Refresh Audio Folder", systemImage: "arrow.clockwise")
- }
- .disabled(importStore.isScanningFolder)
- }
-
- if importStore.isScanningFolder {
- ProgressView()
- }
+ .accessibilityIdentifier("task-list-filter")
}
Section {
- if screen.state.list.emptyVisible {
+ if let emptyMessage = screen.state.emptyMessage {
VStack(alignment: .leading, spacing: 8) {
- Text(screen.state.list.emptyMessage)
+ Text(emptyMessage)
.foregroundStyle(.secondary)
- Button {
- showingInboxFolderPicker = true
- } label: {
- Label("Select Audio Folder", systemImage: "folder")
- }
- Button {
- showingImporter = true
- } label: {
- Label("Import Audio Files", systemImage: "square.and.arrow.down")
- }
- }
- } else {
- ForEach(screen.rows) { row in
- VStack(alignment: .leading, spacing: 8) {
- HStack(alignment: .top) {
- VStack(alignment: .leading, spacing: 4) {
- Text(row.title)
- .font(.headline)
- Text(row.subtitle)
- .font(.subheadline)
- .foregroundStyle(.secondary)
- }
-
- Spacer()
-
- Text(row.badge)
- .font(.caption)
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(.thinMaterial)
- .clipShape(Capsule())
- }
-
- HStack {
- if let importedFile = importedFile(for: row) {
- Button {
- previewPlayer.toggle(
- fileId: importedFile.id,
- url: importStore.localURL(for: importedFile)
- )
- } label: {
- Label(
- previewPlayer.playingFileId == importedFile.id ? "Stop" : row.state.preview.label,
- systemImage: previewPlayer.playingFileId == importedFile.id ? "stop.fill" : "play.fill"
- )
- }
- .disabled(
- transcriber.isActive ||
- !row.state.preview.enabled
- )
-
- if importedFile.status == .pending {
- Button {
- guard let outputDocument = outputStore.currentDocument() else {
- outputStore.refreshAccess()
- return
- }
- transcriber.transcribe(
- file: importedFile,
- localURL: importStore.localURL(for: importedFile),
- modelDirectory: speechModelStore.modelDirectory,
- modelStore: speechModelStore,
- outputDocument: outputDocument,
- store: importStore,
- onSuccess: { transcript in
- shownTranscript = transcript
- selectedTab = .processed
- }
- )
- } label: {
- Label("Transcribe", systemImage: "text.badge.checkmark")
- }
- .disabled(
- transcriber.isActive ||
- !transcriptionReady
- )
- }
-
- if importedFile.status == .failed {
- Button {
- guard let outputDocument = outputStore.currentDocument() else {
- outputStore.refreshAccess()
- return
- }
- transcriber.retry(
- file: importedFile,
- localURL: importStore.localURL(for: importedFile),
- modelDirectory: speechModelStore.modelDirectory,
- modelStore: speechModelStore,
- outputDocument: outputDocument,
- store: importStore,
- onSuccess: { transcript in
- shownTranscript = transcript
- selectedTab = .processed
- }
- )
- } label: {
- Label("Retry", systemImage: "arrow.clockwise")
- }
- .disabled(
- transcriber.isActive ||
- !transcriptionReady
- )
- }
- }
-
- if row.state.showTextVisible {
- Button("Show Text") {
- shownTranscript = row.transcriptText
- }
- }
+ ForEach(Array(screen.state.emptyActions.enumerated()), id: \.offset) { _, action in
+ Button(action.label) {
+ perform(action: action, task: nil, screen: screen)
}
- .buttonStyle(.bordered)
- .font(.caption)
+ .disabled(!action.enabled)
}
- .padding(.vertical, 4)
}
- }
- } header: {
- if !storageSetupComplete {
- Text(selectedTab.title)
- }
- }
-
- if let importMessage = importStore.importMessage {
- Section("Import result") {
- Text(importMessage)
- .foregroundStyle(.secondary)
- }
- }
-
- if let playbackError = previewPlayer.errorMessage {
- Section("Playback") {
- Text(playbackError)
- .foregroundStyle(.secondary)
- Button("Dismiss") {
- previewPlayer.clearError()
- }
- }
- }
-
- if modelSetupVisible {
- Section("Transcription Setup") {
- if !transcriptionBackendConfigured {
- Text(IosSingleFileTranscriptionController.backendUnavailableMessage)
- .foregroundStyle(.secondary)
- }
- VStack(alignment: .leading, spacing: 6) {
- Text(speechModelStore.status.summary)
- if speechModelStore.isInstalling {
- if let progress = speechModelStore.downloadProgress {
- ProgressView(value: Double(progress.percent), total: 100)
- Text("\(progress.percent)% • \(formatBytes(progress.bytesDownloaded)) of \(formatBytes(progress.totalBytes))")
- .font(.footnote)
- .foregroundStyle(.secondary)
- } else {
- ProgressView()
- }
- }
- if let detail = speechModelStore.status.detail {
- Text(detail)
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
- if let modelMessage = speechModelStore.message {
- Text(modelMessage)
- .font(.footnote)
- .foregroundStyle(.secondary)
+ } else {
+ ForEach(screen.state.tasks.filter { $0 is SetupTaskPresentation }, id: \.stableId) { task in
+ TaskListRow(task: task) { action in
+ perform(action: action, task: task, screen: screen)
}
+ .id(task.stableId)
+ .accessibilityIdentifier("task-row-\(task.stableId)")
}
- if modelDownloadAvailable {
+ if screen.state.batchAction.visible {
Button {
- speechModelStore.downloadModel()
+ transcribeAll()
} label: {
- Label("Download Speech Model", systemImage: "arrow.down.circle")
+ Label(
+ "Transcribe All (\(screen.state.batchAction.eligibleCount))",
+ systemImage: "text.badge.checkmark"
+ )
}
- .buttonStyle(.borderedProminent)
+ .disabled(!screen.state.batchAction.enabled)
+ .accessibilityIdentifier("transcribe-all")
}
- if !speechModelReady {
- Button {
- showingModelImporter = true
- } label: {
- Label("Install Speech Model Manually", systemImage: "square.and.arrow.down")
+ ForEach(screen.state.tasks.filter { $0 is AudioTaskPresentation }, id: \.stableId) { task in
+ TaskListRow(task: task) { action in
+ perform(action: action, task: task, screen: screen)
}
- .disabled(speechModelStore.isBusy)
+ .id(task.stableId)
+ .accessibilityIdentifier("task-row-\(task.stableId)")
}
-
}
}
+ Section {
+ Button {
+ showingImporter = true
+ } label: {
+ Label("Import Audio Files", systemImage: "square.and.arrow.down")
+ }
+ if !importStore.inboxFolderStatus.needsSelection {
+ Button {
+ importStore.refreshInboxFolder()
+ selectedTab = .new
+ } label: {
+ Label("Refresh Audio Folder", systemImage: "arrow.clockwise")
+ }
+ .disabled(importStore.isScanningFolder)
+ }
+ }
}
.navigationTitle("Voice Inbox")
.toolbar {
@@ -443,6 +214,13 @@ struct ContentView: View {
}
)
}
+ .alert(item: globalMessageBinding) { message in
+ Alert(
+ title: Text(message.title),
+ message: Text(message.text),
+ dismissButton: .default(Text("OK"))
+ )
+ }
}
}
@@ -457,9 +235,111 @@ struct ContentView: View {
)
}
- private func importedFile(for row: IosShellAudioRow) -> IosImportedAudioFile? {
- guard row.imported else { return nil }
- return importStore.files.first { $0.id == row.id }
+ private var globalMessageBinding: Binding {
+ Binding(
+ get: {
+ if let error = previewPlayer.errorMessage {
+ return IosGlobalMessage(title: "Playback", text: error)
+ }
+ if let message = importStore.importMessage {
+ return IosGlobalMessage(title: "Voice Inbox", text: message)
+ }
+ return nil
+ },
+ set: { value in
+ guard value == nil else { return }
+ previewPlayer.clearError()
+ importStore.importMessage = nil
+ }
+ )
+ }
+
+ private func perform(
+ action: TaskActionPresentation,
+ task: TaskPresentation?,
+ screen: IosTaskListScreen
+ ) {
+ guard action.enabled, let route = IosTaskActionRouter.route(action.kind) else { return }
+ switch route {
+ case .modelDownload:
+ speechModelStore.downloadModel()
+ case .modelImport:
+ showingModelImporter = true
+ case .modelCancel:
+ speechModelStore.cancelDownload()
+ case .outputSelection:
+ showingOutputPicker = true
+ case .folderSelection:
+ showingInboxFolderPicker = true
+ case .folderRefresh:
+ importStore.refreshInboxFolder()
+ selectedTab = .new
+ case .audioImport:
+ showingImporter = true
+ case .transcribe, .retry, .play, .stop, .showText:
+ guard let audioTask = task as? AudioTaskPresentation,
+ let file = screen.filesById[audioTask.entryId] else { return }
+ performAudio(action: action.kind, file: file)
+ }
+ }
+
+ private func performAudio(action: TaskActionKind, file: IosImportedAudioFile) {
+ switch action {
+ case .play:
+ previewPlayer.toggle(fileId: file.id, url: importStore.localURL(for: file))
+ case .stop:
+ previewPlayer.stop()
+ case .showText:
+ shownTranscript = file.transcriptText
+ case .transcribe, .retryTranscription:
+ guard let outputDocument = outputStore.currentDocument() else {
+ outputStore.refreshAccess()
+ return
+ }
+ previewPlayer.stop()
+ let onSuccess: (String) -> Void = { transcript in
+ shownTranscript = transcript
+ selectedTab = .processed
+ }
+ if action == .retryTranscription {
+ transcriber.retry(
+ file: file,
+ localURL: importStore.localURL(for: file),
+ modelDirectory: speechModelStore.modelDirectory,
+ modelStore: speechModelStore,
+ outputDocument: outputDocument,
+ store: importStore,
+ onSuccess: onSuccess
+ )
+ } else {
+ transcriber.transcribe(
+ file: file,
+ localURL: importStore.localURL(for: file),
+ modelDirectory: speechModelStore.modelDirectory,
+ modelStore: speechModelStore,
+ outputDocument: outputDocument,
+ store: importStore,
+ onSuccess: onSuccess
+ )
+ }
+ default:
+ break
+ }
+ }
+
+ private func transcribeAll() {
+ guard let outputDocument = outputStore.currentDocument() else {
+ outputStore.refreshAccess()
+ return
+ }
+ previewPlayer.stop()
+ transcriber.transcribeAll(
+ modelDirectory: speechModelStore.modelDirectory,
+ modelStore: speechModelStore,
+ outputDocument: outputDocument,
+ store: importStore,
+ onFinished: { selectedTab = .processed }
+ )
}
private func refreshStartupSources() {
@@ -530,31 +410,87 @@ struct ContentView: View {
)
}
- private func iOSModelStatusMessage(
- transcriptionBackendConfigured: Bool,
- speechModelReady: Bool
- ) -> String {
- if !transcriptionBackendConfigured {
- return IosSingleFileTranscriptionController.backendUnavailableMessage
- }
- if let progress = speechModelStore.downloadProgress {
- return progress.message
+}
+
+private struct TaskListRow: View {
+ let task: TaskPresentation
+ let onAction: (TaskActionPresentation) -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .top) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(task.title)
+ .font(.headline)
+ if let detail = task.detail, !detail.isEmpty {
+ Text(detail)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ Spacer()
+ Text(task.badge)
+ .font(.caption)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(.thinMaterial)
+ .clipShape(Capsule())
+ }
+
+ if let error = task.errorMessage, !error.isEmpty {
+ Text(error)
+ .font(.footnote)
+ .foregroundStyle(.red)
+ }
+
+ if let progress = task.progress {
+ VStack(alignment: .leading, spacing: 4) {
+ if let percent = progress.percent?.int32Value {
+ ProgressView(value: Double(percent), total: 100)
+ .accessibilityIdentifier("task-progress-\(task.stableId)")
+ } else {
+ ProgressView()
+ .accessibilityIdentifier("task-progress-\(task.stableId)")
+ }
+ Text(progressLabel(progress))
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ if !task.actions.isEmpty {
+ HStack {
+ ForEach(Array(task.actions.enumerated()), id: \.offset) { _, action in
+ Button(action.label) { onAction(action) }
+ .disabled(!action.enabled)
+ .accessibilityIdentifier("task-action-\(task.stableId)-\(action.kind.name.lowercased())")
+ }
+ }
+ .buttonStyle(.bordered)
+ .font(.caption)
+ }
}
- if speechModelStore.isInstalling {
- return "Installing speech model..."
+ .padding(.vertical, 4)
+ }
+
+ private func progressLabel(_ progress: TaskProgressPresentation) -> String {
+ var parts = [progress.phase]
+ if let completed = progress.completedFiles?.int32Value,
+ let total = progress.totalFiles?.int32Value,
+ total > 0 {
+ parts.append("\(completed) / \(total) files")
}
- if !speechModelReady {
- return speechModelStore.status.summary
+ if let failed = progress.failedFiles?.int32Value, failed > 0 {
+ parts.append("\(failed) failed")
}
- return "Ready"
+ return parts.joined(separator: " • ")
}
+}
- private func formatBytes(_ bytes: Int64) -> String {
- let formatter = ByteCountFormatter()
- formatter.allowedUnits = [.useMB, .useGB]
- formatter.countStyle = .file
- return formatter.string(fromByteCount: bytes)
- }
+private struct IosGlobalMessage: Identifiable {
+ let id = UUID()
+ let title: String
+ let text: String
}
private struct IosDisplayedTranscript: Identifiable {
diff --git a/iosApp/VoiceInbox/IosAudioImportStore.swift b/iosApp/VoiceInbox/IosAudioImportStore.swift
index e7fa3e1..f1d6409 100644
--- a/iosApp/VoiceInbox/IosAudioImportStore.swift
+++ b/iosApp/VoiceInbox/IosAudioImportStore.swift
@@ -64,6 +64,7 @@ struct IosImportedAudioFile: Codable, Identifiable, Equatable {
var transcriptText: String?
var durationUs: Int64?
var lastError: String?
+ var processedAt: Date?
init(
id: Int64,
@@ -74,7 +75,8 @@ struct IosImportedAudioFile: Codable, Identifiable, Equatable {
status: IosImportedAudioStatus = .pending,
transcriptText: String? = nil,
durationUs: Int64? = nil,
- lastError: String? = nil
+ lastError: String? = nil,
+ processedAt: Date? = nil
) {
self.id = id
self.displayName = displayName
@@ -85,6 +87,7 @@ struct IosImportedAudioFile: Codable, Identifiable, Equatable {
self.transcriptText = transcriptText
self.durationUs = durationUs
self.lastError = lastError
+ self.processedAt = processedAt
}
init(from decoder: Decoder) throws {
@@ -98,6 +101,7 @@ struct IosImportedAudioFile: Codable, Identifiable, Equatable {
transcriptText = try container.decodeIfPresent(String.self, forKey: .transcriptText)
durationUs = try container.decodeIfPresent(Int64.self, forKey: .durationUs)
lastError = try container.decodeIfPresent(String.self, forKey: .lastError)
+ processedAt = try container.decodeIfPresent(Date.self, forKey: .processedAt)
}
var formattedSize: String {
@@ -128,12 +132,33 @@ struct IosAudioImportSummary {
if failed > 0 { parts.append("\(failed) failed") }
return parts.isEmpty ? "No files imported" : parts.joined(separator: ", ")
}
+
+ var alertMessage: String? {
+ failed > 0 ? message : nil
+ }
}
struct IosInboxFolderStatus {
let displayName: String?
let message: String?
let needsSelection: Bool
+ let hasError: Bool
+
+ init(displayName: String?, message: String?, needsSelection: Bool) {
+ self.init(
+ displayName: displayName,
+ message: message,
+ needsSelection: needsSelection,
+ hasError: false
+ )
+ }
+
+ init(displayName: String?, message: String?, needsSelection: Bool, hasError: Bool) {
+ self.displayName = displayName
+ self.message = message
+ self.needsSelection = needsSelection
+ self.hasError = hasError
+ }
var title: String {
displayName ?? "No audio folder selected"
@@ -171,7 +196,7 @@ final class IosAudioImportStore: ObservableObject {
func importFiles(from urls: [URL]) {
let summary = copySupportedFiles(from: urls)
load()
- importMessage = summary.message
+ importMessage = summary.alertMessage
}
@discardableResult
@@ -190,7 +215,7 @@ final class IosAudioImportStore: ObservableObject {
let summary = copySupportedSharedFiles(from: stagedFiles)
load()
- importMessage = summary.message
+ importMessage = summary.alertMessage
return summary
}
@@ -235,10 +260,13 @@ final class IosAudioImportStore: ObservableObject {
isScanningFolder = false
inboxFolderStatus = IosInboxFolderStatus(
displayName: folder.url.lastPathComponent,
- message: summary.message,
- needsSelection: false
+ message: summary.failed > 0
+ ? "Audio folder scan failed. Check folder access and try again."
+ : summary.message,
+ needsSelection: false,
+ hasError: summary.failed > 0
)
- importMessage = summary.message
+ importMessage = nil
}
func localURL(for file: IosImportedAudioFile) -> URL {
@@ -273,7 +301,7 @@ final class IosAudioImportStore: ObservableObject {
}
func markFailed(fileId: Int64, error: String) {
- catalog.markFailed(id: fileId, message: error)
+ catalog.markFailedAt(id: fileId, message: error, processedAtMillis: currentTimeMillis())
load()
}
@@ -577,7 +605,10 @@ final class IosAudioImportStore: ObservableObject {
status: IosImportedAudioStatus(sharedState: file.state),
transcriptText: file.transcriptText,
durationUs: file.durationUs?.int64Value,
- lastError: file.lastError
+ lastError: file.lastError,
+ processedAt: file.processedAtMillis.map {
+ Date(timeIntervalSince1970: Double($0.int64Value) / 1000)
+ }
)
}
diff --git a/iosApp/VoiceInbox/IosMainScreenShellState.swift b/iosApp/VoiceInbox/IosMainScreenShellState.swift
index 5d846da..5fab936 100644
--- a/iosApp/VoiceInbox/IosMainScreenShellState.swift
+++ b/iosApp/VoiceInbox/IosMainScreenShellState.swift
@@ -4,225 +4,204 @@ import Shared
enum IosShellCatalogSelection: String, CaseIterable, Identifiable {
case new
case processed
+ case all
var id: String { rawValue }
var title: String {
switch self {
- case .new:
- "New"
- case .processed:
- "Processed"
+ case .new: "New"
+ case .processed: "Processed"
+ case .all: "All"
}
}
- var sharedTab: MainScreenCatalogTab {
+ var sharedFilter: TaskListFilter {
switch self {
- case .new:
- MainScreenCatalogTab.theNew
- case .processed:
- MainScreenCatalogTab.processed
+ case .new: .theNew
+ case .processed: .processed
+ case .all: .all
}
}
}
-struct IosShellMainScreen {
- let state: MainScreenState
- let rows: [IosShellAudioRow]
+struct IosTaskListScreen {
+ let state: TaskListState
+ let filesById: [Int64: IosImportedAudioFile]
}
-struct IosShellAudioRow: Identifiable {
- let id: Int64
- let title: String
- let subtitle: String
- let badge: String
- let imported: Bool
- let status: IosImportedAudioStatus?
- let transcriptText: String?
- let lastError: String?
- let state: MainScreenRowState
+enum IosTaskActionRoute: Equatable {
+ case modelDownload
+ case modelImport
+ case modelCancel
+ case outputSelection
+ case folderSelection
+ case folderRefresh
+ case transcribe
+ case retry
+ case play
+ case stop
+ case showText
+ case audioImport
+}
+
+enum IosTaskActionRouter {
+ static func route(_ kind: TaskActionKind) -> IosTaskActionRoute? {
+ switch kind {
+ case .downloadModel, .retryModelDownload: .modelDownload
+ case .importModel: .modelImport
+ case .cancelModelDownload: .modelCancel
+ case .selectOutput: .outputSelection
+ case .selectFolder: .folderSelection
+ case .refreshFolder: .folderRefresh
+ case .transcribe: .transcribe
+ case .retryTranscription: .retry
+ case .play: .play
+ case .stop: .stop
+ case .showText: .showText
+ case .importAudio: .audioImport
+ default: nil
+ }
+ }
}
final class IosMainScreenShellState {
func screen(
selection: IosShellCatalogSelection,
importedFiles: [IosImportedAudioFile],
- runtimeReady: Bool,
- modelReady: Bool,
+ modelStatus: IosSpeechModelStatus,
+ modelMessage: String?,
modelInstalling: Bool,
modelDownloadAvailable: Bool,
modelDownloadProgress: Int?,
- modelMessage: String,
- outputReady: Bool,
+ modelCanCancel: Bool,
+ outputStatus: IosOutputDocumentStatus,
+ folderStatus: IosInboxFolderStatus,
+ folderScanning: Bool,
activePreviewEntryId: Int64?,
previewState: PreviewPlaybackState,
- transcription: IosSingleFileTranscriptionState? = nil
- ) -> IosShellMainScreen {
- let rowsForSelection = displayRows(for: selection, importedFiles: importedFiles)
- let rows = rowsForSelection.map { $0.input }
- let pendingCount = importedFiles.count { $0.status == .pending }
- let state = MainScreenStateController.shared.state(
- input: input(
- selectedTab: selection.sharedTab,
- pendingCount: pendingCount,
- displayedRowCount: Int32(rows.count),
- runtimeReady: runtimeReady,
- modelReady: modelReady,
- modelInstalling: modelInstalling,
- modelDownloadAvailable: modelDownloadAvailable,
- modelDownloadProgress: modelDownloadProgress,
- modelMessage: modelMessage,
- outputReady: outputReady,
- activePreviewEntryId: activePreviewEntryId,
- previewState: previewState,
- transcription: transcription,
- rows: rows
+ transcription: IosSingleFileTranscriptionState,
+ preparationOwnerEntryId: Int64?,
+ prerequisiteError: String?,
+ actionsEnabled: Bool
+ ) -> IosTaskListScreen {
+ let input = TaskListInput(
+ filter: selection.sharedFilter,
+ model: modelSnapshot(
+ status: modelStatus,
+ message: modelMessage,
+ installing: modelInstalling,
+ downloadAvailable: modelDownloadAvailable,
+ progress: modelDownloadProgress,
+ canCancel: modelCanCancel
+ ),
+ output: outputSnapshot(outputStatus),
+ folder: folderSnapshot(folderStatus, scanning: folderScanning),
+ audio: importedFiles.map { file in
+ AudioTaskSnapshot(
+ entryId: file.id,
+ title: file.displayName,
+ detail: subtitle(for: file),
+ state: file.status.sharedState,
+ importedAtMillis: Int64(file.importedAt.timeIntervalSince1970 * 1000),
+ terminalAtMillis: file.processedAt.map {
+ KotlinLong(longLong: Int64($0.timeIntervalSince1970 * 1000))
+ },
+ lastError: file.lastError,
+ hasTranscriptText: file.transcriptText?.isEmpty == false,
+ noSpeech: Self.isNoSpeech(file.lastError),
+ eligibleForTranscription: actionsEnabled
+ )
+ },
+ preview: PreviewTaskSnapshot(
+ activeEntryId: activePreviewEntryId.map(KotlinLong.init(longLong:)),
+ state: previewState
+ ),
+ transcription: TranscriptionTaskSnapshot(
+ active: transcription.active,
+ activeEntryId: transcription.fileId.map(KotlinLong.init(longLong:)),
+ preparationOwnerEntryId: preparationOwnerEntryId.map(KotlinLong.init(longLong:)),
+ phase: transcription.phase,
+ percent: transcription.progressPercent.map { KotlinInt(int: Int32($0)) },
+ processedUs: transcription.processedUs > 0 ? KotlinLong(longLong: transcription.processedUs) : nil,
+ durationUs: transcription.durationUs > 0 ? KotlinLong(longLong: transcription.durationUs) : nil,
+ completedFiles: transcription.totalFiles > 0 ? KotlinInt(int: transcription.completedFiles) : nil,
+ totalFiles: transcription.totalFiles > 0 ? KotlinInt(int: transcription.totalFiles) : nil,
+ failedFiles: transcription.failedFiles > 0 ? KotlinInt(int: transcription.failedFiles) : nil,
+ prerequisiteError: prerequisiteError
)
)
- let sharedRowsById = Dictionary(uniqueKeysWithValues: state.rows.map { ($0.entryId, $0) })
- let displayRows = rowsForSelection.compactMap { row -> IosShellAudioRow? in
- guard let rowState = sharedRowsById[row.input.entryId] else { return nil }
- return IosShellAudioRow(
- id: row.input.entryId,
- title: row.title,
- subtitle: row.subtitle,
- badge: row.badge,
- imported: row.imported,
- status: row.status,
- transcriptText: row.transcriptText,
- lastError: row.lastError,
- state: rowState
- )
+ return IosTaskListScreen(
+ state: TaskListPresentationController.shared.state(input: input),
+ filesById: Dictionary(uniqueKeysWithValues: importedFiles.map { ($0.id, $0) })
+ )
+ }
+
+ private func modelSnapshot(
+ status: IosSpeechModelStatus,
+ message: String?,
+ installing: Bool,
+ downloadAvailable: Bool,
+ progress: Int?,
+ canCancel: Bool
+ ) -> ModelSetupSnapshot {
+ let state: ModelSetupSnapshotState
+ if installing {
+ state = .installing
+ } else if status.isReady {
+ state = .ready
+ } else if status.installationState == .invalid {
+ state = .invalid
+ } else {
+ state = .required
}
- return IosShellMainScreen(
+ return ModelSetupSnapshot(
state: state,
- rows: displayRows
+ detail: status.detail ?? message,
+ progressPercent: progress.map { KotlinInt(int: Int32($0)) },
+ downloadAvailable: downloadAvailable,
+ canCancel: canCancel
)
}
- private func displayRows(for selection: IosShellCatalogSelection, importedFiles: [IosImportedAudioFile]) -> [DisplayRow] {
- switch selection {
- case .new:
- importedFiles.filter { $0.status == .pending || $0.status == .processing }.map { file in
- DisplayRow(
- input: MainScreenRowInput(
- entryId: file.id,
- state: file.status.sharedState,
- hasTranscriptText: file.transcriptText?.isEmpty == false
- ),
- title: file.displayName,
- subtitle: subtitle(for: file),
- badge: file.status.badge,
- imported: true,
- status: file.status,
- transcriptText: file.transcriptText,
- lastError: file.lastError
- )
- }
- case .processed:
- importedFiles.filter { $0.status == .processed || $0.status == .failed }.map { file in
- DisplayRow(
- input: MainScreenRowInput(
- entryId: file.id,
- state: file.status.sharedState,
- hasTranscriptText: file.transcriptText?.isEmpty == false
- ),
- title: file.displayName,
- subtitle: subtitle(for: file),
- badge: file.status.badge,
- imported: true,
- status: file.status,
- transcriptText: file.transcriptText,
- lastError: file.lastError
- )
- }
+ private func outputSnapshot(_ status: IosOutputDocumentStatus) -> OutputSetupSnapshot {
+ let state: OutputSetupSnapshotState = status.ready
+ ? .ready
+ : (status.displayName == nil ? .required : .invalid)
+ return OutputSetupSnapshot(state: state, detail: status.message)
+ }
+
+ private func folderSnapshot(
+ _ status: IosInboxFolderStatus,
+ scanning: Bool
+ ) -> FolderSetupSnapshot {
+ let state: FolderSetupSnapshotState
+ if scanning {
+ state = .scanning
+ } else if status.hasError {
+ state = .error
+ } else if status.needsSelection, status.displayName != nil {
+ state = .error
+ } else if status.needsSelection {
+ state = .unselected
+ } else {
+ state = .ready
}
+ return FolderSetupSnapshot(state: state, detail: status.message)
}
private func subtitle(for file: IosImportedAudioFile) -> String {
var parts = ["Imported", file.formattedSize]
- if let lastError = file.lastError, !lastError.isEmpty {
- parts.append(lastError)
- } else if let durationUs = file.durationUs, durationUs > 0 {
- parts.append(formatDuration(microseconds: durationUs))
+ if let durationUs = file.durationUs, durationUs > 0 {
+ let totalSeconds = durationUs / 1_000_000
+ parts.append("\(totalSeconds / 60):\(String(format: "%02d", totalSeconds % 60))")
}
return parts.joined(separator: " • ")
}
- private func formatDuration(microseconds: Int64) -> String {
- let totalSeconds = microseconds / 1_000_000
- let minutes = totalSeconds / 60
- let seconds = String(format: "%02d", totalSeconds % 60)
- return "\(minutes):\(seconds)"
- }
-
- private func input(
- selectedTab: MainScreenCatalogTab,
- pendingCount: Int,
- displayedRowCount: Int32,
- runtimeReady: Bool,
- modelReady: Bool,
- modelInstalling: Bool,
- modelDownloadAvailable: Bool,
- modelDownloadProgress: Int?,
- modelMessage: String,
- outputReady: Bool,
- activePreviewEntryId: Int64?,
- previewState: PreviewPlaybackState,
- transcription: IosSingleFileTranscriptionState?,
- rows: [MainScreenRowInput]
- ) -> MainScreenInput {
- let active = transcription?.active == true
- let processedUs = transcription?.processedUs ?? 0
- let durationUs = transcription?.durationUs ?? 0
- let installationState: SpeechModelInstallationState = modelInstalling
- ? .installing
- : (modelReady && runtimeReady ? .installed : .notInstalled)
- let runtimeState: SpeechModelRuntimeState = transcription?.active == true &&
- transcription?.phase?.localizedCaseInsensitiveContains("model") == true
- ? .loading
- : .unloaded
- return MainScreenInput(
- modelMessage: modelMessage,
- modelInstallationState: installationState,
- modelRuntimeState: runtimeState,
- modelDownloadAvailable: modelDownloadAvailable,
- modelDownloadProgress: modelDownloadProgress.map { KotlinInt(int: Int32($0)) },
-
- outputSelected: outputReady,
- folderSelected: true,
- pendingCount: Int32(pendingCount),
- folderChecking: false,
- folderScanQueued: false,
- scanning: false,
- scanMessage: nil,
- transcriptionState: active ? TranscriptionObservationState.active : TranscriptionObservationState.idle,
- transcriptionPhase: transcription?.phase,
- transcriptionFilename: transcription?.fileName,
- transcriptionIndeterminate: active && transcription?.progressPercent == nil,
- transcriptionProgress: Int32(transcription?.progressPercent ?? 0),
- processedUs: processedUs,
- durationUs: durationUs,
- completedFiles: transcription?.completedFiles ?? 0,
- totalFiles: transcription?.totalFiles ?? 0,
- failedFiles: transcription?.failedFiles ?? 0,
- errorMessage: nil,
- selectedTab: selectedTab,
- displayedRowCount: displayedRowCount,
- activePreviewEntryId: activePreviewEntryId.map { KotlinLong(longLong: $0) },
- previewState: previewState,
- rows: rows
- )
- }
-
- private struct DisplayRow {
- let input: MainScreenRowInput
- let title: String
- let subtitle: String
- let badge: String
- let imported: Bool
- let status: IosImportedAudioStatus?
- let transcriptText: String?
- let lastError: String?
+ static func isNoSpeech(_ message: String?) -> Bool {
+ guard let message else { return false }
+ return message.localizedCaseInsensitiveContains("no text") ||
+ message.localizedCaseInsensitiveContains("no speech")
}
}
diff --git a/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift b/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift
index 41a27f4..9345541 100644
--- a/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift
+++ b/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift
@@ -74,6 +74,8 @@ struct IosSingleFileTranscriptionState {
@MainActor
final class IosSingleFileTranscriptionController: ObservableObject {
@Published private(set) var state = IosSingleFileTranscriptionState.idle
+ @Published private(set) var preparationOwnerFileId: Int64?
+ @Published private(set) var prerequisiteError: String?
@Published var message: String?
private var task: Task?
@@ -101,6 +103,8 @@ final class IosSingleFileTranscriptionController: ObservableObject {
onSuccess: ((String) -> Void)? = nil
) {
task?.cancel()
+ preparationOwnerFileId = file.id
+ prerequisiteError = nil
state = IosSingleFileTranscriptionState(
active: true,
fileId: file.id,
@@ -122,7 +126,9 @@ final class IosSingleFileTranscriptionController: ObservableObject {
claim: { store.markProcessing(fileId: file.id) }
)
guard prepared else {
- message = modelStore.message ?? "Speech model preparation failed."
+ let error = modelStore.message ?? "Speech model preparation failed."
+ prerequisiteError = error
+ message = error
state = .idle
return
}
@@ -139,6 +145,7 @@ final class IosSingleFileTranscriptionController: ObservableObject {
modelPrepared: true
) { progress in
Task { @MainActor in
+ self.preparationOwnerFileId = nil
self.state = IosSingleFileTranscriptionState(
active: true,
fileId: file.id,
@@ -175,6 +182,8 @@ final class IosSingleFileTranscriptionController: ObservableObject {
store.markFailed(fileId: file.id, error: error)
message = error
}
+ preparationOwnerFileId = nil
+ prerequisiteError = nil
state = .idle
}
}
@@ -188,6 +197,14 @@ final class IosSingleFileTranscriptionController: ObservableObject {
) {
guard !state.active else { return }
task?.cancel()
+ preparationOwnerFileId = store.files
+ .filter { $0.status == .pending }
+ .sorted {
+ if $0.importedAt != $1.importedAt { return $0.importedAt < $1.importedAt }
+ return $0.id < $1.id
+ }
+ .first?.id
+ prerequisiteError = nil
state = IosSingleFileTranscriptionState(
active: true,
fileId: nil,
@@ -205,7 +222,9 @@ final class IosSingleFileTranscriptionController: ObservableObject {
task = Task {
guard await modelStore.prepareForTranscription() != nil else {
- message = modelStore.message ?? "Speech model preparation failed."
+ let error = modelStore.message ?? "Speech model preparation failed."
+ prerequisiteError = error
+ message = error
state = .idle
return
}
@@ -219,9 +238,10 @@ final class IosSingleFileTranscriptionController: ObservableObject {
outputDocument: outputDocument
) { progress in
Task { @MainActor in
+ self.preparationOwnerFileId = nil
self.state = IosSingleFileTranscriptionState(
active: true,
- fileId: nil,
+ fileId: progress.activeEntryId?.int64Value,
fileName: progress.filename,
phase: progress.phase,
processedUs: progress.processedUs?.int64Value ?? 0,
@@ -243,6 +263,8 @@ final class IosSingleFileTranscriptionController: ObservableObject {
}
message = outcome.summary
+ preparationOwnerFileId = nil
+ prerequisiteError = nil
state = IosSingleFileTranscriptionState(
active: false,
fileId: nil,
diff --git a/iosApp/VoiceInbox/IosSpeechModelStore.swift b/iosApp/VoiceInbox/IosSpeechModelStore.swift
index e70e70d..29d68f4 100644
--- a/iosApp/VoiceInbox/IosSpeechModelStore.swift
+++ b/iosApp/VoiceInbox/IosSpeechModelStore.swift
@@ -145,6 +145,10 @@ final class IosSpeechModelStore: ObservableObject {
isInstalling || downloadTask != nil
}
+ var canCancelDownload: Bool {
+ downloadTask != nil
+ }
+
func reload() {
status = inspectInstallation(installationDirectory)
}
diff --git a/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift b/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift
index 5b710de..610746d 100644
--- a/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift
+++ b/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift
@@ -128,6 +128,119 @@ final class DeferredSpeechModelLoadingTests: XCTestCase {
XCTAssertEqual(prepares.value, 1)
XCTAssertEqual(claims.value, 0)
}
+
+ @MainActor
+ func testTaskListAdapterSupportsAllAndKeepsOptionalUnselectedFolderOutOfTasks() {
+ let modelStatus = IosSpeechModelStatus(
+ directory: FileManager.default.temporaryDirectory,
+ installationState: .installedVerified,
+ missingFiles: []
+ )
+ let files = [
+ IosImportedAudioFile(
+ id: 1,
+ displayName: "new.m4a",
+ localFileName: "new.m4a",
+ sizeBytes: 10,
+ importedAt: Date(timeIntervalSince1970: 100),
+ status: .pending
+ ),
+ IosImportedAudioFile(
+ id: 2,
+ displayName: "failed.m4a",
+ localFileName: "failed.m4a",
+ sizeBytes: 20,
+ importedAt: Date(timeIntervalSince1970: 200),
+ status: .failed,
+ lastError: "decode failed",
+ processedAt: Date(timeIntervalSince1970: 300)
+ ),
+ ]
+
+ let screen = IosMainScreenShellState().screen(
+ selection: .all,
+ importedFiles: files,
+ modelStatus: modelStatus,
+ modelMessage: nil,
+ modelInstalling: false,
+ modelDownloadAvailable: false,
+ modelDownloadProgress: nil,
+ modelCanCancel: false,
+ outputStatus: IosOutputDocumentStatus(displayName: "notes.md", message: "Ready", ready: true),
+ folderStatus: IosInboxFolderStatus(displayName: nil, message: nil, needsSelection: true),
+ folderScanning: false,
+ activePreviewEntryId: nil,
+ previewState: .idle,
+ transcription: .idle,
+ preparationOwnerEntryId: nil,
+ prerequisiteError: nil,
+ actionsEnabled: true
+ )
+
+ XCTAssertEqual(screen.state.tasks.map(\.stableId), ["audio:2", "audio:1"])
+ XCTAssertFalse(screen.state.tasks.contains { $0.stableId == "setup:model" })
+ XCTAssertFalse(screen.state.tasks.contains { $0.stableId == "setup:folder" })
+ XCTAssertEqual(screen.filesById.count, 2)
+ }
+
+ @MainActor
+ func testTaskListAdapterAttachesPreparationFailureToPendingOwner() {
+ let file = IosImportedAudioFile(
+ id: 9,
+ displayName: "voice.m4a",
+ localFileName: "voice.m4a",
+ sizeBytes: 10,
+ importedAt: Date(),
+ status: .pending
+ )
+ let screen = IosMainScreenShellState().screen(
+ selection: .new,
+ importedFiles: [file],
+ modelStatus: IosSpeechModelStatus(
+ directory: FileManager.default.temporaryDirectory,
+ installationState: .installedVerified,
+ missingFiles: []
+ ),
+ modelMessage: nil,
+ modelInstalling: false,
+ modelDownloadAvailable: false,
+ modelDownloadProgress: nil,
+ modelCanCancel: false,
+ outputStatus: IosOutputDocumentStatus(displayName: "notes.md", message: "Ready", ready: true),
+ folderStatus: IosInboxFolderStatus(displayName: nil, message: nil, needsSelection: true),
+ folderScanning: false,
+ activePreviewEntryId: nil,
+ previewState: .idle,
+ transcription: .idle,
+ preparationOwnerEntryId: 9,
+ prerequisiteError: "Model could not be loaded",
+ actionsEnabled: true
+ )
+
+ guard let task = screen.state.tasks.first as? AudioTaskPresentation else {
+ return XCTFail("Expected an audio task")
+ }
+ XCTAssertEqual(task.state, .pending)
+ XCTAssertEqual(task.errorMessage, "Model could not be loaded")
+ }
+
+ func testTypedActionsHaveExplicitIosRoutes() {
+ XCTAssertEqual(IosTaskActionRouter.route(.downloadModel), .modelDownload)
+ XCTAssertEqual(IosTaskActionRouter.route(.selectOutput), .outputSelection)
+ XCTAssertEqual(IosTaskActionRouter.route(.selectFolder), .folderSelection)
+ XCTAssertEqual(IosTaskActionRouter.route(.transcribe), .transcribe)
+ XCTAssertEqual(IosTaskActionRouter.route(.retryTranscription), .retry)
+ XCTAssertEqual(IosTaskActionRouter.route(.showText), .showText)
+ }
+
+ func testRoutineImportAndScanSummariesDoNotRequestAnAlert() {
+ XCTAssertNil(IosAudioImportSummary(imported: 2, skipped: 0, failed: 0).alertMessage)
+ XCTAssertNil(IosAudioImportSummary(imported: 0, skipped: 4, failed: 0).alertMessage)
+ XCTAssertEqual(
+ IosAudioImportSummary(imported: 1, skipped: 0, failed: 1).alertMessage,
+ "1 imported, 1 failed"
+ )
+ }
}
private final class LockedCounter: @unchecked Sendable {
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/AudioCatalog.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/AudioCatalog.kt
index da89fbc..372bbda 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/AudioCatalog.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/AudioCatalog.kt
@@ -48,6 +48,10 @@ interface AudioCatalogQueuePort {
fun markFailed(id: Long, message: String)
+ fun markFailedAt(id: Long, message: String, processedAtMillis: Long) {
+ markFailed(id, message)
+ }
+
fun markPending(id: Long)
}
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCase.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCase.kt
index e58d1d3..199e576 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCase.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCase.kt
@@ -11,6 +11,7 @@ data class BatchTranscriptionInput(
data class BatchTranscriptionProgress(
val phase: String,
+ val activeEntryId: Long? = null,
val filename: String? = null,
val completed: Int,
val total: Int,
@@ -83,6 +84,7 @@ class BatchTranscriptionUseCase(
onProgress(
BatchTranscriptionProgress(
phase = progress.phase,
+ activeEntryId = entry.id,
filename = entry.displayName,
completed = completed,
total = total,
@@ -106,7 +108,11 @@ class BatchTranscriptionUseCase(
currentEntry = null
throw cancelled
} catch (error: Throwable) {
- catalog.markFailed(entry.id, error.message ?: ERROR_TRANSCRIPTION_FAILED)
+ catalog.markFailedAt(
+ entry.id,
+ error.message ?: ERROR_TRANSCRIPTION_FAILED,
+ clock.currentTimeMillis(),
+ )
failed += 1
}
completed += 1
@@ -150,6 +156,7 @@ class BatchTranscriptionUseCase(
failed: Int,
): BatchTranscriptionProgress = BatchTranscriptionProgress(
phase = BatchTranscriptionRules.summary(completed, total, failed),
+ activeEntryId = null,
filename = null,
completed = completed,
total = total,
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepository.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepository.kt
index 0aa33ca..1bbe861 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepository.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepository.kt
@@ -183,7 +183,11 @@ class SqlDelightAudioCatalogRepository(
}
override fun markFailed(id: Long, message: String) {
- queries.markFailed(last_error = message, id = id)
+ queries.markFailed(last_error = message, processed_at = null, id = id)
+ }
+
+ override fun markFailedAt(id: Long, message: String, processedAtMillis: Long) {
+ queries.markFailed(last_error = message, processed_at = processedAtMillis, id = id)
}
override fun markPending(id: Long) {
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt
new file mode 100644
index 0000000..a37806b
--- /dev/null
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt
@@ -0,0 +1,431 @@
+package me.maxistar.voiceinbox.core
+
+enum class TaskListFilter {
+ NEW,
+ PROCESSED,
+ ALL,
+}
+
+enum class SetupTaskKind {
+ MODEL,
+ OUTPUT,
+ FOLDER,
+}
+
+enum class SetupTaskState {
+ REQUIRED,
+ ACTIVE,
+ ERROR,
+}
+
+enum class AudioTaskState {
+ PENDING,
+ PROCESSING,
+ SUCCEEDED,
+ FAILED,
+ NO_SPEECH,
+}
+
+enum class TaskRetention {
+ UNTIL_COMPLETED,
+ RETAINED,
+}
+
+enum class TaskActionKind {
+ DOWNLOAD_MODEL,
+ IMPORT_MODEL,
+ CANCEL_MODEL_DOWNLOAD,
+ RETRY_MODEL_DOWNLOAD,
+ SELECT_OUTPUT,
+ SELECT_FOLDER,
+ REFRESH_FOLDER,
+ TRANSCRIBE,
+ RETRY_TRANSCRIPTION,
+ PLAY,
+ STOP,
+ SHOW_TEXT,
+ IMPORT_AUDIO,
+}
+
+data class TaskActionPresentation(
+ val kind: TaskActionKind,
+ val label: String,
+ val enabled: Boolean = true,
+)
+
+data class TaskProgressPresentation(
+ val phase: String,
+ val percent: Int? = null,
+ val processedUs: Long? = null,
+ val durationUs: Long? = null,
+ val completedFiles: Int? = null,
+ val totalFiles: Int? = null,
+ val failedFiles: Int? = null,
+)
+
+sealed class TaskPresentation {
+ abstract val stableId: String
+ abstract val title: String
+ abstract val detail: String?
+ abstract val badge: String
+ abstract val progress: TaskProgressPresentation?
+ abstract val errorMessage: String?
+ abstract val actions: List
+ abstract val retention: TaskRetention
+}
+
+data class SetupTaskPresentation(
+ override val stableId: String,
+ val kind: SetupTaskKind,
+ val state: SetupTaskState,
+ override val title: String,
+ override val detail: String?,
+ override val badge: String,
+ override val progress: TaskProgressPresentation?,
+ override val errorMessage: String?,
+ override val actions: List,
+ override val retention: TaskRetention = TaskRetention.UNTIL_COMPLETED,
+) : TaskPresentation()
+
+data class AudioTaskPresentation(
+ override val stableId: String,
+ val entryId: Long,
+ val state: AudioTaskState,
+ override val title: String,
+ override val detail: String?,
+ override val badge: String,
+ override val progress: TaskProgressPresentation?,
+ override val errorMessage: String?,
+ override val actions: List,
+ override val retention: TaskRetention = TaskRetention.RETAINED,
+) : TaskPresentation()
+
+enum class ModelSetupSnapshotState {
+ REQUIRED,
+ INSTALLING,
+ INVALID,
+ READY,
+}
+
+data class ModelSetupSnapshot(
+ val state: ModelSetupSnapshotState,
+ val detail: String? = null,
+ val progressPercent: Int? = null,
+ val downloadAvailable: Boolean = false,
+ val canCancel: Boolean = false,
+)
+
+enum class OutputSetupSnapshotState {
+ REQUIRED,
+ INVALID,
+ READY,
+}
+
+data class OutputSetupSnapshot(
+ val state: OutputSetupSnapshotState,
+ val detail: String? = null,
+)
+
+enum class FolderSetupSnapshotState {
+ UNSELECTED,
+ READY,
+ SCANNING,
+ ERROR,
+}
+
+data class FolderSetupSnapshot(
+ val state: FolderSetupSnapshotState,
+ val detail: String? = null,
+)
+
+data class AudioTaskSnapshot(
+ val entryId: Long,
+ val title: String,
+ val detail: String? = null,
+ val state: AudioFileState,
+ val importedAtMillis: Long,
+ val terminalAtMillis: Long? = null,
+ val lastError: String? = null,
+ val hasTranscriptText: Boolean = false,
+ val noSpeech: Boolean = false,
+ val eligibleForTranscription: Boolean = true,
+)
+
+data class PreviewTaskSnapshot(
+ val activeEntryId: Long? = null,
+ val state: PreviewPlaybackState = PreviewPlaybackState.IDLE,
+)
+
+data class TranscriptionTaskSnapshot(
+ val active: Boolean = false,
+ val activeEntryId: Long? = null,
+ val preparationOwnerEntryId: Long? = null,
+ val phase: String? = null,
+ val percent: Int? = null,
+ val processedUs: Long? = null,
+ val durationUs: Long? = null,
+ val completedFiles: Int? = null,
+ val totalFiles: Int? = null,
+ val failedFiles: Int? = null,
+ val prerequisiteError: String? = null,
+)
+
+data class TaskListInput(
+ val filter: TaskListFilter,
+ val model: ModelSetupSnapshot,
+ val output: OutputSetupSnapshot,
+ val folder: FolderSetupSnapshot,
+ val audio: List,
+ val preview: PreviewTaskSnapshot = PreviewTaskSnapshot(),
+ val transcription: TranscriptionTaskSnapshot = TranscriptionTaskSnapshot(),
+)
+
+data class TaskListBatchActionState(
+ val visible: Boolean,
+ val enabled: Boolean,
+ val eligibleCount: Int,
+)
+
+data class TaskListState(
+ val filter: TaskListFilter,
+ val tasks: List,
+ val emptyMessage: String?,
+ val emptyActions: List,
+ val batchAction: TaskListBatchActionState,
+)
+
+object TaskListPresentationController {
+ fun state(input: TaskListInput): TaskListState {
+ val setupTasks = setupTasks(input)
+ val audioTasks = input.audio
+ .filter { visibleInFilter(it.state, input.filter) }
+ .sortedWith(audioComparator(input.filter))
+ .map { audioTask(it, input) }
+ val visibleSetup = if (input.filter == TaskListFilter.PROCESSED) emptyList() else setupTasks
+ val tasks = visibleSetup + audioTasks
+ val eligibleCount = input.audio.count {
+ it.state == AudioFileState.PENDING && it.eligibleForTranscription
+ }
+ val batchVisible = input.filter == TaskListFilter.NEW && eligibleCount > 0
+ return TaskListState(
+ filter = input.filter,
+ tasks = tasks,
+ emptyMessage = if (tasks.isEmpty()) emptyMessage(input.filter) else null,
+ emptyActions = if (tasks.isEmpty() && input.filter != TaskListFilter.PROCESSED) {
+ listOf(
+ TaskActionPresentation(TaskActionKind.IMPORT_AUDIO, "Import Audio Files"),
+ TaskActionPresentation(TaskActionKind.SELECT_FOLDER, "Select Audio Folder"),
+ )
+ } else {
+ emptyList()
+ },
+ batchAction = TaskListBatchActionState(
+ visible = batchVisible,
+ enabled = batchVisible && !input.transcription.active,
+ eligibleCount = eligibleCount,
+ ),
+ )
+ }
+
+ fun preparationOwnerEntryId(audio: List): Long? = audio
+ .asSequence()
+ .filter { it.state == AudioFileState.PENDING && it.eligibleForTranscription }
+ .sortedWith(compareBy({ it.importedAtMillis }, { it.title.lowercase() }, { it.entryId }))
+ .firstOrNull()
+ ?.entryId
+
+ private fun setupTasks(input: TaskListInput): List = buildList {
+ modelTask(input.model)?.let(::add)
+ outputTask(input.output)?.let(::add)
+ folderTask(input.folder)?.let(::add)
+ }
+
+ private fun modelTask(snapshot: ModelSetupSnapshot): SetupTaskPresentation? {
+ if (snapshot.state == ModelSetupSnapshotState.READY) return null
+ val active = snapshot.state == ModelSetupSnapshotState.INSTALLING
+ val error = snapshot.state == ModelSetupSnapshotState.INVALID
+ val actions = when {
+ active && snapshot.canCancel -> listOf(
+ TaskActionPresentation(TaskActionKind.CANCEL_MODEL_DOWNLOAD, "Cancel"),
+ )
+ active -> emptyList()
+ error -> listOf(
+ TaskActionPresentation(TaskActionKind.RETRY_MODEL_DOWNLOAD, "Retry Download", snapshot.downloadAvailable),
+ TaskActionPresentation(TaskActionKind.IMPORT_MODEL, "Install Manually"),
+ )
+ else -> buildList {
+ if (snapshot.downloadAvailable) {
+ add(TaskActionPresentation(TaskActionKind.DOWNLOAD_MODEL, "Download Model"))
+ }
+ add(TaskActionPresentation(TaskActionKind.IMPORT_MODEL, "Install Manually"))
+ }
+ }
+ return SetupTaskPresentation(
+ stableId = "setup:model",
+ kind = SetupTaskKind.MODEL,
+ state = when {
+ active -> SetupTaskState.ACTIVE
+ error -> SetupTaskState.ERROR
+ else -> SetupTaskState.REQUIRED
+ },
+ title = "Install Speech Model",
+ detail = snapshot.detail,
+ badge = if (active) "Downloading" else if (error) "Needs attention" else "Required",
+ progress = if (active) TaskProgressPresentation("Downloading model", snapshot.progressPercent) else null,
+ errorMessage = snapshot.detail.takeIf { error },
+ actions = actions,
+ )
+ }
+
+ private fun outputTask(snapshot: OutputSetupSnapshot): SetupTaskPresentation? {
+ if (snapshot.state == OutputSetupSnapshotState.READY) return null
+ val error = snapshot.state == OutputSetupSnapshotState.INVALID
+ return SetupTaskPresentation(
+ stableId = "setup:output",
+ kind = SetupTaskKind.OUTPUT,
+ state = if (error) SetupTaskState.ERROR else SetupTaskState.REQUIRED,
+ title = "Select Output File",
+ detail = snapshot.detail,
+ badge = if (error) "Needs attention" else "Required",
+ progress = null,
+ errorMessage = snapshot.detail.takeIf { error },
+ actions = listOf(TaskActionPresentation(TaskActionKind.SELECT_OUTPUT, "Select Output File")),
+ )
+ }
+
+ private fun folderTask(snapshot: FolderSetupSnapshot): SetupTaskPresentation? = when (snapshot.state) {
+ FolderSetupSnapshotState.UNSELECTED,
+ FolderSetupSnapshotState.READY,
+ -> null
+ FolderSetupSnapshotState.SCANNING -> SetupTaskPresentation(
+ stableId = "setup:folder",
+ kind = SetupTaskKind.FOLDER,
+ state = SetupTaskState.ACTIVE,
+ title = "Refresh Audio Folder",
+ detail = snapshot.detail,
+ badge = "Scanning",
+ progress = TaskProgressPresentation("Scanning audio folder"),
+ errorMessage = null,
+ actions = emptyList(),
+ )
+ FolderSetupSnapshotState.ERROR -> SetupTaskPresentation(
+ stableId = "setup:folder",
+ kind = SetupTaskKind.FOLDER,
+ state = SetupTaskState.ERROR,
+ title = "Restore Audio Folder Access",
+ detail = snapshot.detail,
+ badge = "Needs attention",
+ progress = null,
+ errorMessage = snapshot.detail,
+ actions = listOf(
+ TaskActionPresentation(TaskActionKind.SELECT_FOLDER, "Select Folder"),
+ TaskActionPresentation(TaskActionKind.REFRESH_FOLDER, "Retry"),
+ ),
+ )
+ }
+
+ private fun audioTask(snapshot: AudioTaskSnapshot, input: TaskListInput): AudioTaskPresentation {
+ val progressOwnerEntryId = input.transcription.activeEntryId
+ ?: input.transcription.preparationOwnerEntryId
+ ?: preparationOwnerEntryId(input.audio).takeIf {
+ input.transcription.active && input.transcription.phase?.contains("model", ignoreCase = true) == true
+ }
+ val ownsProgress = input.transcription.active && (
+ progressOwnerEntryId == snapshot.entryId
+ )
+ val presentationState = when (snapshot.state) {
+ AudioFileState.PENDING -> if (ownsProgress) AudioTaskState.PROCESSING else AudioTaskState.PENDING
+ AudioFileState.PROCESSING -> AudioTaskState.PROCESSING
+ AudioFileState.PROCESSED -> AudioTaskState.SUCCEEDED
+ AudioFileState.FAILED -> if (snapshot.noSpeech) AudioTaskState.NO_SPEECH else AudioTaskState.FAILED
+ AudioFileState.MISSING -> AudioTaskState.FAILED
+ }
+ val isPreviewing = input.preview.activeEntryId == snapshot.entryId &&
+ input.preview.state != PreviewPlaybackState.IDLE
+ val actions = buildList {
+ when (presentationState) {
+ AudioTaskState.PENDING -> add(
+ TaskActionPresentation(
+ TaskActionKind.TRANSCRIBE,
+ "Transcribe",
+ snapshot.eligibleForTranscription && !input.transcription.active,
+ ),
+ )
+ AudioTaskState.FAILED,
+ AudioTaskState.NO_SPEECH,
+ -> add(
+ TaskActionPresentation(
+ TaskActionKind.RETRY_TRANSCRIPTION,
+ "Retry",
+ snapshot.eligibleForTranscription && !input.transcription.active,
+ ),
+ )
+ AudioTaskState.SUCCEEDED -> if (snapshot.hasTranscriptText) {
+ add(TaskActionPresentation(TaskActionKind.SHOW_TEXT, "Show Text"))
+ }
+ AudioTaskState.PROCESSING -> Unit
+ }
+ add(
+ TaskActionPresentation(
+ if (isPreviewing) TaskActionKind.STOP else TaskActionKind.PLAY,
+ if (isPreviewing) "Stop" else "Play",
+ !input.transcription.active,
+ ),
+ )
+ }
+ val prerequisiteError = input.transcription.prerequisiteError
+ .takeIf {
+ snapshot.state == AudioFileState.PENDING &&
+ (input.transcription.preparationOwnerEntryId ?: preparationOwnerEntryId(input.audio)) == snapshot.entryId
+ }
+ return AudioTaskPresentation(
+ stableId = "audio:${snapshot.entryId}",
+ entryId = snapshot.entryId,
+ state = presentationState,
+ title = snapshot.title,
+ detail = snapshot.detail,
+ badge = when (presentationState) {
+ AudioTaskState.PENDING -> "New"
+ AudioTaskState.PROCESSING -> "Processing"
+ AudioTaskState.SUCCEEDED -> "Processed"
+ AudioTaskState.FAILED -> "Failed"
+ AudioTaskState.NO_SPEECH -> "No speech"
+ },
+ progress = if (ownsProgress) input.transcription.toProgress() else null,
+ errorMessage = prerequisiteError ?: snapshot.lastError,
+ actions = actions,
+ )
+ }
+
+ private fun TranscriptionTaskSnapshot.toProgress(): TaskProgressPresentation =
+ TaskProgressPresentation(
+ phase = phase ?: "Processing",
+ percent = percent,
+ processedUs = processedUs,
+ durationUs = durationUs,
+ completedFiles = completedFiles,
+ totalFiles = totalFiles,
+ failedFiles = failedFiles,
+ )
+
+ private fun visibleInFilter(state: AudioFileState, filter: TaskListFilter): Boolean = when (filter) {
+ TaskListFilter.NEW -> state == AudioFileState.PENDING || state == AudioFileState.PROCESSING
+ TaskListFilter.PROCESSED -> state == AudioFileState.PROCESSED || state == AudioFileState.FAILED
+ TaskListFilter.ALL -> true
+ }
+
+ private fun audioComparator(filter: TaskListFilter): Comparator = when (filter) {
+ TaskListFilter.PROCESSED -> compareByDescending {
+ it.terminalAtMillis ?: it.importedAtMillis
+ }.thenByDescending { it.importedAtMillis }.thenByDescending { it.entryId }
+ TaskListFilter.NEW,
+ TaskListFilter.ALL,
+ -> compareByDescending { it.importedAtMillis }.thenByDescending { it.entryId }
+ }
+
+ private fun emptyMessage(filter: TaskListFilter): String = when (filter) {
+ TaskListFilter.NEW -> "No new tasks"
+ TaskListFilter.PROCESSED -> "No processed audio files"
+ TaskListFilter.ALL -> "No audio tasks"
+ }
+}
diff --git a/shared/src/commonMain/sqldelight/me/maxistar/voiceinbox/db/AudioFiles.sq b/shared/src/commonMain/sqldelight/me/maxistar/voiceinbox/db/AudioFiles.sq
index 99d2800..5902267 100644
--- a/shared/src/commonMain/sqldelight/me/maxistar/voiceinbox/db/AudioFiles.sq
+++ b/shared/src/commonMain/sqldelight/me/maxistar/voiceinbox/db/AudioFiles.sq
@@ -215,7 +215,7 @@ markFailed:
UPDATE audio_files
SET state = 'FAILED',
last_error = ?,
- processed_at = NULL,
+ processed_at = ?,
transcript_text = NULL,
duration_us = NULL
WHERE id = ?;
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCaseTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCaseTest.kt
index 02e0b7e..73d06ce 100644
--- a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCaseTest.kt
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCaseTest.kt
@@ -69,6 +69,7 @@ class BatchTranscriptionUseCaseTest {
assertEquals(AudioFileState.FAILED, catalog.entry(1).state)
assertEquals("decode failed", catalog.entry(1).lastError)
+ assertEquals(1234, catalog.entry(1).processedAtMillis)
assertEquals(AudioFileState.PROCESSED, catalog.entry(2).state)
assertEquals(BatchTranscriptionResult(2, 2, 1, "Completed 2 of 2, 1 failed", 100, false), result)
assertEquals("Completed 1 of 2, 1 failed", progress.first { it.filename == null }.phase)
@@ -103,6 +104,7 @@ class BatchTranscriptionUseCaseTest {
assertEquals(
BatchTranscriptionProgress(
phase = "Transcribing",
+ activeEntryId = 1,
filename = "one.wav",
completed = 0,
total = 1,
@@ -113,6 +115,8 @@ class BatchTranscriptionUseCaseTest {
),
progress.first(),
)
+ assertEquals(1L, progress.first().activeEntryId)
+ assertNull(progress.last().activeEntryId)
}
private fun useCase(
@@ -182,6 +186,11 @@ class BatchTranscriptionUseCaseTest {
}
}
+ override fun markFailedAt(id: Long, message: String, processedAtMillis: Long) {
+ markFailed(id, message)
+ update(id) { it.copy(processedAtMillis = processedAtMillis) }
+ }
+
override fun markPending(id: Long) {
update(id) {
if (it.state == AudioFileState.PROCESSING) {
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt
new file mode 100644
index 0000000..a8f710d
--- /dev/null
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt
@@ -0,0 +1,199 @@
+package me.maxistar.voiceinbox.core
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class TaskListPresentationControllerTest {
+ @Test
+ fun setupTasksAreSynthesizedInKindOrderAndCompletedTasksDisappear() {
+ val state = state(
+ filter = TaskListFilter.NEW,
+ model = ModelSetupSnapshot(ModelSetupSnapshotState.REQUIRED, downloadAvailable = true),
+ output = OutputSetupSnapshot(OutputSetupSnapshotState.INVALID, "Access expired"),
+ folder = FolderSetupSnapshot(FolderSetupSnapshotState.SCANNING),
+ )
+
+ assertEquals(listOf("setup:model", "setup:output", "setup:folder"), state.tasks.map { it.stableId })
+ assertEquals(listOf(SetupTaskKind.MODEL, SetupTaskKind.OUTPUT, SetupTaskKind.FOLDER), state.tasks.map {
+ assertIs(it).kind
+ })
+
+ val completed = state(
+ filter = TaskListFilter.ALL,
+ model = ModelSetupSnapshot(ModelSetupSnapshotState.READY),
+ output = OutputSetupSnapshot(OutputSetupSnapshotState.READY),
+ folder = FolderSetupSnapshot(FolderSetupSnapshotState.READY),
+ )
+ assertTrue(completed.tasks.isEmpty())
+ }
+
+ @Test
+ fun unselectedOptionalFolderDoesNotCreateBlockingTask() {
+ val state = state(folder = FolderSetupSnapshot(FolderSetupSnapshotState.UNSELECTED))
+
+ assertTrue(state.tasks.none { it.stableId == "setup:folder" })
+ }
+
+ @Test
+ fun filtersApplyOpenTerminalAndAllRetentionRules() {
+ val audio = listOf(
+ audio(1, AudioFileState.PENDING),
+ audio(2, AudioFileState.PROCESSING),
+ audio(3, AudioFileState.PROCESSED),
+ audio(4, AudioFileState.FAILED),
+ audio(5, AudioFileState.MISSING),
+ )
+
+ assertEquals(listOf("audio:2", "audio:1"), state(TaskListFilter.NEW, audio = audio).tasks.map { it.stableId })
+ assertEquals(listOf("audio:4", "audio:3"), state(TaskListFilter.PROCESSED, audio = audio).tasks.map { it.stableId })
+ assertEquals(listOf("audio:5", "audio:4", "audio:3", "audio:2", "audio:1"), state(TaskListFilter.ALL, audio = audio).tasks.map { it.stableId })
+ }
+
+ @Test
+ fun noSpeechIsTerminalAndRetainsRetry() {
+ val task = state(
+ TaskListFilter.PROCESSED,
+ audio = listOf(audio(1, AudioFileState.FAILED, noSpeech = true, error = "No text was recognized")),
+ ).tasks.single()
+
+ val audioTask = assertIs(task)
+ assertEquals(AudioTaskState.NO_SPEECH, audioTask.state)
+ assertEquals(TaskActionKind.RETRY_TRANSCRIPTION, audioTask.actions.first().kind)
+ }
+
+ @Test
+ fun prerequisiteFailureLeavesPendingTaskInNew() {
+ val state = state(
+ filter = TaskListFilter.NEW,
+ audio = listOf(audio(7, AudioFileState.PENDING)),
+ transcription = TranscriptionTaskSnapshot(
+ active = false,
+ preparationOwnerEntryId = 7,
+ prerequisiteError = "Model could not be loaded",
+ ),
+ )
+
+ val task = assertIs(state.tasks.single())
+ assertEquals(AudioTaskState.PENDING, task.state)
+ assertEquals("Model could not be loaded", task.errorMessage)
+ assertTrue(state(TaskListFilter.PROCESSED, audio = listOf(audio(7, AudioFileState.PENDING))).tasks.isEmpty())
+ }
+
+ @Test
+ fun progressBelongsOnlyToStableActiveAudioTask() {
+ val state = state(
+ audio = listOf(audio(1, AudioFileState.PENDING), audio(2, AudioFileState.PENDING)),
+ transcription = TranscriptionTaskSnapshot(
+ active = true,
+ preparationOwnerEntryId = 1,
+ phase = "Preparing speech model",
+ ),
+ )
+
+ val byId = state.tasks.associateBy(TaskPresentation::stableId)
+ val active = assertIs(byId.getValue("audio:1"))
+ assertEquals(AudioTaskState.PROCESSING, active.state)
+ assertEquals("Preparing speech model", active.progress?.phase)
+ assertNull(assertIs(byId.getValue("audio:2")).progress)
+ assertEquals("audio:1", active.stableId)
+ }
+
+ @Test
+ fun preClaimPreparationUsesDeterministicNextEligiblePendingTask() {
+ val audio = listOf(
+ audio(3, AudioFileState.PENDING, importedAt = 300),
+ audio(1, AudioFileState.PENDING, importedAt = 100),
+ audio(2, AudioFileState.PENDING, importedAt = 50, eligible = false),
+ )
+
+ assertEquals(1L, TaskListPresentationController.preparationOwnerEntryId(audio))
+ val state = state(
+ audio = audio,
+ transcription = TranscriptionTaskSnapshot(active = true, phase = "Preparing speech model"),
+ )
+ assertEquals(
+ "audio:1",
+ state.tasks.single { it.progress != null }.stableId,
+ )
+ assertTrue(audio.all { it.state == AudioFileState.PENDING })
+ }
+
+ @Test
+ fun batchActionCountsOnlyEligiblePendingAudioAndOnlyAppearsInNew() {
+ val audio = listOf(
+ audio(1, AudioFileState.PENDING, eligible = true),
+ audio(2, AudioFileState.PENDING, eligible = false),
+ audio(3, AudioFileState.FAILED, eligible = true),
+ )
+
+ val newState = state(TaskListFilter.NEW, audio = audio)
+ assertTrue(newState.batchAction.visible)
+ assertTrue(newState.batchAction.enabled)
+ assertEquals(1, newState.batchAction.eligibleCount)
+ assertFalse(state(TaskListFilter.ALL, audio = audio).batchAction.visible)
+ assertFalse(state(TaskListFilter.PROCESSED, audio = audio).batchAction.visible)
+ }
+
+ @Test
+ fun orderingUsesImportTimeExceptProcessedUsesTerminalTime() {
+ val audio = listOf(
+ audio(1, AudioFileState.PROCESSED, importedAt = 300, terminalAt = 100),
+ audio(2, AudioFileState.FAILED, importedAt = 100, terminalAt = 400),
+ )
+
+ assertEquals(listOf("audio:2", "audio:1"), state(TaskListFilter.PROCESSED, audio = audio).tasks.map { it.stableId })
+ assertEquals(listOf("audio:1", "audio:2"), state(TaskListFilter.ALL, audio = audio).tasks.map { it.stableId })
+ }
+
+ @Test
+ fun emptyStateIsSpecificToFilterAndOffersImportOnlyForOpenLists() {
+ val newState = state(TaskListFilter.NEW)
+ val processed = state(TaskListFilter.PROCESSED)
+
+ assertEquals("No new tasks", newState.emptyMessage)
+ assertEquals(listOf(TaskActionKind.IMPORT_AUDIO, TaskActionKind.SELECT_FOLDER), newState.emptyActions.map { it.kind })
+ assertEquals("No processed audio files", processed.emptyMessage)
+ assertTrue(processed.emptyActions.isEmpty())
+ }
+
+ private fun state(
+ filter: TaskListFilter = TaskListFilter.NEW,
+ model: ModelSetupSnapshot = ModelSetupSnapshot(ModelSetupSnapshotState.READY),
+ output: OutputSetupSnapshot = OutputSetupSnapshot(OutputSetupSnapshotState.READY),
+ folder: FolderSetupSnapshot = FolderSetupSnapshot(FolderSetupSnapshotState.READY),
+ audio: List = emptyList(),
+ transcription: TranscriptionTaskSnapshot = TranscriptionTaskSnapshot(),
+ ): TaskListState = TaskListPresentationController.state(
+ TaskListInput(
+ filter = filter,
+ model = model,
+ output = output,
+ folder = folder,
+ audio = audio,
+ transcription = transcription,
+ ),
+ )
+
+ private fun audio(
+ id: Long,
+ state: AudioFileState,
+ importedAt: Long = id,
+ terminalAt: Long? = if (state == AudioFileState.PROCESSED || state == AudioFileState.FAILED) id else null,
+ noSpeech: Boolean = false,
+ error: String? = null,
+ eligible: Boolean = true,
+ ) = AudioTaskSnapshot(
+ entryId = id,
+ title = "$id.wav",
+ state = state,
+ importedAtMillis = importedAt,
+ terminalAtMillis = terminalAt,
+ lastError = error,
+ noSpeech = noSpeech,
+ eligibleForTranscription = eligible,
+ )
+}
From fa2625c0be54f5d3b634fa48dd0f151bd316ec2b Mon Sep 17 00:00:00 2001
From: Max Starikov
Date: Thu, 16 Jul 2026 09:56:48 +0200
Subject: [PATCH 06/10] add importing feature
---
.../AudioShareIntentInstrumentedTest.kt | 158 +++++++
.../MainActivityInstrumentedTest.kt | 308 ++++++++++++
.../TestAudioDocumentsProvider.java | 42 ++
.../TranscriptionWorkerInstrumentedTest.kt | 51 ++
app/src/main/AndroidManifest.xml | 21 +-
.../voiceinbox/AudioImportCoordinator.kt | 367 +++++++++++++++
.../voiceinbox/CatalogRefreshPolicy.kt | 35 ++
.../me/maxistar/voiceinbox/MainActivity.kt | 440 ++++++++++++++----
.../voiceinbox/PersistedSelectionAccess.kt | 68 +++
.../ScheduledTranscriptionWorker.kt | 6 +-
.../voiceinbox/TranscriptionWorker.kt | 24 +-
app/src/main/res/layout/activity_main.xml | 7 +
app/src/main/res/values/strings.xml | 7 +
app/src/main/res/xml/imported_audio_paths.xml | 6 +
.../voiceinbox/AudioImportCoordinatorTest.kt | 190 ++++++++
.../voiceinbox/CatalogRefreshPolicyTest.kt | 72 +++
.../PersistedSelectionAccessPolicyTest.kt | 55 +++
...IosSingleFileTranscriptionController.swift | 4 +-
.../maxistar/voiceinbox/core/AudioCatalog.kt | 38 +-
.../core/BatchTranscriptionUseCase.kt | 8 +-
.../core/MainScreenStateController.kt | 15 +-
.../core/SqlDelightAudioCatalogRepository.kt | 40 +-
.../voiceinbox/core/TranscriptionUiRules.kt | 7 +-
.../me/maxistar/voiceinbox/db/AudioFiles.sq | 54 +++
.../core/AudioCatalogQueuePortTest.kt | 18 +-
.../core/BatchTranscriptionUseCaseTest.kt | 48 +-
.../core/MainScreenStateControllerTest.kt | 61 ++-
.../core/TranscriptionUiRulesTest.kt | 59 ++-
.../SqlDelightAudioCatalogRepositoryTest.kt | 30 +-
29 files changed, 2089 insertions(+), 150 deletions(-)
create mode 100644 app/src/androidTest/java/me/maxistar/voiceinbox/AudioShareIntentInstrumentedTest.kt
create mode 100644 app/src/main/java/me/maxistar/voiceinbox/AudioImportCoordinator.kt
create mode 100644 app/src/main/java/me/maxistar/voiceinbox/CatalogRefreshPolicy.kt
create mode 100644 app/src/main/java/me/maxistar/voiceinbox/PersistedSelectionAccess.kt
create mode 100644 app/src/main/res/xml/imported_audio_paths.xml
create mode 100644 app/src/test/java/me/maxistar/voiceinbox/AudioImportCoordinatorTest.kt
create mode 100644 app/src/test/java/me/maxistar/voiceinbox/CatalogRefreshPolicyTest.kt
create mode 100644 app/src/test/java/me/maxistar/voiceinbox/PersistedSelectionAccessPolicyTest.kt
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 84e611c..1ef5f65 100644
--- a/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
+++ b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
@@ -4,6 +4,10 @@ import me.maxistar.voiceinbox.core.*
import android.content.Context
import android.net.Uri
+import android.provider.DocumentsContract
+import android.widget.Button
+import android.widget.LinearLayout
+import android.view.ViewGroup
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu
@@ -14,14 +18,21 @@ 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.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
import org.hamcrest.Matchers.containsString
import org.hamcrest.Matchers.not
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.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class)
class MainActivityInstrumentedTest {
@@ -34,6 +45,7 @@ class MainActivityInstrumentedTest {
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(withId(R.id.importAudio)).check(matches(isDisplayed()))
onView(withContentDescription(R.string.menu_refresh_folder)).check(matches(isDisplayed()))
onView(withId(R.id.transcribeAll)).check(matches(not(isDisplayed())))
@@ -44,6 +56,37 @@ class MainActivityInstrumentedTest {
}
}
+ @Test
+ fun storageSetupIgnoresModelReadinessButRespectsActiveWork() {
+ clearActivityState()
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ scenario.onActivity { activity ->
+ MainActivity::class.java.getDeclaredField("modelReady")
+ .apply { isAccessible = true }
+ .setBoolean(activity, false)
+ MainActivity::class.java.getDeclaredField("modelLoading")
+ .apply { isAccessible = true }
+ .setBoolean(activity, true)
+ 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.selectOutput)).check(matches(isEnabled()))
+ onView(withId(R.id.selectFolder)).check(matches(isEnabled()))
+
+ enqueueDelayedTranscription()
+ awaitActivity(scenario) { activity ->
+ transcriptionState(activity) == TranscriptionObservationState.ACTIVE &&
+ !activity.findViewById(R.id.selectOutput).isEnabled &&
+ !activity.findViewById(R.id.selectFolder).isEnabled
+ }
+ }
+ }
+
@Test
fun transcribeAllIsVisibleOnlyOnNewTabWhenPendingWorkExists() {
clearActivityState()
@@ -92,6 +135,161 @@ class MainActivityInstrumentedTest {
}
}
+ @Test
+ fun recreationRestoresFolderOutputAndProcessingRows() {
+ clearActivityState()
+ val folder = DocumentsContract.buildTreeDocumentUri(
+ TestAudioDocumentsProvider.AUTHORITY,
+ TestAudioDocumentsProvider.ROOT_ID,
+ )
+ val output = DocumentsContract.buildDocumentUri(
+ TestAudioDocumentsProvider.AUTHORITY,
+ "output",
+ )
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ scenario.onActivity { activity ->
+ val store = DocumentSelectionStore(
+ activity.getSharedPreferences(
+ DocumentSelectionStore.PREFERENCES_NAME,
+ Context.MODE_PRIVATE,
+ ),
+ )
+ store.saveFolderUri(folder.toString())
+ store.saveOutputUri(output.toString())
+ MainActivity::class.java.getDeclaredMethod(
+ "restoreSelections",
+ Boolean::class.javaPrimitiveType,
+ ).apply { isAccessible = true }.invoke(activity, false)
+ }
+ awaitActivity(scenario) { activity ->
+ booleanField(activity, "folderAccessReady") &&
+ booleanField(activity, "outputAccessReady")
+ }
+
+ seedCatalogEntry(folder, "processing.wav", AudioFileState.PROCESSING, 20)
+ seedCatalogEntry(folder, "pending.wav", AudioFileState.PENDING, 10)
+ enqueueDelayedTranscription()
+ scenario.onActivity { activity -> invokeRefreshCatalog(activity) }
+ awaitActivity(scenario) { activity ->
+ transcriptionState(activity) == TranscriptionObservationState.ACTIVE &&
+ rowNames(activity).containsAll(listOf("processing.wav", "pending.wav"))
+ }
+
+ scenario.recreate()
+
+ awaitActivity(scenario) { activity ->
+ booleanField(activity, "folderAccessReady") &&
+ booleanField(activity, "outputAccessReady") &&
+ transcriptionState(activity) == TranscriptionObservationState.ACTIVE &&
+ rowNames(activity).containsAll(listOf("processing.wav", "pending.wav"))
+ }
+ onView(withText(containsString("Test audio folder"))).check(matches(isDisplayed()))
+ onView(withText(containsString("transcripts.txt"))).check(matches(isDisplayed()))
+ onView(withContentDescription("processing.wav")).check(matches(isDisplayed()))
+ awaitActivity(scenario) { activity ->
+ rowButton(activity, "processing.wav", "Play")?.isEnabled == false
+ }
+ }
+ }
+
+ @Test
+ fun importOnlyProcessingRowsSurviveRecreationWithoutFolder() {
+ clearActivityState()
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val output = DocumentsContract.buildDocumentUri(
+ TestAudioDocumentsProvider.AUTHORITY,
+ "output",
+ )
+ DocumentSelectionStore(
+ context.getSharedPreferences(
+ DocumentSelectionStore.PREFERENCES_NAME,
+ Context.MODE_PRIVATE,
+ ),
+ ).saveOutputUri(output.toString())
+ seedCatalogEntry(
+ Uri.parse(AndroidAudioImportConstants.SOURCE_ID),
+ "import-processing.ogg",
+ AudioFileState.PROCESSING,
+ 20,
+ )
+ seedCatalogEntry(
+ Uri.parse(AndroidAudioImportConstants.SOURCE_ID),
+ "import-pending.ogg",
+ AudioFileState.PENDING,
+ 10,
+ )
+
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ awaitRows(scenario, setOf("import-processing.ogg", "import-pending.ogg"))
+ scenario.recreate()
+ awaitRows(scenario, setOf("import-processing.ogg", "import-pending.ogg"))
+
+ onView(withText(R.string.folder_not_selected)).check(matches(isDisplayed()))
+ onView(withText(containsString("transcripts.txt"))).check(matches(isDisplayed()))
+ onView(withContentDescription("import-processing.ogg")).check(matches(isDisplayed()))
+ }
+ }
+
+ @Test
+ fun transientFolderValidationFailureKeepsDurableSelection() {
+ clearActivityState()
+ val folder = DocumentsContract.buildTreeDocumentUri(
+ TestAudioDocumentsProvider.AUTHORITY,
+ TestAudioDocumentsProvider.ROOT_ID,
+ )
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ scenario.onActivity { activity ->
+ DocumentSelectionStore(
+ activity.getSharedPreferences(
+ DocumentSelectionStore.PREFERENCES_NAME,
+ Context.MODE_PRIVATE,
+ ),
+ ).saveFolderUri(folder.toString())
+ MainActivity::class.java.getDeclaredField("selectionAccess")
+ .apply { isAccessible = true }
+ .set(
+ activity,
+ object : PersistedSelectionAccess(activity.contentResolver) {
+ override fun validate(
+ uri: Uri,
+ requiredAccess: RequiredDocumentAccess,
+ validation: () -> T,
+ ): PersistedSelectionValidation = PersistedSelectionValidation(
+ state = PersistedSelectionAccessState.TEMPORARILY_UNAVAILABLE,
+ error = SecurityException("Temporary provider failure"),
+ )
+ },
+ )
+ MainActivity::class.java.getDeclaredMethod(
+ "restoreSelections",
+ Boolean::class.javaPrimitiveType,
+ ).apply { isAccessible = true }.invoke(activity, false)
+ }
+ awaitActivity(scenario) { activity ->
+ !booleanField(activity, "folderChecking") &&
+ !booleanField(activity, "folderAccessReady")
+ }
+
+ scenario.onActivity { activity ->
+ val stored = DocumentSelectionStore(
+ activity.getSharedPreferences(
+ DocumentSelectionStore.PREFERENCES_NAME,
+ Context.MODE_PRIVATE,
+ ),
+ ).loadFolderUri()
+ assertEquals(folder.toString(), stored)
+ assertEquals(
+ folder,
+ MainActivity::class.java.getDeclaredField("folderUri")
+ .apply { isAccessible = true }
+ .get(activity),
+ )
+ }
+ onView(withText(R.string.folder_temporarily_unavailable))
+ .check(matches(isDisplayed()))
+ }
+ }
+
@Test
fun rememberedStartupPromptActionsPersistTheirPolicies() {
clearActivityState()
@@ -203,6 +401,10 @@ class MainActivityInstrumentedTest {
private fun clearActivityState() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
+ WorkManager.getInstance(context)
+ .cancelUniqueWork(TranscriptionWorker.UNIQUE_WORK_NAME)
+ .result.get(30, TimeUnit.SECONDS)
+ WorkManager.getInstance(context).pruneWork().result.get(30, TimeUnit.SECONDS)
context.getSharedPreferences(DocumentSelectionStore.PREFERENCES_NAME, Context.MODE_PRIVATE)
.edit()
.clear()
@@ -212,6 +414,112 @@ class MainActivityInstrumentedTest {
.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,
+ ) {
+ 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/wav",
+ sizeBytes = 100,
+ importedAtMillis = modifiedMillis,
+ state = state,
+ lastError = null,
+ processedAtMillis = null,
+ transcriptText = null,
+ durationUs = null,
+ )
+ } finally {
+ repository.close()
+ }
+ }
+
+ private fun enqueueDelayedTranscription() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val request = OneTimeWorkRequestBuilder()
+ .setInitialDelay(1, TimeUnit.DAYS)
+ .build()
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ TranscriptionWorker.UNIQUE_WORK_NAME,
+ ExistingWorkPolicy.REPLACE,
+ request,
+ ).result.get(30, TimeUnit.SECONDS)
+ }
+
+ private fun awaitRows(
+ scenario: ActivityScenario,
+ expected: Set,
+ ) {
+ awaitActivity(scenario) { activity -> rowNames(activity).containsAll(expected) }
+ }
+
+ 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 booleanField(activity: MainActivity, name: String): Boolean =
+ MainActivity::class.java.getDeclaredField(name)
+ .apply { isAccessible = true }
+ .getBoolean(activity)
+
+ private fun transcriptionState(activity: MainActivity): TranscriptionObservationState =
+ MainActivity::class.java.getDeclaredField("transcriptionState")
+ .apply { isAccessible = true }
+ .get(activity) as TranscriptionObservationState
+
+ private fun invokeRefreshCatalog(activity: MainActivity) {
+ MainActivity::class.java.getDeclaredMethod(
+ "refreshCatalog",
+ java.lang.Long::class.java,
+ ).apply { isAccessible = true }.invoke(activity, null)
+ }
+
+ private fun rowNames(activity: MainActivity): List {
+ val list = activity.findViewById(R.id.fileList)
+ return (0 until list.childCount).mapNotNull { index ->
+ list.getChildAt(index).contentDescription?.toString()
+ }
+ }
+
+ private fun rowButton(activity: MainActivity, rowName: String, label: String): Button? {
+ val list = activity.findViewById(R.id.fileList)
+ val row = (0 until list.childCount)
+ .map { index -> list.getChildAt(index) }
+ .firstOrNull { it.contentDescription?.toString() == rowName }
+ ?: return null
+ return descendants(row as ViewGroup)
+ .filterIsInstance()
+ .firstOrNull { it.text.toString() == label }
+ }
+
+ private fun descendants(root: ViewGroup): Sequence = sequence {
+ for (index in 0 until root.childCount) {
+ val child = root.getChildAt(index)
+ yield(child)
+ if (child is ViewGroup) yieldAll(descendants(child))
+ }
}
private fun showStartupPrompt(
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">
+
+
+
+
+
+
+
+
+
+
+
+
,
+) {
+ 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..0762d85
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/CatalogRefreshPolicy.kt
@@ -0,0 +1,35 @@
+package me.maxistar.voiceinbox
+
+import me.maxistar.voiceinbox.core.AudioCatalogSourceScope
+import me.maxistar.voiceinbox.core.MainScreenCatalogTab
+
+data class CatalogRefreshToken(
+ val generation: Long,
+ val sourceScope: AudioCatalogSourceScope,
+ val selectedTab: MainScreenCatalogTab,
+)
+
+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,
+ currentTab: MainScreenCatalogTab,
+ ): Boolean =
+ request.generation == currentGeneration &&
+ request.sourceScope == currentSourceScope &&
+ request.selectedTab == currentTab
+
+ 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 9448031..a3bcf79 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
@@ -16,6 +16,7 @@ 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.appcompat.app.AlertDialog
@@ -36,6 +37,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
private lateinit var transcribeAll: Button
private lateinit var selectOutput: Button
private lateinit var selectFolder: Button
+ private lateinit var importAudio: Button
private lateinit var outputName: TextView
private lateinit var folderName: TextView
private lateinit var newTab: Button
@@ -44,6 +46,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
private lateinit var emptyList: TextView
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
@@ -51,17 +54,22 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
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 outputUri: Uri? = null
private var folderUri: Uri? = null
+ private var outputAccessReady = false
+ private var folderAccessReady = false
+ 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"
@@ -85,6 +93,15 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
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 hasStarted = false
+ private var ingestionActive = false
+ private var importInboxAvailable = false
+ private val queuedImportUris = mutableListOf()
private val outputPicker = registerForActivityResult(
ActivityResultContracts.OpenDocument(),
@@ -98,6 +115,12 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
if (uri != null) acceptFolder(uri)
}
+ private val importPicker = registerForActivityResult(
+ ActivityResultContracts.OpenMultipleDocuments(),
+ ) { uris ->
+ if (uris.isNotEmpty()) ingestAudioUris(uris)
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -113,6 +136,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
selectionStore = DocumentSelectionStore(
getSharedPreferences(DocumentSelectionStore.PREFERENCES_NAME, MODE_PRIVATE),
)
+ selectionAccess = PersistedSelectionAccess(contentResolver)
documentAccess = DocumentAccess(contentResolver)
folderScanner = AudioFolderScanner(contentResolver)
catalog = AndroidSqlDelightAudioCatalogFactory(this).create()
@@ -123,6 +147,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
startupCoordinator = StartupProcessingCoordinator.restore(
savedInstanceState?.getString(STATE_STARTUP_PROCESSING_STAGE),
)
+ bootstrapSelectionIdentities()
ScheduledTranscriptionScheduler.sync(
this,
ScheduledTranscriptionSettingsStore(
@@ -132,6 +157,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
downloadModel.setOnClickListener { SpeechModelDownloadWorker.enqueue(this) }
transcribeAll.setOnClickListener { startBatchTranscription() }
+ importAudio.setOnClickListener {
+ if (!ingestionActive) importPicker.launch(arrayOf("audio/*", "application/ogg"))
+ }
selectOutput.setOnClickListener { launchOutputPickerIfEnabled() }
selectFolder.setOnClickListener { launchFolderPickerIfEnabled() }
newTab.setOnClickListener {
@@ -145,10 +173,28 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
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)
@@ -184,6 +230,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
activityDestroyed = true
stopPreviewPlayback(render = false)
folderExecutor.shutdown()
+ importExecutor.shutdown()
super.onDestroy()
}
@@ -196,6 +243,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
transcribeAll = findViewById(R.id.transcribeAll)
selectOutput = findViewById(R.id.selectOutput)
selectFolder = findViewById(R.id.selectFolder)
+ importAudio = findViewById(R.id.importAudio)
outputName = findViewById(R.id.outputName)
folderName = findViewById(R.id.folderName)
newTab = findViewById(R.id.newTab)
@@ -204,68 +252,161 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
emptyList = findViewById(R.id.emptyList)
}
- private fun restoreSelections() {
- selectionStore.loadOutputUri()?.let(Uri::parse)?.let { stored ->
+ private fun bootstrapSelectionIdentities() {
+ setStoredOutputIdentity(selectionStore.loadOutputUri()?.let(Uri::parse))
+ setStoredFolderIdentity(selectionStore.loadFolderUri()?.let(Uri::parse))
+ }
+
+ private fun setStoredOutputIdentity(stored: Uri?) {
+ outputUri = stored
+ outputAccessReady = false
+ startupCoordinator.setOutputReady(false)
+ outputName.text = getString(
+ if (stored == null) R.string.output_not_selected else R.string.output_restoring,
+ )
+ }
+
+ private fun setStoredFolderIdentity(stored: Uri?) {
+ folderUri = stored
+ folderAccessReady = false
+ startupCoordinator.setFolderReady(false)
+ folderName.text = getString(
+ if (stored == null) R.string.folder_not_selected else R.string.folder_restoring,
+ )
+ }
+
+ 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
+ startupCoordinator.setOutputReady(false)
+ } else {
+ outputAccessReady = false
+ startupCoordinator.setOutputReady(false)
+ outputName.text = getString(R.string.output_restoring)
folderExecutor.execute {
- runCatching {
- documentAccess.requireAppendable(stored)
- documentAccess.metadata(stored)
- }.onSuccess { metadata ->
- runOnUiThread {
- if (activityDestroyed) return@runOnUiThread
- outputUri = stored
- startupCoordinator.setOutputReady(true)
- updateOutputSummary(metadata.displayName)
- updateControls()
- evaluateStartupProcessing()
- }
- }.onFailure {
- selectionStore.clearOutputUri()
- runOnUiThread {
- if (activityDestroyed) return@runOnUiThread
- startupCoordinator.setOutputReady(false)
- evaluateStartupProcessing()
- }
+ val result = selectionAccess.validate(
+ uri = storedOutput,
+ requiredAccess = RequiredDocumentAccess.WRITE,
+ ) {
+ documentAccess.requireAppendable(storedOutput)
+ documentAccess.metadata(storedOutput)
}
- }
- }
- selectionStore.loadFolderUri()?.let(Uri::parse)?.let { stored ->
- folderExecutor.execute {
runOnUiThread {
- if (activityDestroyed) return@runOnUiThread
- folderChecking = true
+ if (
+ activityDestroyed ||
+ outputGeneration != outputValidationGeneration ||
+ outputUri != storedOutput
+ ) return@runOnUiThread
+ 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)
+ outputName.text = getString(R.string.output_temporarily_unavailable)
+ }
+ PersistedSelectionAccessState.PERMISSION_REVOKED -> {
+ selectionStore.clearOutputUri()
+ setStoredOutputIdentity(null)
+ outputAccessError = result.error?.message
+ ?: "Output file access was revoked; select it again"
+ }
+ }
renderStatusBlock()
updateControls()
+ evaluateStartupProcessing()
}
- runCatching {
- folderScanner.requireReadable(stored)
- folderScanner.folderName(stored)
- }.onSuccess { name ->
- runOnUiThread {
- if (activityDestroyed) return@runOnUiThread
- folderChecking = false
- folderUri = stored
+ }
+ }
+
+ val storedFolder = selectionStore.loadFolderUri()?.let(Uri::parse)
+ if (storedFolder != folderUri) setStoredFolderIdentity(storedFolder)
+ val folderGeneration = ++folderValidationGeneration
+ if (storedFolder == null) {
+ folderAccessError = null
+ folderAccessReady = false
+ folderChecking = false
+ startupCoordinator.setFolderReady(false)
+ refreshCatalog()
+ return
+ }
+ folderAccessReady = false
+ folderChecking = true
+ startupCoordinator.setFolderReady(false)
+ folderName.text = getString(R.string.folder_restoring)
+ renderStatusBlock()
+ 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
+ folderChecking = false
+ when (result.state) {
+ PersistedSelectionAccessState.VALID -> {
+ folderAccessReady = true
+ folderAccessError = null
startupCoordinator.setFolderReady(true)
- updateFolderSummary(name)
- updateControls()
- refreshCatalog()
- scanFolder(FolderScanOrigin.STARTUP)
+ updateFolderSummary(result.value)
+ if (scanFolderOnSuccess) {
+ pendingFolderScanOrigin = FolderScanOrigin.STARTUP
+ }
}
- }.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)
- evaluateStartupProcessing()
+ folderName.text = getString(R.string.folder_temporarily_unavailable)
+ }
+ PersistedSelectionAccessState.PERMISSION_REVOKED -> {
+ selectionStore.clearFolderUri()
+ setStoredFolderIdentity(null)
+ folderAccessError = result.error?.message
+ ?: "Audio folder access was revoked; select it again"
}
- showError(it.message ?: "Audio folder is not readable")
}
+ renderStatusBlock()
+ updateControls()
+ refreshCatalog()
+ maybeStartPendingFolderScan()
+ evaluateStartupProcessing()
}
}
}
+ private fun maybeStartPendingFolderScan() {
+ val origin = pendingFolderScanOrigin ?: return
+ if (
+ !folderAccessReady ||
+ transcriptionState == TranscriptionObservationState.UNKNOWN ||
+ transcriptionActive()
+ ) return
+ pendingFolderScanOrigin = null
+ scanFolder(origin)
+ }
+
private fun acceptOutput(uri: Uri) {
+ val validationGeneration = ++outputValidationGeneration
runCatching {
contentResolver.takePersistableUriPermission(
uri,
@@ -277,10 +418,15 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
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
startupCoordinator.setOutputReady(true)
updateOutputSummary(metadata.displayName)
statusError = null
@@ -290,22 +436,30 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
}
}.onFailure {
runOnUiThread {
- if (activityDestroyed) return@runOnUiThread
- startupCoordinator.setOutputReady(false)
+ if (
+ activityDestroyed ||
+ validationGeneration != outputValidationGeneration
+ ) return@runOnUiThread
+ statusError = it.message ?: "Output file is not writable"
+ renderStatusBlock()
+ updateControls()
evaluateStartupProcessing()
}
- showError(it.message ?: "Output file is not writable")
}
}
}
private fun acceptFolder(uri: Uri) {
+ val validationGeneration = ++folderValidationGeneration
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()
updateControls()
@@ -314,34 +468,51 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
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
+ folderAccessError = null
startupCoordinator.setFolderReady(true)
updateFolderSummary(name)
statusError = null
renderStatusBlock()
updateControls()
refreshCatalog()
- scanFolder()
+ pendingFolderScanOrigin = FolderScanOrigin.USER
+ maybeStartPendingFolderScan()
}
}.onFailure {
runOnUiThread {
- if (activityDestroyed) return@runOnUiThread
+ if (
+ activityDestroyed ||
+ validationGeneration != folderValidationGeneration
+ ) return@runOnUiThread
folderChecking = false
- startupCoordinator.setFolderReady(false)
+ statusError = it.message ?: "Audio folder is not readable"
+ renderStatusBlock()
+ updateControls()
evaluateStartupProcessing()
}
- showError(it.message ?: "Audio folder is not readable")
}
}
}
private fun scanFolder(origin: FolderScanOrigin = FolderScanOrigin.USER) {
val folder = folderUri ?: return
- if (folderChecking || folderScanQueued || scanning || transcriptionActive()) return
+ if (
+ !folderAccessReady ||
+ folderChecking ||
+ folderScanQueued ||
+ scanning ||
+ transcriptionState == TranscriptionObservationState.UNKNOWN ||
+ transcriptionActive()
+ ) return
val generation = ++scanGeneration
val startupGeneration = generation.takeIf {
origin == FolderScanOrigin.STARTUP && startupCoordinator.beginStartupScan(generation)
@@ -395,31 +566,47 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
private fun refreshCatalog(startupGeneration: 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(),
+ selectedTab = selectedTab,
+ )
+ val startupGenerationForRequest = pendingStartupCatalogGeneration
folderExecutor.execute {
- val newEntries = catalog.newEntries(folder)
- val entries = if (selectedTab == MainScreenCatalogTab.NEW) {
+ val newEntries = catalog.newEntries(token.sourceScope)
+ val processedEntries = catalog.processedEntries(token.sourceScope)
+ val entries = if (token.selectedTab == MainScreenCatalogTab.NEW) {
newEntries
} else {
- catalog.processedEntries(folder)
+ processedEntries
}
runOnUiThread {
if (activityDestroyed) return@runOnUiThread
+ if (
+ !CatalogRefreshPolicy.isCurrent(
+ request = token,
+ currentGeneration = catalogRefreshGeneration,
+ currentSourceScope = activeSourceScope(),
+ currentTab = selectedTab,
+ )
+ ) return@runOnUiThread
pendingCount = newEntries.count { it.state == AudioFileState.PENDING }
- val catalogFailedCount = newEntries.count { it.state == AudioFileState.FAILED }
+ importInboxAvailable = (newEntries + processedEntries).any {
+ it.folderUri == AndroidAudioImportConstants.SOURCE_ID
+ }
+ val catalogFailedCount = processedEntries.count { it.state == AudioFileState.FAILED }
renderEntries(entries)
renderStatusBlock()
updateControls()
- if (startupGeneration != null) {
+ if (startupGenerationForRequest != null) {
+ if (pendingStartupCatalogGeneration == startupGenerationForRequest) {
+ pendingStartupCatalogGeneration = null
+ }
startupCoordinator.onStartupCatalogReady(
- startupGeneration,
+ startupGenerationForRequest,
pendingCount,
catalogFailedCount,
)
@@ -552,12 +739,11 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
}
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,
)
@@ -691,6 +877,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
known = true,
active = transcriptionActive(),
)
+ maybeStartPendingFolderScan()
evaluateStartupProcessing()
if (infos.isEmpty()) {
transcriptionFinished = false
@@ -746,8 +933,14 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
statusError = null
}
renderStatusBlock()
- val catalogState = "${info.id}:${info.state}:$completedFiles:$failedFiles"
- if (catalogState != lastCatalogWorkState) {
+ 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 {
@@ -774,10 +967,10 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
}
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() {
@@ -850,6 +1043,8 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
selectOutput.isEnabled = controls.outputEnabled
selectFolder.visibility = if (controls.folderSetupVisible) View.VISIBLE else View.GONE
selectFolder.isEnabled = controls.folderEnabled
+ importAudio.isEnabled = !ingestionActive
+ importAudio.text = getString(if (ingestionActive) R.string.importing_audio else R.string.import_audio)
transcribeAll.visibility = if (state.list.transcribeAllVisible) View.VISIBLE else View.GONE
transcribeAll.isEnabled = state.list.transcribeAllEnabled
for (index in 0 until fileList.childCount) {
@@ -1019,6 +1214,8 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
modelDownloadProgress = modelDownloadProgress,
outputSelected = outputUri != null,
folderSelected = folderUri != null,
+ outputReady = outputAccessReady,
+ folderReady = folderAccessReady,
pendingCount = pendingCount,
folderChecking = folderChecking,
folderScanQueued = folderScanQueued,
@@ -1034,15 +1231,96 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
completedFiles = completedFiles,
totalFiles = totalFiles,
failedFiles = failedFiles,
- errorMessage = statusError,
+ errorMessage = statusError ?: outputAccessError ?: folderAccessError,
selectedTab = selectedTab,
displayedRowCount = entries.size,
activePreviewEntryId = previewEntryId,
previewState = previewState,
rows = entries.map { it.toMainScreenRowInput() },
+ importInboxAvailable = importInboxAvailable,
),
)
+ private fun activeSourceScope(): AudioCatalogSourceScope =
+ AudioCatalogSourceScope.of(
+ listOfNotNull(
+ AndroidAudioImportConstants.SOURCE_ID,
+ folderUri?.toString(),
+ ),
+ )
+
+ 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 fun AudioCatalogEntry.toMainScreenRowInput(): MainScreenRowInput =
MainScreenRowInput(
entryId = id,
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 155cd7a..837580a 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/ScheduledTranscriptionWorker.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/ScheduledTranscriptionWorker.kt
@@ -58,7 +58,11 @@ class ScheduledTranscriptionWorker(
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/TranscriptionWorker.kt b/app/src/main/java/me/maxistar/voiceinbox/TranscriptionWorker.kt
index 65fdbcb..e241cdd 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 }
@@ -52,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,
@@ -251,30 +252,29 @@ class TranscriptionWorker(
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)
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/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 02ac805..4e0ae2e 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -90,6 +90,13 @@
android:text="@string/select_output_file"
android:visibility="gone" />
+
+
Leave queued
Output: not selected
Output: %1$s
+ Output: restoring selection…
+ Output: temporarily unavailable
Select output file
Audio folder: not selected
Audio folder: %1$s
+ Audio folder: restoring selection…
+ Audio folder: temporarily unavailable
Select audio folder
+ Import audio
+ Importing audio…
+ No readable audio was shared.
Checking speech model
Download speech model
New
diff --git a/app/src/main/res/xml/imported_audio_paths.xml b/app/src/main/res/xml/imported_audio_paths.xml
new file mode 100644
index 0000000..34aceee
--- /dev/null
+++ b/app/src/main/res/xml/imported_audio_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/app/src/test/java/me/maxistar/voiceinbox/AudioImportCoordinatorTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AudioImportCoordinatorTest.kt
new file mode 100644
index 0000000..9975dc8
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/AudioImportCoordinatorTest.kt
@@ -0,0 +1,190 @@
+package me.maxistar.voiceinbox
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class AudioImportCoordinatorTest {
+ @Test
+ fun validationAcceptsAudioMimeAndVoiceNoteExtensions() {
+ assertEquals(
+ null,
+ AudioImportRules.rejectionReason(metadata("voice", "audio/ogg", 10)),
+ )
+ assertEquals(
+ null,
+ AudioImportRules.rejectionReason(metadata("voice.OPUS", "application/octet-stream", 10)),
+ )
+ assertEquals(
+ "Unsupported audio format",
+ AudioImportRules.rejectionReason(metadata("notes.txt", "text/plain", 10)),
+ )
+ assertEquals(
+ "Audio file is empty",
+ AudioImportRules.rejectionReason(metadata("empty.wav", "audio/wav", 0)),
+ )
+ }
+
+ @Test
+ fun sameNameWithDistinctIdentityKeepsBothImports() {
+ val storage = FakeStorage(
+ metadata("voice.ogg", "audio/ogg", 10, source = "content://one"),
+ metadata("voice.ogg", "audio/ogg", 20, source = "content://two"),
+ )
+ val catalog = FakeCatalog()
+
+ val result = useCase(storage, catalog).ingest(listOf("content://one", "content://two"))
+
+ assertEquals(2, result.importedCount)
+ assertEquals(2, catalog.rows.size)
+ assertEquals(setOf("voice.ogg"), catalog.rows.values.map { it.displayName }.toSet())
+ assertEquals(2, storage.stored.size)
+ }
+
+ @Test
+ fun stableRedeliveryReusesCopyAndCatalogRow() {
+ val storage = FakeStorage(metadata("voice.m4a", "audio/mp4", 100))
+ val catalog = FakeCatalog()
+ assertEquals(1, useCase(storage, catalog).ingest(listOf(SOURCE)).importedCount)
+ assertEquals(1, useCase(storage, catalog).ingest(listOf(SOURCE)).duplicateCount)
+ assertEquals(1, storage.copyCount)
+ assertEquals(1, catalog.rows.size)
+ }
+
+ @Test
+ fun insufficientMetadataPreservesRepeatedDeliveries() {
+ val storage = FakeStorage(metadata("voice", "audio/ogg", null))
+ val catalog = FakeCatalog()
+ var token = 0
+ val useCase = AudioImportUseCase(
+ storage = storage,
+ catalog = catalog,
+ clockMillis = { 1234 },
+ unstableToken = { "token-${token++}" },
+ )
+
+ assertEquals(1, useCase.ingest(listOf(SOURCE)).importedCount)
+ assertEquals(1, useCase.ingest(listOf(SOURCE)).importedCount)
+ assertEquals(2, catalog.rows.size)
+ }
+
+ @Test
+ fun catalogFailureRemovesNewCopy() {
+ val storage = FakeStorage(metadata("voice.wav", "audio/wav", 10))
+ val catalog = FakeCatalog(failInsert = true)
+
+ val result = useCase(storage, catalog).ingest(listOf(SOURCE))
+
+ assertEquals(1, result.rejectedCount)
+ assertTrue(result.message().contains("catalog unavailable"))
+ assertTrue(storage.stored.isEmpty())
+ assertEquals(1, storage.deleteCount)
+ }
+
+ @Test
+ fun partialInputKeepsSuccessAndReportsRejectedItems() {
+ val storage = FakeStorage(
+ metadata("good.flac", "audio/flac", 30, source = "content://good"),
+ metadata("bad.txt", "text/plain", 5, source = "content://bad"),
+ metadata("broken.mp3", "audio/mpeg", 10, source = "content://broken"),
+ ).apply {
+ copyFailures += "content://broken"
+ }
+ val catalog = FakeCatalog()
+
+ val result = useCase(storage, catalog).ingest(
+ listOf("content://good", "content://bad", "content://broken", "content://good"),
+ )
+
+ assertEquals(1, result.importedCount)
+ assertEquals(0, result.duplicateCount)
+ assertEquals(2, result.rejectedCount)
+ assertEquals(1, catalog.rows.size)
+ assertTrue(result.message().contains("Imported 1"))
+ assertTrue(result.message().contains("2 rejected"))
+ assertFalse(storage.stored.any { it.contains("broken") })
+ }
+
+ private fun useCase(storage: FakeStorage, catalog: FakeCatalog) =
+ AudioImportUseCase(
+ storage = storage,
+ catalog = catalog,
+ clockMillis = { 1234 },
+ unstableToken = { "unstable" },
+ )
+
+ private fun metadata(
+ name: String,
+ mime: String?,
+ size: Long?,
+ source: String = SOURCE,
+ ) = AudioImportMetadata(
+ sourceId = source,
+ displayName = name,
+ mimeType = mime,
+ sizeBytes = size,
+ modifiedMillis = null,
+ )
+
+ private data class CatalogRow(
+ val displayName: String,
+ val sizeBytes: Long,
+ )
+
+ private class FakeCatalog(
+ private val failInsert: Boolean = false,
+ ) : AudioImportCatalog {
+ val rows = mutableMapOf()
+
+ override fun contains(documentId: String): Boolean = documentId in rows
+
+ override fun insertPending(
+ documentId: String,
+ displayName: String,
+ mimeType: String?,
+ sizeBytes: Long,
+ importedAtMillis: Long,
+ ) {
+ if (failInsert) error("catalog unavailable")
+ rows[documentId] = CatalogRow(displayName, sizeBytes)
+ }
+ }
+
+ private class FakeStorage(
+ vararg metadata: AudioImportMetadata,
+ ) : AudioImportStorage {
+ private val metadata = metadata.associateBy(AudioImportMetadata::sourceId)
+ val stored = mutableSetOf()
+ val copyFailures = mutableSetOf()
+ var copyCount = 0
+ var deleteCount = 0
+
+ override fun metadata(sourceId: String): AudioImportMetadata =
+ metadata[sourceId] ?: error("unreadable")
+
+ override fun destination(
+ receiptKey: String,
+ displayName: String,
+ ): AudioImportDestination {
+ val documentId = "content://imports/$receiptKey-${AudioImportRules.safeFilename(displayName)}"
+ return AudioImportDestination(documentId, documentId in stored)
+ }
+
+ override fun copy(sourceId: String, destination: AudioImportDestination): Long {
+ if (sourceId in copyFailures) error("copy failed")
+ copyCount += 1
+ stored += destination.documentId
+ return metadata(sourceId).sizeBytes ?: 12
+ }
+
+ override fun delete(destination: AudioImportDestination) {
+ deleteCount += 1
+ stored -= destination.documentId
+ }
+ }
+
+ private companion object {
+ const val SOURCE = "content://voice"
+ }
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/CatalogRefreshPolicyTest.kt b/app/src/test/java/me/maxistar/voiceinbox/CatalogRefreshPolicyTest.kt
new file mode 100644
index 0000000..d336655
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/CatalogRefreshPolicyTest.kt
@@ -0,0 +1,72 @@
+package me.maxistar.voiceinbox
+
+import me.maxistar.voiceinbox.core.AudioCatalogSourceScope
+import me.maxistar.voiceinbox.core.MainScreenCatalogTab
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CatalogRefreshPolicyTest {
+ @Test
+ fun firstObservationFilenameAndTerminalStateTriggerRefresh() {
+ val first = workKey(filename = "one.wav")
+ assertTrue(CatalogRefreshPolicy.shouldRefreshForWork(null, first))
+ assertFalse(CatalogRefreshPolicy.shouldRefreshForWork(first, first))
+ assertTrue(
+ CatalogRefreshPolicy.shouldRefreshForWork(
+ first,
+ workKey(filename = "two.wav"),
+ ),
+ )
+ assertTrue(
+ CatalogRefreshPolicy.shouldRefreshForWork(
+ first,
+ workKey(filename = "one.wav", state = "SUCCEEDED", completed = 1),
+ ),
+ )
+ }
+
+ @Test
+ fun fineGrainedProgressDoesNotParticipateInRefreshKey() {
+ val before = workKey(filename = "one.wav")
+ val after = workKey(filename = "one.wav")
+
+ assertFalse(CatalogRefreshPolicy.shouldRefreshForWork(before, after))
+ }
+
+ @Test
+ fun refreshTokenRejectsOldGenerationScopeAndTab() {
+ val scope = AudioCatalogSourceScope.of(listOf("imports", "folder"))
+ val token = CatalogRefreshToken(3, scope, MainScreenCatalogTab.NEW)
+
+ assertTrue(
+ CatalogRefreshPolicy.isCurrent(token, 3, scope, MainScreenCatalogTab.NEW),
+ )
+ assertFalse(
+ CatalogRefreshPolicy.isCurrent(token, 4, scope, MainScreenCatalogTab.NEW),
+ )
+ assertFalse(
+ CatalogRefreshPolicy.isCurrent(
+ token,
+ 3,
+ AudioCatalogSourceScope.single("imports"),
+ MainScreenCatalogTab.NEW,
+ ),
+ )
+ assertFalse(
+ CatalogRefreshPolicy.isCurrent(token, 3, scope, MainScreenCatalogTab.PROCESSED),
+ )
+ }
+
+ private fun workKey(
+ filename: String?,
+ state: String = "RUNNING",
+ completed: Int = 0,
+ ) = CatalogWorkRefreshKey(
+ workId = "work",
+ workState = state,
+ filename = filename,
+ completedFiles = completed,
+ failedFiles = 0,
+ )
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/PersistedSelectionAccessPolicyTest.kt b/app/src/test/java/me/maxistar/voiceinbox/PersistedSelectionAccessPolicyTest.kt
new file mode 100644
index 0000000..11b2d2e
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/PersistedSelectionAccessPolicyTest.kt
@@ -0,0 +1,55 @@
+package me.maxistar.voiceinbox
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class PersistedSelectionAccessPolicyTest {
+ @Test
+ fun successfulValidationIsValidWithoutPersistedPermission() {
+ assertEquals(
+ PersistedSelectionAccessState.VALID,
+ PersistedSelectionAccessPolicy.classify(
+ validationSucceeded = true,
+ hasRequiredPersistedPermission = false,
+ ),
+ )
+ }
+
+ @Test
+ fun failedValidationWithPersistedPermissionIsTransient() {
+ val state = PersistedSelectionAccessPolicy.classify(
+ validationSucceeded = false,
+ hasRequiredPersistedPermission = true,
+ )
+
+ assertEquals(PersistedSelectionAccessState.TEMPORARILY_UNAVAILABLE, state)
+ assertFalse(PersistedSelectionAccessPolicy.shouldClearSelection(state))
+ }
+
+ @Test
+ fun failedValidationWithoutPersistedPermissionIsRevoked() {
+ val state = PersistedSelectionAccessPolicy.classify(
+ validationSucceeded = false,
+ hasRequiredPersistedPermission = false,
+ )
+
+ assertEquals(PersistedSelectionAccessState.PERMISSION_REVOKED, state)
+ assertTrue(PersistedSelectionAccessPolicy.shouldClearSelection(state))
+ }
+
+ @Test
+ fun validAndTransientSelectionsAreNeverClearedByPolicy() {
+ assertFalse(
+ PersistedSelectionAccessPolicy.shouldClearSelection(
+ PersistedSelectionAccessState.VALID,
+ ),
+ )
+ assertFalse(
+ PersistedSelectionAccessPolicy.shouldClearSelection(
+ PersistedSelectionAccessState.TEMPORARILY_UNAVAILABLE,
+ ),
+ )
+ }
+}
diff --git a/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift b/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift
index 9345541..90d13f2 100644
--- a/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift
+++ b/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift
@@ -370,7 +370,9 @@ final class IosSingleFileTranscriptionController: ObservableObject {
)
return useCase.transcribe(
input: BatchTranscriptionInput(
- folderId: IosAudioCatalogConstants.importedFolderUri,
+ sourceScope: AudioCatalogSourceScope(
+ sourceIds: [IosAudioCatalogConstants.importedFolderUri]
+ ),
outputId: outputDocument.id,
runId: UUID().uuidString,
retryEntryId: nil
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/AudioCatalog.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/AudioCatalog.kt
index 372bbda..ccaefce 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/AudioCatalog.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/AudioCatalog.kt
@@ -35,14 +35,46 @@ data class AudioCatalogEntry(
val transcriptText: String?,
)
+data class AudioCatalogSourceScope(
+ val sourceIds: List,
+) {
+ init {
+ require(sourceIds.isNotEmpty()) { "At least one audio catalog source is required" }
+ require(sourceIds.none(String::isBlank)) { "Audio catalog source ids must not be blank" }
+ require(sourceIds.distinct().size == sourceIds.size) {
+ "Audio catalog source ids must be unique"
+ }
+ }
+
+ companion object {
+ fun single(sourceId: String): AudioCatalogSourceScope =
+ AudioCatalogSourceScope(listOf(sourceId))
+
+ fun of(sourceIds: Iterable): AudioCatalogSourceScope =
+ AudioCatalogSourceScope(sourceIds.distinct().toList())
+ }
+}
+
interface AudioCatalogQueuePort {
- fun pendingCount(folderUri: String): Int
+ fun pendingCount(scope: AudioCatalogSourceScope): Int
+
+ fun pendingCount(sourceId: String): Int =
+ pendingCount(AudioCatalogSourceScope.single(sourceId))
fun recoverInterrupted()
- fun claimPending(folderUri: String, specificId: Long? = null): AudioCatalogEntry?
+ fun claimPending(
+ scope: AudioCatalogSourceScope,
+ specificId: Long? = null,
+ ): AudioCatalogEntry?
+
+ fun claimPending(sourceId: String, specificId: Long? = null): AudioCatalogEntry? =
+ claimPending(AudioCatalogSourceScope.single(sourceId), specificId)
+
+ fun claimFailed(scope: AudioCatalogSourceScope, id: Long): AudioCatalogEntry?
- fun claimFailed(folderUri: String, id: Long): AudioCatalogEntry?
+ fun claimFailed(sourceId: String, id: Long): AudioCatalogEntry? =
+ claimFailed(AudioCatalogSourceScope.single(sourceId), id)
fun markProcessed(id: Long, processedAtMillis: Long, transcriptText: String)
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCase.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCase.kt
index 199e576..cde6165 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCase.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCase.kt
@@ -3,7 +3,7 @@ package me.maxistar.voiceinbox.core
import kotlin.coroutines.cancellation.CancellationException
data class BatchTranscriptionInput(
- val folderId: String,
+ val sourceScope: AudioCatalogSourceScope,
val outputId: String,
val runId: String,
val retryEntryId: Long? = null,
@@ -63,7 +63,7 @@ class BatchTranscriptionUseCase(
): BatchTranscriptionResult {
catalog.recoverInterrupted()
val total = if (input.retryEntryId == null) {
- catalog.pendingCount(input.folderId)
+ catalog.pendingCount(input.sourceScope)
} else {
1
}
@@ -143,9 +143,9 @@ class BatchTranscriptionUseCase(
completed: Int,
): AudioCatalogEntry? =
if (input.retryEntryId == null) {
- catalog.claimPending(input.folderId)
+ catalog.claimPending(input.sourceScope)
} else if (completed == 0) {
- catalog.claimFailed(input.folderId, input.retryEntryId)
+ catalog.claimFailed(input.sourceScope, input.retryEntryId)
} else {
null
}
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/MainScreenStateController.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/MainScreenStateController.kt
index fd6c9b8..5466619 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/MainScreenStateController.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/MainScreenStateController.kt
@@ -19,6 +19,8 @@ data class MainScreenInput(
val modelDownloadProgress: Int?,
val outputSelected: Boolean,
val folderSelected: Boolean,
+ val outputReady: Boolean = outputSelected,
+ val folderReady: Boolean = folderSelected,
val pendingCount: Int,
val folderChecking: Boolean,
val folderScanQueued: Boolean,
@@ -40,6 +42,7 @@ data class MainScreenInput(
val activePreviewEntryId: Long?,
val previewState: PreviewPlaybackState,
val rows: List = emptyList(),
+ val importInboxAvailable: Boolean = false,
)
data class MainScreenState(
@@ -78,11 +81,19 @@ object MainScreenStateController {
val busy = input.folderChecking || input.folderScanQueued || input.scanning
val controls = TranscriptionUiRules.catalogControls(
modelInstallationState = input.modelInstallationState,
- outputSelected = input.outputSelected,
- folderSelected = input.folderSelected,
+ outputSelected = input.outputReady,
+ folderSelected = input.folderReady,
pendingCount = input.pendingCount,
transcriptionState = input.transcriptionState,
scanning = busy,
+ audioInputAvailable = if (input.folderSelected) {
+ input.folderReady
+ } else {
+ input.importInboxAvailable
+ },
+ ).copy(
+ outputSetupVisible = !input.outputSelected,
+ folderSetupVisible = !input.folderSelected,
)
return MainScreenState(
status = TranscriptionUiRules.statusProgressBlock(input.toStatusInput()),
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepository.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepository.kt
index 1bbe861..2a9f471 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepository.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepository.kt
@@ -51,10 +51,18 @@ class SqlDelightAudioCatalogRepository(
queries.selectDisplayRows(folderUri, mapper = ::toCatalogFile).executeAsList()
fun newEntries(folderUri: String): List =
- queries.selectNewDisplayRows(folderUri, mapper = ::toCatalogEntry).executeAsList()
+ newEntries(AudioCatalogSourceScope.single(folderUri))
+
+ fun newEntries(scope: AudioCatalogSourceScope): List =
+ queries.selectNewDisplayRowsForSources(scope.sourceIds, mapper = ::toCatalogEntry)
+ .executeAsList()
fun processedEntries(folderUri: String): List =
- queries.selectProcessedDisplayRows(folderUri, mapper = ::toCatalogEntry).executeAsList()
+ processedEntries(AudioCatalogSourceScope.single(folderUri))
+
+ fun processedEntries(scope: AudioCatalogSourceScope): List =
+ queries.selectProcessedDisplayRowsForSources(scope.sourceIds, mapper = ::toCatalogEntry)
+ .executeAsList()
fun missingEntries(folderUri: String): List =
queries.selectMissingDisplayRows(folderUri, mapper = ::toCatalogEntry).executeAsList()
@@ -65,6 +73,10 @@ class SqlDelightAudioCatalogRepository(
fun catalogFile(id: Long): SqlDelightAudioCatalogFile? =
queries.selectById(id, mapper = ::toCatalogFile).executeAsOneOrNull()
+ fun catalogFile(folderUri: String, documentUri: String): SqlDelightAudioCatalogFile? =
+ queries.selectByFolderDocument(folderUri, documentUri, mapper = ::toCatalogFile)
+ .executeAsOneOrNull()
+
fun upsertImportedFile(
folderUri: String,
documentUri: String,
@@ -141,21 +153,27 @@ class SqlDelightAudioCatalogRepository(
}
}
- override fun pendingCount(folderUri: String): Int =
- queries.pendingCount(folderUri).executeAsOne().toInt()
+ override fun pendingCount(scope: AudioCatalogSourceScope): Int =
+ queries.pendingCountForSources(scope.sourceIds).executeAsOne().toInt()
override fun recoverInterrupted() {
queries.recoverInterrupted()
}
- override fun claimPending(folderUri: String, specificId: Long?): AudioCatalogEntry? =
+ override fun claimPending(
+ scope: AudioCatalogSourceScope,
+ specificId: Long?,
+ ): AudioCatalogEntry? =
queries.transactionWithResult {
val candidate = if (specificId == null) {
- queries.selectNextPendingForClaim(folderUri, mapper = ::toCatalogEntry)
+ queries.selectNextPendingForClaimInSources(
+ scope.sourceIds,
+ mapper = ::toCatalogEntry,
+ )
.executeAsOneOrNull()
} else {
- queries.selectSpecificPendingForClaim(
- folder_uri = folderUri,
+ queries.selectSpecificPendingForClaimInSources(
+ folder_uri = scope.sourceIds,
id = specificId,
mapper = ::toCatalogEntry,
).executeAsOneOrNull()
@@ -163,10 +181,10 @@ class SqlDelightAudioCatalogRepository(
markClaimed(candidate.id, AudioFileState.PENDING)
}
- override fun claimFailed(folderUri: String, id: Long): AudioCatalogEntry? =
+ override fun claimFailed(scope: AudioCatalogSourceScope, id: Long): AudioCatalogEntry? =
queries.transactionWithResult {
- queries.selectSpecificFailedForClaim(
- folder_uri = folderUri,
+ queries.selectSpecificFailedForClaimInSources(
+ folder_uri = scope.sourceIds,
id = id,
mapper = ::toCatalogEntry,
).executeAsOneOrNull() ?: return@transactionWithResult null
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRules.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRules.kt
index 988989c..f390ec5 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRules.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRules.kt
@@ -83,15 +83,16 @@ object TranscriptionUiRules {
pendingCount: Int,
transcriptionState: TranscriptionObservationState,
scanning: Boolean,
+ audioInputAvailable: Boolean = folderSelected,
): CatalogControlState {
val workReady = transcriptionState == TranscriptionObservationState.IDLE ||
transcriptionState == TranscriptionObservationState.FINISHED
val idle = workReady && !scanning
val modelAvailable = modelInstallationState.isAvailable
- val prerequisites = modelAvailable && outputSelected && folderSelected && idle
+ val prerequisites = modelAvailable && outputSelected && audioInputAvailable && idle
return CatalogControlState(
- outputEnabled = modelAvailable && idle,
- folderEnabled = modelAvailable && idle,
+ outputEnabled = idle,
+ folderEnabled = idle,
outputSetupVisible = !outputSelected,
folderSetupVisible = !folderSelected,
refreshEnabled = modelAvailable && folderSelected && idle,
diff --git a/shared/src/commonMain/sqldelight/me/maxistar/voiceinbox/db/AudioFiles.sq b/shared/src/commonMain/sqldelight/me/maxistar/voiceinbox/db/AudioFiles.sq
index 5902267..7e24637 100644
--- a/shared/src/commonMain/sqldelight/me/maxistar/voiceinbox/db/AudioFiles.sq
+++ b/shared/src/commonMain/sqldelight/me/maxistar/voiceinbox/db/AudioFiles.sq
@@ -53,6 +53,17 @@ ORDER BY
display_name COLLATE NOCASE ASC,
document_uri ASC;
+selectNewDisplayRowsForSources:
+SELECT *
+FROM audio_files
+WHERE folder_uri IN ?
+ AND state IN ('PENDING', 'PROCESSING')
+ORDER BY
+ CASE WHEN modified_millis IS NULL THEN 1 ELSE 0 END,
+ modified_millis DESC,
+ display_name COLLATE NOCASE ASC,
+ document_uri ASC;
+
selectProcessedDisplayRows:
SELECT *
FROM audio_files
@@ -64,6 +75,17 @@ ORDER BY
display_name COLLATE NOCASE ASC,
document_uri ASC;
+selectProcessedDisplayRowsForSources:
+SELECT *
+FROM audio_files
+WHERE folder_uri IN ?
+ AND state IN ('PROCESSED', 'FAILED')
+ORDER BY
+ CASE WHEN modified_millis IS NULL THEN 1 ELSE 0 END,
+ modified_millis DESC,
+ display_name COLLATE NOCASE ASC,
+ document_uri ASC;
+
selectMissingDisplayRows:
SELECT *
FROM audio_files
@@ -86,6 +108,12 @@ FROM audio_files
WHERE folder_uri = ?
AND state = 'PENDING';
+pendingCountForSources:
+SELECT count(*)
+FROM audio_files
+WHERE folder_uri IN ?
+ AND state = 'PENDING';
+
selectNextPendingForClaim:
SELECT *
FROM audio_files
@@ -98,6 +126,18 @@ ORDER BY
document_uri ASC
LIMIT 1;
+selectNextPendingForClaimInSources:
+SELECT *
+FROM audio_files
+WHERE folder_uri IN ?
+ AND state = 'PENDING'
+ORDER BY
+ CASE WHEN modified_millis IS NULL THEN 1 ELSE 0 END,
+ modified_millis ASC,
+ display_name COLLATE NOCASE ASC,
+ document_uri ASC
+LIMIT 1;
+
selectSpecificPendingForClaim:
SELECT *
FROM audio_files
@@ -105,6 +145,13 @@ WHERE folder_uri = ?
AND state = 'PENDING'
AND id = ?;
+selectSpecificPendingForClaimInSources:
+SELECT *
+FROM audio_files
+WHERE folder_uri IN ?
+ AND state = 'PENDING'
+ AND id = ?;
+
selectSpecificFailedForClaim:
SELECT *
FROM audio_files
@@ -112,6 +159,13 @@ WHERE folder_uri = ?
AND state = 'FAILED'
AND id = ?;
+selectSpecificFailedForClaimInSources:
+SELECT *
+FROM audio_files
+WHERE folder_uri IN ?
+ AND state = 'FAILED'
+ AND id = ?;
+
insertScannedFile:
INSERT INTO audio_files(
folder_uri,
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/AudioCatalogQueuePortTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/AudioCatalogQueuePortTest.kt
index aca91d7..8111563 100644
--- a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/AudioCatalogQueuePortTest.kt
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/AudioCatalogQueuePortTest.kt
@@ -38,8 +38,10 @@ class AudioCatalogQueuePortTest {
) : AudioCatalogQueuePort {
private val entries = entries.associateBy(AudioCatalogEntry::id).toMutableMap()
- override fun pendingCount(folderUri: String): Int =
- entries.values.count { it.folderUri == folderUri && it.state == AudioFileState.PENDING }
+ override fun pendingCount(scope: AudioCatalogSourceScope): Int =
+ entries.values.count {
+ it.folderUri in scope.sourceIds && it.state == AudioFileState.PENDING
+ }
override fun recoverInterrupted() {
entries.keys.toList().forEach { id ->
@@ -56,12 +58,12 @@ class AudioCatalogQueuePortTest {
}
override fun claimPending(
- folderUri: String,
+ scope: AudioCatalogSourceScope,
specificId: Long?,
- ): AudioCatalogEntry? = claim(folderUri, AudioFileState.PENDING, specificId)
+ ): AudioCatalogEntry? = claim(scope, AudioFileState.PENDING, specificId)
- override fun claimFailed(folderUri: String, id: Long): AudioCatalogEntry? =
- claim(folderUri, AudioFileState.FAILED, id)
+ override fun claimFailed(scope: AudioCatalogSourceScope, id: Long): AudioCatalogEntry? =
+ claim(scope, AudioFileState.FAILED, id)
override fun markProcessed(
id: Long,
@@ -105,12 +107,12 @@ class AudioCatalogQueuePortTest {
}
private fun claim(
- folderUri: String,
+ scope: AudioCatalogSourceScope,
state: AudioFileState,
specificId: Long?,
): AudioCatalogEntry? {
val entry = entries.values
- .filter { it.folderUri == folderUri && it.state == state }
+ .filter { it.folderUri in scope.sourceIds && it.state == state }
.filter { specificId == null || it.id == specificId }
.sortedWith(AudioCatalogRules.processingOrder)
.firstOrNull()
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCaseTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCaseTest.kt
index 73d06ce..a48d015 100644
--- a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCaseTest.kt
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCaseTest.kt
@@ -119,13 +119,36 @@ class BatchTranscriptionUseCaseTest {
assertNull(progress.last().activeEntryId)
}
+ @Test
+ fun transcribeAllProcessesPendingEntriesAcrossActiveSources() {
+ val catalog = FakeCatalog(
+ entry(1, "folder.wav", AudioFileState.PENDING, sourceId = FOLDER),
+ entry(2, "imported.ogg", AudioFileState.PENDING, sourceId = IMPORTS),
+ entry(3, "inactive.wav", AudioFileState.PENDING, sourceId = OTHER_FOLDER),
+ )
+ val transcriber = FakeTranscriber(
+ 1L to SingleFileTranscriptionResult(10, 5, "folder"),
+ 2L to SingleFileTranscriptionResult(20, 6, "import"),
+ )
+
+ val result = useCase(catalog, transcriber).transcribe(
+ input().copy(sourceScope = AudioCatalogSourceScope.of(listOf(FOLDER, IMPORTS))),
+ ) {}
+
+ assertEquals(listOf(1L, 2L), transcriber.transcribedIds)
+ assertEquals(AudioFileState.PROCESSED, catalog.entry(1).state)
+ assertEquals(AudioFileState.PROCESSED, catalog.entry(2).state)
+ assertEquals(AudioFileState.PENDING, catalog.entry(3).state)
+ assertEquals(2, result.total)
+ }
+
private fun useCase(
catalog: FakeCatalog,
transcriber: FakeTranscriber,
) = BatchTranscriptionUseCase(catalog, transcriber, FakeClock)
private fun input(retryEntryId: Long? = null) = BatchTranscriptionInput(
- folderId = FOLDER,
+ sourceScope = AudioCatalogSourceScope.single(FOLDER),
outputId = "content://output",
runId = "run-1",
retryEntryId = retryEntryId,
@@ -139,8 +162,10 @@ class BatchTranscriptionUseCaseTest {
fun entry(id: Long): AudioCatalogEntry = entries.getValue(id)
- override fun pendingCount(folderUri: String): Int =
- entries.values.count { it.folderUri == folderUri && it.state == AudioFileState.PENDING }
+ override fun pendingCount(scope: AudioCatalogSourceScope): Int =
+ entries.values.count {
+ it.folderUri in scope.sourceIds && it.state == AudioFileState.PENDING
+ }
override fun recoverInterrupted() {
recoveries += 1
@@ -153,12 +178,12 @@ class BatchTranscriptionUseCaseTest {
}
override fun claimPending(
- folderUri: String,
+ scope: AudioCatalogSourceScope,
specificId: Long?,
- ): AudioCatalogEntry? = claim(folderUri, AudioFileState.PENDING, specificId)
+ ): AudioCatalogEntry? = claim(scope, AudioFileState.PENDING, specificId)
- override fun claimFailed(folderUri: String, id: Long): AudioCatalogEntry? =
- claim(folderUri, AudioFileState.FAILED, id)
+ override fun claimFailed(scope: AudioCatalogSourceScope, id: Long): AudioCatalogEntry? =
+ claim(scope, AudioFileState.FAILED, id)
override fun markProcessed(
id: Long,
@@ -207,12 +232,12 @@ class BatchTranscriptionUseCaseTest {
}
private fun claim(
- folderUri: String,
+ scope: AudioCatalogSourceScope,
state: AudioFileState,
specificId: Long?,
): AudioCatalogEntry? {
val entry = entries.values
- .filter { it.folderUri == folderUri && it.state == state }
+ .filter { it.folderUri in scope.sourceIds && it.state == state }
.filter { specificId == null || it.id == specificId }
.sortedWith(AudioCatalogRules.processingOrder)
.firstOrNull()
@@ -258,9 +283,10 @@ class BatchTranscriptionUseCaseTest {
id: Long,
name: String,
state: AudioFileState,
+ sourceId: String = FOLDER,
) = AudioCatalogEntry(
id = id,
- folderUri = FOLDER,
+ folderUri = sourceId,
documentUri = "content://audio/$id",
displayName = name,
mimeType = "audio/wav",
@@ -274,5 +300,7 @@ class BatchTranscriptionUseCaseTest {
private companion object {
const val FOLDER = "content://folder"
+ const val IMPORTS = "android-imported-audio"
+ const val OTHER_FOLDER = "content://other-folder"
}
}
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/MainScreenStateControllerTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/MainScreenStateControllerTest.kt
index 9ac38a3..ffd1397 100644
--- a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/MainScreenStateControllerTest.kt
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/MainScreenStateControllerTest.kt
@@ -6,12 +6,27 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue
class MainScreenStateControllerTest {
+ @Test
+ fun importInboxEnablesPendingWorkWithoutSelectedFolder() {
+ val state = MainScreenStateController.state(
+ input(
+ outputSelected = true,
+ folderSelected = false,
+ pendingCount = 1,
+ importInboxAvailable = true,
+ ),
+ )
+
+ assertEquals(true, state.controls.transcribeAllEnabled)
+ assertEquals(false, state.controls.refreshEnabled)
+ }
+
@Test
fun setupControlsReflectMissingModelOutputAndFolder() {
assertEquals(
CatalogControlState(
- outputEnabled = false,
- folderEnabled = false,
+ outputEnabled = true,
+ folderEnabled = true,
outputSetupVisible = true,
folderSetupVisible = true,
refreshEnabled = false,
@@ -48,6 +63,24 @@ class MainScreenStateControllerTest {
)
}
+ @Test
+ fun restoringSelectionsStayConfiguredButDoNotEnableDependentActions() {
+ val state = state(
+ input(
+ outputSelected = true,
+ folderSelected = true,
+ outputReady = false,
+ folderReady = false,
+ pendingCount = 2,
+ ),
+ )
+
+ assertFalse(state.controls.outputSetupVisible)
+ assertFalse(state.controls.folderSetupVisible)
+ assertFalse(state.controls.refreshEnabled)
+ assertFalse(state.controls.transcribeAllEnabled)
+ }
+
@Test
fun installedModelDoesNotRequireLoadedRuntimeForActions() {
val unloaded = state(
@@ -73,16 +106,16 @@ class MainScreenStateControllerTest {
@Test
fun controlsAreDisabledDuringActiveWorkOrQueuedScan() {
- assertFalse(
- state(
- input(
- outputSelected = true,
- folderSelected = true,
- pendingCount = 2,
- transcriptionState = TranscriptionObservationState.ACTIVE,
- ),
- ).controls.transcribeAllEnabled,
+ val active = state(
+ input(
+ modelReady = false,
+ pendingCount = 2,
+ transcriptionState = TranscriptionObservationState.ACTIVE,
+ ),
)
+ assertFalse(active.controls.outputEnabled)
+ assertFalse(active.controls.folderEnabled)
+ assertFalse(active.controls.transcribeAllEnabled)
val queued = state(
input(
@@ -282,6 +315,8 @@ class MainScreenStateControllerTest {
modelRuntimeState: SpeechModelRuntimeState = SpeechModelRuntimeState.UNLOADED,
outputSelected: Boolean = false,
folderSelected: Boolean = false,
+ outputReady: Boolean = outputSelected,
+ folderReady: Boolean = folderSelected,
pendingCount: Int = 0,
folderChecking: Boolean = false,
folderScanQueued: Boolean = false,
@@ -303,6 +338,7 @@ class MainScreenStateControllerTest {
activePreviewEntryId: Long? = null,
previewState: PreviewPlaybackState = PreviewPlaybackState.IDLE,
rows: List = emptyList(),
+ importInboxAvailable: Boolean = false,
) = MainScreenInput(
modelMessage = modelMessage,
modelInstallationState = when {
@@ -315,6 +351,8 @@ class MainScreenStateControllerTest {
modelDownloadProgress = modelDownloadProgress,
outputSelected = outputSelected,
folderSelected = folderSelected,
+ outputReady = outputReady,
+ folderReady = folderReady,
pendingCount = pendingCount,
folderChecking = folderChecking,
folderScanQueued = folderScanQueued,
@@ -336,6 +374,7 @@ class MainScreenStateControllerTest {
activePreviewEntryId = activePreviewEntryId,
previewState = previewState,
rows = rows,
+ importInboxAvailable = importInboxAvailable,
)
private fun row(
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRulesTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRulesTest.kt
index 89d6a59..284b396 100644
--- a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRulesTest.kt
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TranscriptionUiRulesTest.kt
@@ -5,10 +5,27 @@ import kotlin.test.assertEquals
import kotlin.test.Test
class TranscriptionUiRulesTest {
+ @Test
+ fun importedAudioAllowsTranscriptionWithoutSelectedFolder() {
+ val controls = TranscriptionUiRules.catalogControls(
+ modelInstallationState = SpeechModelInstallationState.INSTALLED,
+ outputSelected = true,
+ folderSelected = false,
+ pendingCount = 1,
+ transcriptionState = TranscriptionObservationState.IDLE,
+ scanning = false,
+ audioInputAvailable = true,
+ )
+
+ assertEquals(true, controls.transcribeAllEnabled)
+ assertEquals(true, controls.retryEnabled)
+ assertEquals(false, controls.refreshEnabled)
+ }
+
@Test
fun controlsRequireModelSelectionsPendingWorkAndIdleState() {
assertEquals(
- CatalogControlState(false, false, true, true, false, false, false),
+ CatalogControlState(true, true, true, true, false, false, false),
controls(modelReady = false),
)
assertEquals(
@@ -39,6 +56,46 @@ class TranscriptionUiRulesTest {
)
}
+ @Test
+ fun storageSelectionDoesNotRequireAnInstalledModel() {
+ listOf(
+ SpeechModelInstallationState.NOT_INSTALLED,
+ SpeechModelInstallationState.INSTALLING,
+ SpeechModelInstallationState.INVALID,
+ SpeechModelInstallationState.INSTALLED,
+ ).forEach { modelState ->
+ val controls = TranscriptionUiRules.catalogControls(
+ modelInstallationState = modelState,
+ outputSelected = false,
+ folderSelected = false,
+ pendingCount = 1,
+ transcriptionState = TranscriptionObservationState.IDLE,
+ scanning = false,
+ )
+
+ assertEquals(true, controls.outputEnabled, modelState.name)
+ assertEquals(true, controls.folderEnabled, modelState.name)
+ assertEquals(false, controls.refreshEnabled, modelState.name)
+ assertEquals(false, controls.transcribeAllEnabled, modelState.name)
+ assertEquals(false, controls.retryEnabled, modelState.name)
+ }
+ }
+
+ @Test
+ fun activeWorkBlocksStorageSelectionWithoutAnInstalledModel() {
+ val controls = TranscriptionUiRules.catalogControls(
+ modelInstallationState = SpeechModelInstallationState.INSTALLING,
+ outputSelected = false,
+ folderSelected = false,
+ pendingCount = 0,
+ transcriptionState = TranscriptionObservationState.ACTIVE,
+ scanning = false,
+ )
+
+ assertEquals(false, controls.outputEnabled)
+ assertEquals(false, controls.folderEnabled)
+ }
+
@Test
fun selectionControlsAreDisabledWhileScanningOrTranscribing() {
assertEquals(
diff --git a/shared/src/jvmTest/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepositoryTest.kt b/shared/src/jvmTest/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepositoryTest.kt
index b15eade..228901e 100644
--- a/shared/src/jvmTest/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepositoryTest.kt
+++ b/shared/src/jvmTest/kotlin/me/maxistar/voiceinbox/core/SqlDelightAudioCatalogRepositoryTest.kt
@@ -121,6 +121,31 @@ class SqlDelightAudioCatalogRepositoryTest {
assertEquals(listOf("new.wav", "processed.wav"), rows.map { it.displayName })
}
+ @Test
+ fun activeSourceScopeMergesListsCountsAndClaimsWithoutTouchingOtherSources() {
+ val repository = repository()
+ val folder = imported(repository, "folder.wav", modified = 10, folderUri = FOLDER)
+ val imported = imported(repository, "voice.ogg", modified = 20, folderUri = IMPORTS)
+ imported(repository, "inactive.wav", modified = 30, folderUri = OTHER_FOLDER)
+ val failed = imported(
+ repository,
+ "failed.opus",
+ modified = 40,
+ folderUri = IMPORTS,
+ state = AudioFileState.FAILED,
+ lastError = "decode failed",
+ )
+ val scope = AudioCatalogSourceScope.of(listOf(FOLDER, IMPORTS))
+
+ assertEquals(listOf(imported.id, folder.id), repository.newEntries(scope).map { it.id })
+ assertEquals(listOf(failed.id), repository.processedEntries(scope).map { it.id })
+ assertEquals(2, repository.pendingCount(scope))
+ assertEquals(folder.id, repository.claimPending(scope)?.id)
+ assertEquals(imported.id, repository.claimPending(scope)?.id)
+ assertNull(repository.claimPending(scope))
+ assertEquals(failed.id, repository.claimFailed(scope, failed.id)?.id)
+ }
+
private fun repository(): SqlDelightAudioCatalogRepository {
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
VoiceInboxDatabase.Schema.create(driver)
@@ -135,9 +160,10 @@ class SqlDelightAudioCatalogRepositoryTest {
lastError: String? = null,
processedAtMillis: Long? = null,
transcriptText: String? = null,
+ folderUri: String = FOLDER,
): SqlDelightAudioCatalogFile =
repository.upsertImportedFile(
- folderUri = FOLDER,
+ folderUri = folderUri,
documentUri = name,
displayName = name,
mimeType = "audio/wav",
@@ -161,5 +187,7 @@ class SqlDelightAudioCatalogRepositoryTest {
private companion object {
const val FOLDER = "ios-imported-audio"
+ const val IMPORTS = "android-imported-audio"
+ const val OTHER_FOLDER = "content://other-folder"
}
}
From a65b7072c14e3bfeacb722b5b1eede62cc98a785 Mon Sep 17 00:00:00 2001
From: Max Starikov
Date: Thu, 16 Jul 2026 11:02:20 +0200
Subject: [PATCH 07/10] add local file
---
.../MainActivityInstrumentedTest.kt | 83 ++++++++++
...peechModelLocalImporterInstrumentedTest.kt | 90 +++++++++++
.../me/maxistar/voiceinbox/MainActivity.kt | 96 +++++++++--
.../voiceinbox/SpeechModelDirectoryReader.kt | 74 +++++++++
.../voiceinbox/SpeechModelDownloadWorker.kt | 47 +-----
.../voiceinbox/SpeechModelImportPermission.kt | 31 ++++
.../voiceinbox/SpeechModelImportWorker.kt | 83 ++++++++++
.../voiceinbox/SpeechModelInstallationWork.kt | 42 +++++
.../voiceinbox/SpeechModelLocalImporter.kt | 93 +++++++++++
.../voiceinbox/SpeechModelRepository.kt | 100 +++++++++++-
app/src/main/res/layout/activity_main.xml | 8 +
app/src/main/res/values/strings.xml | 1 +
.../SpeechModelDirectoryReaderTest.kt | 75 +++++++++
.../SpeechModelInstallationWorkTest.kt | 17 ++
.../SpeechModelLocalImporterTest.kt | 153 ++++++++++++++++++
.../voiceinbox/SpeechModelRepositoryTest.kt | 93 +++++++++++
16 files changed, 1028 insertions(+), 58 deletions(-)
create mode 100644 app/src/androidTest/java/me/maxistar/voiceinbox/SpeechModelLocalImporterInstrumentedTest.kt
create mode 100644 app/src/main/java/me/maxistar/voiceinbox/SpeechModelDirectoryReader.kt
create mode 100644 app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportPermission.kt
create mode 100644 app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt
create mode 100644 app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt
create mode 100644 app/src/main/java/me/maxistar/voiceinbox/SpeechModelLocalImporter.kt
create mode 100644 app/src/test/java/me/maxistar/voiceinbox/SpeechModelDirectoryReaderTest.kt
create mode 100644 app/src/test/java/me/maxistar/voiceinbox/SpeechModelInstallationWorkTest.kt
create mode 100644 app/src/test/java/me/maxistar/voiceinbox/SpeechModelLocalImporterTest.kt
diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
index 1ef5f65..e747c0e 100644
--- a/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
+++ b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
@@ -87,6 +87,87 @@ class MainActivityInstrumentedTest {
}
}
+ @Test
+ fun missingModelShowsDownloadAndLocalImportActions() {
+ clearActivityState()
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ scenario.onActivity { activity ->
+ MainActivity::class.java.getDeclaredField("modelReady")
+ .apply { isAccessible = true }.setBoolean(activity, false)
+ MainActivity::class.java.getDeclaredField("modelLoading")
+ .apply { isAccessible = true }.setBoolean(activity, false)
+ MainActivity::class.java.getDeclaredField("modelDownloadAvailable")
+ .apply { isAccessible = true }.setBoolean(activity, true)
+ MainActivity::class.java.getDeclaredMethod("renderStatusBlock")
+ .apply { isAccessible = true }.invoke(activity)
+ }
+
+ onView(withId(R.id.downloadModel)).check(matches(isDisplayed()))
+ onView(withId(R.id.importModel)).check(matches(isDisplayed()))
+ onView(withId(R.id.importModel)).check(matches(isEnabled()))
+ }
+ }
+
+ @Test
+ fun activeModelInstallationRemainsVisibleAcrossRecreation() {
+ 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 ->
+ !activity.findViewById(R.id.downloadModel).isShown &&
+ !activity.findViewById(R.id.importModel).isShown
+ }
+ scenario.recreate()
+ awaitActivity(scenario) { activity ->
+ !activity.findViewById(R.id.downloadModel).isShown &&
+ !activity.findViewById(R.id.importModel).isShown &&
+ activity.findViewById(R.id.statusProgress).isShown
+ }
+ }
+ }
+
+ @Test
+ 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 transcribeAllIsVisibleOnlyOnNewTabWhenPendingWorkExists() {
clearActivityState()
@@ -405,6 +486,8 @@ class MainActivityInstrumentedTest {
.cancelUniqueWork(TranscriptionWorker.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()
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/main/java/me/maxistar/voiceinbox/MainActivity.kt b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
index a3bcf79..c5b4125 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
@@ -34,6 +34,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
private lateinit var statusProgress: ProgressBar
private lateinit var statusMeta: TextView
private lateinit var downloadModel: Button
+ private lateinit var importModel: Button
private lateinit var transcribeAll: Button
private lateinit var selectOutput: Button
private lateinit var selectFolder: Button
@@ -121,6 +122,12 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
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()
@@ -156,6 +163,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
)
downloadModel.setOnClickListener { SpeechModelDownloadWorker.enqueue(this) }
+ importModel.setOnClickListener {
+ if (modelDownloadAvailable && !modelLoading) modelFolderPicker.launch(null)
+ }
transcribeAll.setOnClickListener { startBatchTranscription() }
importAudio.setOnClickListener {
if (!ingestionActive) importPicker.launch(arrayOf("audio/*", "application/ogg"))
@@ -240,6 +250,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
statusProgress = findViewById(R.id.statusProgress)
statusMeta = findViewById(R.id.statusMeta)
downloadModel = findViewById(R.id.downloadModel)
+ importModel = findViewById(R.id.importModel)
transcribeAll = findViewById(R.id.transcribeAll)
selectOutput = findViewById(R.id.selectOutput)
selectFolder = findViewById(R.id.selectFolder)
@@ -503,6 +514,55 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
}
}
+ 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) {
+ setModelUi(
+ "The selected model folder cannot be accessed after the picker closes",
+ loading = false,
+ canDownload = true,
+ )
+ updateControls()
+ return
+ }
+ if (!alreadyPersisted) SpeechModelImportPermission.recordOwned(this, uri)
+ statusError = null
+ setModelUi("Checking local speech model", loading = true, 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
+ setModelUi(
+ error.message ?: "The selected model folder is not readable",
+ loading = false,
+ canDownload = true,
+ )
+ updateControls()
+ }
+ }
+ }
+ }
+
private fun scanFolder(origin: FolderScanOrigin = FolderScanOrigin.USER) {
val folder = folderUri ?: return
if (
@@ -819,9 +879,13 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
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,
@@ -829,14 +893,14 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
-> {
modelReady = false
startupCoordinator.setModelReady(false)
- val bytes = info.progress.getLong(SpeechModelDownloadWorker.KEY_BYTES_DOWNLOADED, 0)
+ 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"
+ info.progress.getString(SpeechModelInstallationWork.KEY_MESSAGE)
+ ?: "Installing speech model"
modelLoading = true
modelDownloadAvailable = false
modelDownloadProgress = ((bytes * 100) / total.coerceAtLeast(1)).toInt()
@@ -845,25 +909,31 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
evaluateStartupProcessing()
}
WorkInfo.State.SUCCEEDED -> {
- if (shouldHandleModelInstallSuccess(info.id.toString())) {
- modelReadiness.invalidate()
- SpeechModelPreparation.invalidate(NativeTranscriptionBridge::reset)
+ SpeechModelImportPermission.releaseOwnedIfUnused(this)
+ if (shouldHandleModelInstallSuccess(info.id.toString())) {
+ modelReadiness.invalidate()
+ SpeechModelPreparation.invalidate(NativeTranscriptionBridge::reset)
}
refreshModel()
}
WorkInfo.State.FAILED -> {
+ SpeechModelImportPermission.releaseOwnedIfUnused(this)
modelReady = false
startupCoordinator.setModelReady(false)
setModelUi(
- info.outputData.getString(SpeechModelDownloadWorker.KEY_ERROR)
- ?: "Speech model download failed",
+ info.outputData.getString(SpeechModelInstallationWork.KEY_ERROR)
+ ?: "Speech model installation failed",
false,
true,
)
updateControls()
evaluateStartupProcessing()
}
- else -> Unit
+ WorkInfo.State.CANCELLED -> {
+ SpeechModelImportPermission.releaseOwnedIfUnused(this)
+ modelReadiness.invalidate()
+ refreshModel()
+ }
}
}
}
@@ -1152,6 +1222,8 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
statusProgress.isIndeterminate = state.progressIndeterminate
statusProgress.progress = state.progress
downloadModel.visibility = if (state.downloadVisible) View.VISIBLE else View.GONE
+ importModel.visibility = if (state.downloadVisible) View.VISIBLE else View.GONE
+ importModel.isEnabled = modelDownloadAvailable && !modelLoading
}
private fun setOptionalText(view: TextView, value: String?) {
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 ed5a918..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")
}
@@ -134,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/SpeechModelRepository.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelRepository.kt
index a25bba7..294b21a 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelRepository.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelRepository.kt
@@ -27,11 +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)
@@ -40,6 +45,7 @@ class SpeechModelRepository(
get() = File(installedRoot, manifest.version)
fun inspectLightweight(): InstalledSpeechModelState {
+ recoverInterruptedActivation()
invalidModelFile.takeIf(File::isFile)?.readText()?.trim()?.takeIf(String::isNotEmpty)?.let {
return InstalledSpeechModelState.Invalid(it)
}
@@ -60,6 +66,7 @@ class SpeechModelRepository(
}
fun inspect(): InstalledSpeechModelState {
+ recoverInterruptedActivation()
val activeVersion = activeVersionFile.takeIf(File::isFile)?.readText()?.trim()
if (activeVersion == manifest.version) {
return recordValidation(validateDirectory(installedDirectory))
@@ -96,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 =
@@ -115,21 +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)
- invalidModelFile.delete()
installedRoot.listFiles()
?.filter { it.name != manifest.version }
@@ -147,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
@@ -216,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/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 4e0ae2e..86a4b5e 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -60,6 +60,14 @@
android:text="@string/download_speech_model"
android:visibility="gone" />
+
+
No readable audio was shared.
Checking speech model
Download speech model
+ Install from folder
New
Processed
No new audio files
diff --git a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelDirectoryReaderTest.kt b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelDirectoryReaderTest.kt
new file mode 100644
index 0000000..e86b277
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelDirectoryReaderTest.kt
@@ -0,0 +1,75 @@
+package me.maxistar.voiceinbox
+
+import android.provider.DocumentsContract
+import me.maxistar.voiceinbox.core.SpeechModelFile
+import me.maxistar.voiceinbox.core.SpeechModelManifest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class SpeechModelDirectoryReaderTest {
+ @Test
+ fun exactDirectFilesAreMatchedByManifestName() {
+ val documents = listOf(
+ document("model.bin"),
+ document("config.json"),
+ document("nested", DocumentsContract.Document.MIME_TYPE_DIR),
+ document("unrelated.txt"),
+ )
+
+ val matched = SpeechModelDirectoryReader.matchRequiredDocuments(documents, manifest)
+
+ assertEquals(setOf("model.bin", "config.json"), matched.keys)
+ }
+
+ @Test
+ fun missingAndDuplicateRequiredFilesAreRejected() {
+ assertTrue(
+ runCatching {
+ SpeechModelDirectoryReader.matchRequiredDocuments(
+ listOf(document("model.bin")),
+ manifest,
+ )
+ }.exceptionOrNull()?.message?.contains("config.json") == true,
+ )
+ assertTrue(
+ runCatching {
+ SpeechModelDirectoryReader.matchRequiredDocuments(
+ listOf(document("model.bin"), document("model.bin"), document("config.json")),
+ manifest,
+ )
+ }.exceptionOrNull()?.message?.contains("Multiple") == true,
+ )
+ }
+
+ @Test
+ fun directoryWithRequiredNameDoesNotCountAsAFile() {
+ val error = runCatching {
+ SpeechModelDirectoryReader.matchRequiredDocuments(
+ listOf(
+ document("model.bin", DocumentsContract.Document.MIME_TYPE_DIR),
+ document("config.json"),
+ ),
+ manifest,
+ )
+ }.exceptionOrNull()
+
+ assertTrue(error?.message?.contains("model.bin") == true)
+ }
+
+ private fun document(name: String, mime: String = "application/octet-stream") =
+ SpeechModelSourceDocument(name, "content://test/$name/${System.nanoTime()}", mime)
+
+ companion object {
+ private val manifest = SpeechModelManifest(
+ modelId = "test/model",
+ version = "test",
+ repositoryRevision = "revision",
+ files = listOf(
+ SpeechModelFile("model.bin", 1, "hash"),
+ SpeechModelFile("config.json", 1, "hash"),
+ ),
+ safetyMarginBytes = 0,
+ )
+ }
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelInstallationWorkTest.kt b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelInstallationWorkTest.kt
new file mode 100644
index 0000000..fd752c3
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelInstallationWorkTest.kt
@@ -0,0 +1,17 @@
+package me.maxistar.voiceinbox
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class SpeechModelInstallationWorkTest {
+ @Test
+ fun downloadCompatibilityAliasesKeepTheirHistoricalValues() {
+ assertEquals("speech-model-installation", SpeechModelInstallationWork.UNIQUE_WORK_NAME)
+ assertEquals(SpeechModelInstallationWork.UNIQUE_WORK_NAME, SpeechModelDownloadWorker.UNIQUE_WORK_NAME)
+ assertEquals(SpeechModelInstallationWork.KEY_BYTES_DOWNLOADED, SpeechModelDownloadWorker.KEY_BYTES_DOWNLOADED)
+ assertEquals(SpeechModelInstallationWork.KEY_TOTAL_BYTES, SpeechModelDownloadWorker.KEY_TOTAL_BYTES)
+ assertEquals(SpeechModelInstallationWork.KEY_MESSAGE, SpeechModelDownloadWorker.KEY_MESSAGE)
+ assertEquals(SpeechModelInstallationWork.KEY_ERROR, SpeechModelDownloadWorker.KEY_ERROR)
+ assertEquals(SpeechModelInstallationWork.KEY_MODEL_PATH, SpeechModelDownloadWorker.KEY_MODEL_PATH)
+ }
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelLocalImporterTest.kt b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelLocalImporterTest.kt
new file mode 100644
index 0000000..2032a4a
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelLocalImporterTest.kt
@@ -0,0 +1,153 @@
+package me.maxistar.voiceinbox
+
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.CancellationException
+import me.maxistar.voiceinbox.core.SpeechModelFile
+import me.maxistar.voiceinbox.core.SpeechModelManifest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.ByteArrayInputStream
+import java.io.File
+import java.io.IOException
+import java.security.MessageDigest
+
+class SpeechModelLocalImporterTest {
+ @get:Rule
+ val temporaryFolder = TemporaryFolder()
+
+ @Test
+ fun validSourceIsCopiedVerifiedAndActivatedWithProgress() = runBlocking {
+ val sources = sourceUris()
+ val progress = mutableListOf()
+ val repository = repository()
+ val importer = importer(repository, sources) { uri ->
+ ByteArrayInputStream(testFiles.getValue(uri.substringAfterLast('/')))
+ }
+
+ val installed = importer.import(treeUri) { progress += it }.getOrThrow()
+
+ assertTrue(installed.isDirectory)
+ testFiles.forEach { (name, contents) ->
+ assertTrue(installed.resolve(name).readBytes().contentEquals(contents))
+ }
+ assertEquals(testManifest.totalSizeBytes, progress.last().bytesCopied)
+ assertTrue(repository.inspect() is InstalledSpeechModelState.Ready)
+ }
+
+ @Test
+ fun invalidPayloadIsRejectedAndTemporaryFileIsRemoved() = runBlocking {
+ val sources = sourceUris()
+ val repository = repository()
+ val importer = importer(repository, sources) { uri ->
+ val name = uri.substringAfterLast('/')
+ val bytes = if (name == "model.bin") "wrong".toByteArray()
+ else testFiles.getValue(name)
+ ByteArrayInputStream(bytes)
+ }
+
+ val result = importer.import(treeUri) {}
+
+ assertTrue(result.isFailure)
+ assertFalse(repository.temporaryFile(testManifest.files.first()).exists())
+ assertFalse(repository.installedDirectory.exists())
+ }
+
+ @Test
+ fun providerFailureIsRecoverableAndExistingModelSurvives() = runBlocking {
+ val repository = repository()
+ repository.prepareForInstall().getOrThrow()
+ testFiles.forEach { (name, bytes) -> repository.stagingDirectory.resolve(name).writeBytes(bytes) }
+ repository.activate().getOrThrow()
+ val importer = importer(repository, sourceUris()) { throw IOException("provider disconnected") }
+
+ val result = importer.import(treeUri) {}
+
+ assertTrue(result.exceptionOrNull()?.message?.contains("provider disconnected") == true)
+ assertTrue(repository.inspect() is InstalledSpeechModelState.Ready)
+ }
+
+ @Test
+ fun missingSourceMapCannotBeCompletedByOldStaging() = runBlocking {
+ val repository = repository()
+ repository.prepareForInstall().getOrThrow()
+ testFiles.forEach { (name, bytes) -> repository.stagingDirectory.resolve(name).writeBytes(bytes) }
+ val incomplete = sourceUris().filterKeys { it != "config.json" }
+ val importer = importer(repository, incomplete) { ByteArrayInputStream(byteArrayOf()) }
+
+ val result = importer.import(treeUri) {}
+
+ assertTrue(result.isFailure)
+ assertFalse(repository.installedDirectory.exists())
+ assertTrue(repository.stagingDirectory.listFiles().orEmpty().isEmpty())
+ }
+
+ @Test
+ fun cancellationIsPropagatedAndPartialFileIsRemoved() = runBlocking {
+ val repository = repository()
+ val importer = importer(repository, sourceUris()) { uri ->
+ ByteArrayInputStream(testFiles.getValue(uri.substringAfterLast('/')))
+ }
+
+ var cancelled = false
+ try {
+ importer.import(treeUri) { progress ->
+ if (progress.bytesCopied > 0) throw CancellationException("test cancellation")
+ }
+ } catch (_: CancellationException) {
+ cancelled = true
+ }
+
+ assertTrue(cancelled)
+ assertFalse(repository.temporaryFile(testManifest.files.first()).exists())
+ assertFalse(repository.installedDirectory.exists())
+ }
+
+ private fun importer(
+ repository: SpeechModelRepository,
+ sources: Map,
+ open: (String) -> ByteArrayInputStream,
+ ) = SpeechModelLocalImporter(
+ repository = repository,
+ requiredDocuments = { _, manifest ->
+ manifest.files.associate { entry ->
+ entry.name to (sources[entry.name]
+ ?: throw IOException("${entry.name} is missing from the selected model folder"))
+ }
+ },
+ openInputStream = open,
+ )
+
+ private fun repository() = SpeechModelRepository(
+ root = File(temporaryFolder.root, "models"),
+ manifest = testManifest,
+ usableSpace = { Long.MAX_VALUE },
+ )
+
+ private fun sourceUris() = testFiles.keys.associateWith { "content://test/$it" }
+
+ companion object {
+ private const val treeUri = "content://test/tree/model"
+ private val testFiles = linkedMapOf(
+ "model.bin" to "model".toByteArray(),
+ "config.json" to "{}".toByteArray(),
+ )
+ private val testManifest = SpeechModelManifest(
+ modelId = "example/model",
+ version = "test-version",
+ repositoryRevision = "revision",
+ files = testFiles.map { (name, contents) ->
+ SpeechModelFile(
+ name,
+ contents.size.toLong(),
+ MessageDigest.getInstance("SHA-256").digest(contents)
+ .joinToString("") { "%02x".format(it) },
+ )
+ },
+ safetyMarginBytes = 8,
+ )
+ }
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelRepositoryTest.kt b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelRepositoryTest.kt
index 51889d5..6f2b40b 100644
--- a/app/src/test/java/me/maxistar/voiceinbox/SpeechModelRepositoryTest.kt
+++ b/app/src/test/java/me/maxistar/voiceinbox/SpeechModelRepositoryTest.kt
@@ -3,6 +3,7 @@ package me.maxistar.voiceinbox
import me.maxistar.voiceinbox.core.*
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -126,6 +127,98 @@ class SpeechModelRepositoryTest {
assertTrue(repository.prepareForInstall().isFailure)
}
+ @Test
+ fun freshImportClearsCurrentStagingAndChecksFullModelSpace() {
+ val repository = repository()
+ repository.prepareForInstall().getOrThrow()
+ writeValidStaging(repository)
+
+ repository.prepareFreshImport().getOrThrow()
+
+ assertTrue(repository.stagingDirectory.isDirectory)
+ assertTrue(repository.stagingDirectory.listFiles().orEmpty().isEmpty())
+
+ val insufficient = SpeechModelRepository(
+ root = File(temporaryFolder.root, "small-models"),
+ manifest = testManifest,
+ usableSpace = { testManifest.requiredFreeBytes - 1 },
+ )
+ assertTrue(insufficient.prepareFreshImport().isFailure)
+ }
+
+ @Test
+ fun verifiedTemporaryFileIsAcceptedIntoStaging() {
+ val repository = repository()
+ repository.prepareFreshImport().getOrThrow()
+ val entry = testManifest.files.first()
+ repository.temporaryFile(entry).writeBytes(testFiles.getValue(entry.name))
+
+ val accepted = repository.acceptTemporaryFile(entry).getOrThrow()
+
+ assertEquals(repository.stagingFile(entry), accepted)
+ assertTrue(accepted.isFile)
+ assertFalse(repository.temporaryFile(entry).exists())
+ }
+
+ @Test
+ fun activationFailureRestoresPreviousValidModel() {
+ val initial = repository()
+ initial.prepareForInstall().getOrThrow()
+ writeValidStaging(initial)
+ initial.activate().getOrThrow()
+
+ val failing = SpeechModelRepository(
+ root = File(temporaryFolder.root, "models"),
+ manifest = testManifest,
+ usableSpace = { Long.MAX_VALUE },
+ moveDirectory = { source, destination ->
+ if (source.path.contains("${File.separator}staging${File.separator}")) false
+ else source.renameTo(destination)
+ },
+ )
+ failing.prepareFreshImport().getOrThrow()
+ writeValidStaging(failing)
+
+ assertTrue(failing.activate().isFailure)
+ assertTrue(repository().inspect() is InstalledSpeechModelState.Ready)
+ }
+
+ @Test
+ fun interruptedReplacementRestoresBackupDuringInspection() {
+ val repository = repository()
+ repository.prepareForInstall().getOrThrow()
+ writeValidStaging(repository)
+ val installed = repository.activate().getOrThrow()
+ val root = File(temporaryFolder.root, "models")
+ val backup = File(root, "installed/${testManifest.version}.backup")
+ assertTrue(installed.renameTo(backup))
+ installed.mkdirs()
+ installed.resolve("model.bin").writeText("partial")
+ File(root, "activation-model").writeText("replacement")
+
+ val recovered = repository().inspectLightweight()
+
+ assertTrue(recovered is InstalledSpeechModelState.Ready)
+ assertFalse(backup.exists())
+ assertFalse(File(root, "activation-model").exists())
+ assertEquals("model", installed.resolve("model.bin").readText())
+ }
+
+ @Test
+ fun staleBackupIsRemovedAfterCompletedActivation() {
+ val repository = repository()
+ repository.prepareForInstall().getOrThrow()
+ writeValidStaging(repository)
+ val installed = repository.activate().getOrThrow()
+ val backup = File(temporaryFolder.root, "models/installed/${testManifest.version}.backup")
+ installed.copyRecursively(backup)
+
+ repository.cleanupStaleState()
+
+ assertFalse(backup.exists())
+ assertTrue(repository.inspectLightweight() is InstalledSpeechModelState.Ready)
+ }
+
private fun repository() = SpeechModelRepository(
root = File(temporaryFolder.root, "models"),
manifest = testManifest,
From 8901d1a4ef5ba9c786c602224fbae951172049f0 Mon Sep 17 00:00:00 2001
From: Max Starikov
Date: Thu, 16 Jul 2026 13:00:39 +0200
Subject: [PATCH 08/10] add list ui
---
app/build.gradle.kts | 3 +
.../MainActivityInstrumentedTest.kt | 628 ++++-------------
.../voiceinbox/AndroidMainScreenStateHost.kt | 127 ++++
.../voiceinbox/AndroidTaskActionRouter.kt | 59 ++
.../voiceinbox/CatalogRefreshPolicy.kt | 6 +-
.../me/maxistar/voiceinbox/MainActivity.kt | 647 +++++++-----------
.../me/maxistar/voiceinbox/TaskListAdapter.kt | 306 +++++++++
.../voiceinbox/TranscriptionWorker.kt | 18 +-
app/src/main/res/drawable/ic_add_24.xml | 10 +
app/src/main/res/layout/activity_main.xml | 203 ++----
app/src/main/res/layout/row_batch_action.xml | 7 +
app/src/main/res/layout/row_empty_task.xml | 24 +
app/src/main/res/layout/row_task.xml | 83 +++
app/src/main/res/values/strings.xml | 5 +
app/src/main/res/values/themes.xml | 7 +-
.../AndroidMainScreenStateHostTest.kt | 184 +++++
.../voiceinbox/AndroidTaskActionRouterTest.kt | 116 ++++
.../voiceinbox/CatalogRefreshPolicyTest.kt | 17 +-
.../voiceinbox/TaskListDisplayItemsTest.kt | 99 +++
gradle/libs.versions.toml | 5 +
iosApp/VoiceInbox/ContentView.swift | 84 ++-
.../VoiceInbox/IosMainScreenShellState.swift | 7 +-
.../DeferredSpeechModelLoadingTests.swift | 2 +
.../core/BatchTranscriptionUseCase.kt | 1 +
.../core/TaskListPresentationController.kt | 16 +-
.../core/BatchTranscriptionUseCaseTest.kt | 18 +
.../TaskListPresentationControllerTest.kt | 54 ++
27 files changed, 1660 insertions(+), 1076 deletions(-)
create mode 100644 app/src/main/java/me/maxistar/voiceinbox/AndroidMainScreenStateHost.kt
create mode 100644 app/src/main/java/me/maxistar/voiceinbox/AndroidTaskActionRouter.kt
create mode 100644 app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt
create mode 100644 app/src/main/res/drawable/ic_add_24.xml
create mode 100644 app/src/main/res/layout/row_batch_action.xml
create mode 100644 app/src/main/res/layout/row_empty_task.xml
create mode 100644 app/src/main/res/layout/row_task.xml
create mode 100644 app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt
create mode 100644 app/src/test/java/me/maxistar/voiceinbox/AndroidTaskActionRouterTest.kt
create mode 100644 app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt
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/MainActivityInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
index e747c0e..b271e00 100644
--- a/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
+++ b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
@@ -1,32 +1,30 @@
package me.maxistar.voiceinbox
-import me.maxistar.voiceinbox.core.*
-
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
-import android.widget.Button
-import android.widget.LinearLayout
-import android.view.ViewGroup
+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.isEnabled
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 androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
-import org.hamcrest.Matchers.containsString
-import org.hamcrest.Matchers.not
+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.assertTrue
@@ -37,79 +35,83 @@ import java.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class)
class MainActivityInstrumentedTest {
@Test
- fun selectionSummariesAndCurrentMenuActionsAreVisible() {
+ 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()))
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
onView(withId(R.id.importAudio)).check(matches(isDisplayed()))
- onView(withContentDescription(R.string.menu_refresh_folder)).check(matches(isDisplayed()))
- onView(withId(R.id.transcribeAll)).check(matches(not(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()))
- openActionBarOverflowOrOptionsMenu(
- InstrumentationRegistry.getInstrumentation().targetContext,
- )
+ 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 storageSetupIgnoresModelReadinessButRespectsActiveWork() {
+ fun setupSelectionActionsRemainAvailableBeforeModelReadiness() {
clearActivityState()
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
scenario.onActivity { activity ->
- MainActivity::class.java.getDeclaredField("modelReady")
- .apply { isAccessible = true }
- .setBoolean(activity, false)
- MainActivity::class.java.getDeclaredField("modelLoading")
- .apply { isAccessible = true }
- .setBoolean(activity, true)
- MainActivity::class.java.getDeclaredField("transcriptionState")
- .apply { isAccessible = true }
- .set(activity, TranscriptionObservationState.IDLE)
- MainActivity::class.java.getDeclaredMethod("updateControls")
- .apply { isAccessible = true }
- .invoke(activity)
+ setField(activity, "modelReady", false)
+ setField(activity, "modelSetupState", ModelSetupSnapshotState.REQUIRED)
+ setField(activity, "modelDownloadAvailable", true)
+ setField(activity, "transcriptionState", TranscriptionObservationState.IDLE)
+ invoke(activity, "updateControls")
}
- onView(withId(R.id.selectOutput)).check(matches(isEnabled()))
- onView(withId(R.id.selectFolder)).check(matches(isEnabled()))
-
- enqueueDelayedTranscription()
awaitActivity(scenario) { activity ->
- transcriptionState(activity) == TranscriptionObservationState.ACTIVE &&
- !activity.findViewById(R.id.selectOutput).isEnabled &&
- !activity.findViewById(R.id.selectFolder).isEnabled
+ 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 missingModelShowsDownloadAndLocalImportActions() {
+ 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 ->
- MainActivity::class.java.getDeclaredField("modelReady")
- .apply { isAccessible = true }.setBoolean(activity, false)
- MainActivity::class.java.getDeclaredField("modelLoading")
- .apply { isAccessible = true }.setBoolean(activity, false)
- MainActivity::class.java.getDeclaredField("modelDownloadAvailable")
- .apply { isAccessible = true }.setBoolean(activity, true)
- MainActivity::class.java.getDeclaredMethod("renderStatusBlock")
- .apply { isAccessible = true }.invoke(activity)
+ displayItems(activity).forEach { before[it.stableKey] = TaskListAdapter.stableLongId(it.stableKey) }
}
+ scenario.recreate()
- onView(withId(R.id.downloadModel)).check(matches(isDisplayed()))
- onView(withId(R.id.importModel)).check(matches(isDisplayed()))
- onView(withId(R.id.importModel)).check(matches(isEnabled()))
+ 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 activeModelInstallationRemainsVisibleAcrossRecreation() {
+ fun activeModelInstallationReattachesAcrossRecreation() {
clearActivityState()
val context = InstrumentationRegistry.getInstrumentation().targetContext
val request = OneTimeWorkRequestBuilder()
@@ -127,15 +129,51 @@ class MainActivityInstrumentedTest {
).result.get(30, TimeUnit.SECONDS)
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ awaitActivity(scenario) { activity -> activeModelRow(activity) }
+ scenario.recreate()
+ awaitActivity(scenario) { activity -> activeModelRow(activity) }
+ }
+ }
+
+ @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 ->
- !activity.findViewById(R.id.downloadModel).isShown &&
- !activity.findViewById(R.id.importModel).isShown
+ audioTitles(activity).toSet() == setOf("processing.ogg", "pending.ogg")
}
scenario.recreate()
awaitActivity(scenario) { activity ->
- !activity.findViewById(R.id.downloadModel).isShown &&
- !activity.findViewById(R.id.importModel).isShown &&
- activity.findViewById(R.id.statusProgress).isShown
+ 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 }
}
}
}
@@ -168,334 +206,14 @@ class MainActivityInstrumentedTest {
assertEquals(importRequest.id, active.single().id)
}
- @Test
- fun transcribeAllIsVisibleOnlyOnNewTabWhenPendingWorkExists() {
- 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()))
-
- scenario.onActivity { activity ->
- activity.findViewById(R.id.processedTab).performClick()
- MainActivity::class.java.getDeclaredMethod("updateControls")
- .apply { isAccessible = true }
- .invoke(activity)
- }
-
- onView(withId(R.id.transcribeAll)).check(matches(not(isDisplayed())))
- }
- }
-
- @Test
- fun activityRecreationKeepsStatusBlockVisible() {
- clearActivityState()
- ActivityScenario.launch(MainActivity::class.java).use { scenario ->
- scenario.recreate()
-
- onView(withId(R.id.statusTitle)).check(matches(isDisplayed()))
- }
- }
-
- @Test
- fun recreationRestoresFolderOutputAndProcessingRows() {
- clearActivityState()
- val folder = DocumentsContract.buildTreeDocumentUri(
- TestAudioDocumentsProvider.AUTHORITY,
- TestAudioDocumentsProvider.ROOT_ID,
- )
- val output = DocumentsContract.buildDocumentUri(
- TestAudioDocumentsProvider.AUTHORITY,
- "output",
- )
- ActivityScenario.launch(MainActivity::class.java).use { scenario ->
- scenario.onActivity { activity ->
- val store = DocumentSelectionStore(
- activity.getSharedPreferences(
- DocumentSelectionStore.PREFERENCES_NAME,
- Context.MODE_PRIVATE,
- ),
- )
- store.saveFolderUri(folder.toString())
- store.saveOutputUri(output.toString())
- MainActivity::class.java.getDeclaredMethod(
- "restoreSelections",
- Boolean::class.javaPrimitiveType,
- ).apply { isAccessible = true }.invoke(activity, false)
- }
- awaitActivity(scenario) { activity ->
- booleanField(activity, "folderAccessReady") &&
- booleanField(activity, "outputAccessReady")
- }
-
- seedCatalogEntry(folder, "processing.wav", AudioFileState.PROCESSING, 20)
- seedCatalogEntry(folder, "pending.wav", AudioFileState.PENDING, 10)
- enqueueDelayedTranscription()
- scenario.onActivity { activity -> invokeRefreshCatalog(activity) }
- awaitActivity(scenario) { activity ->
- transcriptionState(activity) == TranscriptionObservationState.ACTIVE &&
- rowNames(activity).containsAll(listOf("processing.wav", "pending.wav"))
- }
-
- scenario.recreate()
-
- awaitActivity(scenario) { activity ->
- booleanField(activity, "folderAccessReady") &&
- booleanField(activity, "outputAccessReady") &&
- transcriptionState(activity) == TranscriptionObservationState.ACTIVE &&
- rowNames(activity).containsAll(listOf("processing.wav", "pending.wav"))
- }
- onView(withText(containsString("Test audio folder"))).check(matches(isDisplayed()))
- onView(withText(containsString("transcripts.txt"))).check(matches(isDisplayed()))
- onView(withContentDescription("processing.wav")).check(matches(isDisplayed()))
- awaitActivity(scenario) { activity ->
- rowButton(activity, "processing.wav", "Play")?.isEnabled == false
- }
- }
- }
-
- @Test
- fun importOnlyProcessingRowsSurviveRecreationWithoutFolder() {
- clearActivityState()
- val context = InstrumentationRegistry.getInstrumentation().targetContext
- val output = DocumentsContract.buildDocumentUri(
- TestAudioDocumentsProvider.AUTHORITY,
- "output",
- )
- DocumentSelectionStore(
- context.getSharedPreferences(
- DocumentSelectionStore.PREFERENCES_NAME,
- Context.MODE_PRIVATE,
- ),
- ).saveOutputUri(output.toString())
- seedCatalogEntry(
- Uri.parse(AndroidAudioImportConstants.SOURCE_ID),
- "import-processing.ogg",
- AudioFileState.PROCESSING,
- 20,
- )
- seedCatalogEntry(
- Uri.parse(AndroidAudioImportConstants.SOURCE_ID),
- "import-pending.ogg",
- AudioFileState.PENDING,
- 10,
- )
-
- ActivityScenario.launch(MainActivity::class.java).use { scenario ->
- awaitRows(scenario, setOf("import-processing.ogg", "import-pending.ogg"))
- scenario.recreate()
- awaitRows(scenario, setOf("import-processing.ogg", "import-pending.ogg"))
-
- onView(withText(R.string.folder_not_selected)).check(matches(isDisplayed()))
- onView(withText(containsString("transcripts.txt"))).check(matches(isDisplayed()))
- onView(withContentDescription("import-processing.ogg")).check(matches(isDisplayed()))
- }
- }
-
- @Test
- fun transientFolderValidationFailureKeepsDurableSelection() {
- clearActivityState()
- val folder = DocumentsContract.buildTreeDocumentUri(
- TestAudioDocumentsProvider.AUTHORITY,
- TestAudioDocumentsProvider.ROOT_ID,
- )
- ActivityScenario.launch(MainActivity::class.java).use { scenario ->
- scenario.onActivity { activity ->
- DocumentSelectionStore(
- activity.getSharedPreferences(
- DocumentSelectionStore.PREFERENCES_NAME,
- Context.MODE_PRIVATE,
- ),
- ).saveFolderUri(folder.toString())
- MainActivity::class.java.getDeclaredField("selectionAccess")
- .apply { isAccessible = true }
- .set(
- activity,
- object : PersistedSelectionAccess(activity.contentResolver) {
- override fun validate(
- uri: Uri,
- requiredAccess: RequiredDocumentAccess,
- validation: () -> T,
- ): PersistedSelectionValidation = PersistedSelectionValidation(
- state = PersistedSelectionAccessState.TEMPORARILY_UNAVAILABLE,
- error = SecurityException("Temporary provider failure"),
- )
- },
- )
- MainActivity::class.java.getDeclaredMethod(
- "restoreSelections",
- Boolean::class.javaPrimitiveType,
- ).apply { isAccessible = true }.invoke(activity, false)
- }
- awaitActivity(scenario) { activity ->
- !booleanField(activity, "folderChecking") &&
- !booleanField(activity, "folderAccessReady")
- }
-
- scenario.onActivity { activity ->
- val stored = DocumentSelectionStore(
- activity.getSharedPreferences(
- DocumentSelectionStore.PREFERENCES_NAME,
- Context.MODE_PRIVATE,
- ),
- ).loadFolderUri()
- assertEquals(folder.toString(), stored)
- assertEquals(
- folder,
- MainActivity::class.java.getDeclaredField("folderUri")
- .apply { isAccessible = true }
- .get(activity),
- )
- }
- onView(withText(R.string.folder_temporarily_unavailable))
- .check(matches(isDisplayed()))
- }
- }
-
- @Test
- fun rememberedStartupPromptActionsPersistTheirPolicies() {
- clearActivityState()
- ActivityScenario.launch(MainActivity::class.java).use { scenario ->
- showStartupPrompt(scenario, pendingCount = 2)
- onView(withText(R.string.startup_processing_prompt_title)).check(matches(isDisplayed()))
- onView(withText(R.string.startup_processing_remember_choice)).perform(click())
- onView(withText(R.string.startup_processing_process_now)).perform(click())
- }
-
- val context = InstrumentationRegistry.getInstrumentation().targetContext
- val store = StartupProcessingPolicyStore(
- context.getSharedPreferences(StartupProcessingPolicyStore.PREFERENCES_NAME, Context.MODE_PRIVATE),
- )
- assertEquals(StartupProcessingPolicy.AUTOMATIC, store.load())
-
- clearActivityState()
- ActivityScenario.launch(MainActivity::class.java).use { scenario ->
- showStartupPrompt(scenario, pendingCount = 1)
- onView(withText(R.string.startup_processing_remember_choice)).perform(click())
- onView(withText(R.string.startup_processing_leave_queued)).perform(click())
- }
-
- assertEquals(StartupProcessingPolicy.LEAVE_QUEUED, store.load())
- }
-
- @Test
- fun startupPromptSurvivesActivityRecreationWithoutASecondDecision() {
- clearActivityState()
- ActivityScenario.launch(MainActivity::class.java).use { scenario ->
- showStartupPrompt(scenario, pendingCount = 2)
- scenario.recreate()
-
- onView(withText(R.string.startup_processing_prompt_title)).check(matches(isDisplayed()))
- onView(withText(R.string.startup_processing_leave_queued)).perform(click())
- }
- }
-
- @Test
- fun failedRowsShowErrorAndRetryAction() {
- clearActivityState()
- ActivityScenario.launch(MainActivity::class.java).use { scenario ->
- 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,
- )
- 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)
- }
-
- 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()))
- }
- }
-
- @Test
- fun processedRowContextMenuShowsStoredTranscriptText() {
- clearActivityState()
- ActivityScenario.launch(MainActivity::class.java).use { scenario ->
- 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",
- )
- 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)
- }
-
- onView(withContentDescription("done.wav")).perform(longClick())
- onView(withText("Show text")).perform(click())
- onView(withText("recognized words from the note")).check(matches(isDisplayed()))
- }
- }
-
private fun clearActivityState() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
- WorkManager.getInstance(context)
- .cancelUniqueWork(TranscriptionWorker.UNIQUE_WORK_NAME)
- .result.get(30, TimeUnit.SECONDS)
+ 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.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()
}
@@ -505,24 +223,23 @@ class MainActivityInstrumentedTest {
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(),
+ documentUri = DocumentsContract.buildDocumentUri(TestAudioDocumentsProvider.AUTHORITY, displayName).toString(),
displayName = displayName,
- mimeType = "audio/wav",
+ mimeType = "audio/ogg",
sizeBytes = 100,
importedAtMillis = modifiedMillis,
state = state,
- lastError = null,
- processedAtMillis = null,
- transcriptText = null,
+ lastError = error,
+ processedAtMillis = modifiedMillis.takeIf { state == AudioFileState.PROCESSED || state == AudioFileState.FAILED },
+ transcriptText = transcript,
durationUs = null,
)
} finally {
@@ -530,23 +247,33 @@ class MainActivityInstrumentedTest {
}
}
- private fun enqueueDelayedTranscription() {
- val context = InstrumentationRegistry.getInstrumentation().targetContext
- val request = OneTimeWorkRequestBuilder()
- .setInitialDelay(1, TimeUnit.DAYS)
- .build()
- WorkManager.getInstance(context).enqueueUniqueWork(
- TranscriptionWorker.UNIQUE_WORK_NAME,
- ExistingWorkPolicy.REPLACE,
- request,
- ).result.get(30, TimeUnit.SECONDS)
- }
+ private fun activeModelRow(activity: MainActivity): Boolean =
+ displayItems(activity)
+ .filterIsInstance()
+ .any { it.task.stableId == "setup:model" && it.task.progress != null }
- private fun awaitRows(
- scenario: ActivityScenario,
- expected: Set,
- ) {
- awaitActivity(scenario) { activity -> rowNames(activity).containsAll(expected) }
+ private fun selectedFilter(activity: MainActivity): TaskListFilter =
+ stateHost(activity).state.value.taskList.filter
+
+ 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 awaitActivity(
@@ -562,80 +289,11 @@ class MainActivityInstrumentedTest {
throw AssertionError("Activity did not reach expected state")
}
- private fun booleanField(activity: MainActivity, name: String): Boolean =
- MainActivity::class.java.getDeclaredField(name)
- .apply { isAccessible = true }
- .getBoolean(activity)
-
- private fun transcriptionState(activity: MainActivity): TranscriptionObservationState =
- MainActivity::class.java.getDeclaredField("transcriptionState")
- .apply { isAccessible = true }
- .get(activity) as TranscriptionObservationState
-
- private fun invokeRefreshCatalog(activity: MainActivity) {
- MainActivity::class.java.getDeclaredMethod(
- "refreshCatalog",
- java.lang.Long::class.java,
- ).apply { isAccessible = true }.invoke(activity, null)
- }
-
- private fun rowNames(activity: MainActivity): List {
- val list = activity.findViewById(R.id.fileList)
- return (0 until list.childCount).mapNotNull { index ->
- list.getChildAt(index).contentDescription?.toString()
- }
- }
-
- private fun rowButton(activity: MainActivity, rowName: String, label: String): Button? {
- val list = activity.findViewById(R.id.fileList)
- val row = (0 until list.childCount)
- .map { index -> list.getChildAt(index) }
- .firstOrNull { it.contentDescription?.toString() == rowName }
- ?: return null
- return descendants(row as ViewGroup)
- .filterIsInstance()
- .firstOrNull { it.text.toString() == label }
+ private fun setField(activity: MainActivity, name: String, value: Any) {
+ MainActivity::class.java.getDeclaredField(name).apply { isAccessible = true }.set(activity, value)
}
- private fun descendants(root: ViewGroup): Sequence = sequence {
- for (index in 0 until root.childCount) {
- val child = root.getChildAt(index)
- yield(child)
- if (child is ViewGroup) yieldAll(descendants(child))
- }
+ private fun invoke(activity: MainActivity, name: String) {
+ MainActivity::class.java.getDeclaredMethod(name).apply { isAccessible = true }.invoke(activity)
}
-
- private fun showStartupPrompt(
- scenario: ActivityScenario,
- pendingCount: Int,
- ) {
- scenario.onActivity { activity ->
- val coordinator = MainActivity::class.java.getDeclaredField("startupCoordinator")
- .apply { isAccessible = true }
- .get(activity) as StartupProcessingCoordinator
- coordinator.beginStartupScan(99)
- coordinator.setFolderReady(true)
- coordinator.setOutputReady(true)
- coordinator.setModelReady(true)
- coordinator.setTranscriptionState(known = true, active = false)
- coordinator.onStartupCatalogReady(99, pendingCount, failedCount = 0)
- MainActivity::class.java.getDeclaredMethod("evaluateStartupProcessing")
- .apply { isAccessible = true }
- .invoke(activity)
- }
- }
-
- 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,
- )
}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/AndroidMainScreenStateHost.kt b/app/src/main/java/me/maxistar/voiceinbox/AndroidMainScreenStateHost.kt
new file mode 100644
index 0000000..6116d34
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/AndroidMainScreenStateHost.kt
@@ -0,0 +1,127 @@
+package me.maxistar.voiceinbox
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import me.maxistar.voiceinbox.core.AudioCatalogEntry
+import me.maxistar.voiceinbox.core.AudioFileState
+import me.maxistar.voiceinbox.core.AudioTaskSnapshot
+import me.maxistar.voiceinbox.core.FolderSetupSnapshot
+import me.maxistar.voiceinbox.core.FolderSetupSnapshotState
+import me.maxistar.voiceinbox.core.ModelSetupSnapshot
+import me.maxistar.voiceinbox.core.ModelSetupSnapshotState
+import me.maxistar.voiceinbox.core.OutputSetupSnapshot
+import me.maxistar.voiceinbox.core.OutputSetupSnapshotState
+import me.maxistar.voiceinbox.core.PreviewTaskSnapshot
+import me.maxistar.voiceinbox.core.TaskListFilter
+import me.maxistar.voiceinbox.core.TaskListInput
+import me.maxistar.voiceinbox.core.TaskListPresentationController
+import me.maxistar.voiceinbox.core.TaskListState
+import me.maxistar.voiceinbox.core.TranscriptionTaskSnapshot
+
+data class AndroidMainScreenInput(
+ val filter: TaskListFilter = TaskListFilter.NEW,
+ val model: ModelSetupSnapshot = ModelSetupSnapshot(ModelSetupSnapshotState.REQUIRED),
+ val output: OutputSetupSnapshot = OutputSetupSnapshot(OutputSetupSnapshotState.REQUIRED),
+ val folder: FolderSetupSnapshot = FolderSetupSnapshot(FolderSetupSnapshotState.UNSELECTED),
+ val entries: List = emptyList(),
+ val preview: PreviewTaskSnapshot = PreviewTaskSnapshot(),
+ val transcription: TranscriptionTaskSnapshot = TranscriptionTaskSnapshot(),
+ val transcriptionEligible: Boolean = false,
+ val previewEligible: Boolean = true,
+ val importEnabled: Boolean = true,
+ val refreshFolderVisible: Boolean = false,
+ val refreshFolderEnabled: Boolean = false,
+)
+
+data class AndroidMainScreenState(
+ val taskList: TaskListState,
+ val entriesById: Map,
+ val importEnabled: Boolean,
+ val refreshFolderVisible: Boolean,
+ val refreshFolderEnabled: Boolean,
+)
+
+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,
+ )
+ }
+ return AndroidMainScreenState(
+ taskList = TaskListPresentationController.state(
+ TaskListInput(
+ filter = input.filter,
+ model = input.model,
+ output = input.output,
+ folder = input.folder,
+ audio = audio,
+ preview = input.preview,
+ transcription = input.transcription,
+ ),
+ ),
+ entriesById = input.entries.associateBy(AudioCatalogEntry::id),
+ importEnabled = input.importEnabled,
+ refreshFolderVisible = input.refreshFolderVisible,
+ refreshFolderEnabled = input.refreshFolderEnabled,
+ )
+ }
+
+ 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 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/CatalogRefreshPolicy.kt b/app/src/main/java/me/maxistar/voiceinbox/CatalogRefreshPolicy.kt
index 0762d85..8ad91e0 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/CatalogRefreshPolicy.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/CatalogRefreshPolicy.kt
@@ -1,12 +1,10 @@
package me.maxistar.voiceinbox
import me.maxistar.voiceinbox.core.AudioCatalogSourceScope
-import me.maxistar.voiceinbox.core.MainScreenCatalogTab
data class CatalogRefreshToken(
val generation: Long,
val sourceScope: AudioCatalogSourceScope,
- val selectedTab: MainScreenCatalogTab,
)
data class CatalogWorkRefreshKey(
@@ -22,11 +20,9 @@ object CatalogRefreshPolicy {
request: CatalogRefreshToken,
currentGeneration: Long,
currentSourceScope: AudioCatalogSourceScope,
- currentTab: MainScreenCatalogTab,
): Boolean =
request.generation == currentGeneration &&
- request.sourceScope == currentSourceScope &&
- request.selectedTab == currentTab
+ request.sourceScope == currentSourceScope
fun shouldRefreshForWork(
previous: CatalogWorkRefreshKey?,
diff --git a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
index c5b4125..03a45aa 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
@@ -3,48 +3,43 @@ 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.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.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(), StartupProcessingDialogFragment.Listener {
- 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 importModel: Button
- private lateinit var transcribeAll: Button
- private lateinit var selectOutput: Button
- private lateinit var selectFolder: Button
- private lateinit var importAudio: 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
+ 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
@@ -58,10 +53,13 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
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
@@ -69,18 +67,18 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
private var folderScanQueued = false
private var scanning = false
private var pendingCount = 0
- private var selectedTab = MainScreenCatalogTab.NEW
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
@@ -88,7 +86,6 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
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
@@ -101,7 +98,8 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
private var pendingFolderScanOrigin: FolderScanOrigin? = null
private var hasStarted = false
private var ingestionActive = false
- private var importInboxAvailable = false
+ private var currentEntries: List = emptyList()
+ private var renderingFilter = false
private val queuedImportUris = mutableListOf()
private val outputPicker = registerForActivityResult(
@@ -154,6 +152,10 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
startupCoordinator = StartupProcessingCoordinator.restore(
savedInstanceState?.getString(STATE_STARTUP_PROCESSING_STAGE),
)
+ taskActionRouter = AndroidTaskActionRouter(
+ currentState = { taskStateHost.state.value },
+ gateway = AndroidTaskActionGateway(::performTaskAction),
+ )
bootstrapSelectionIdentities()
ScheduledTranscriptionScheduler.sync(
this,
@@ -162,23 +164,22 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
).load(),
)
- downloadModel.setOnClickListener { SpeechModelDownloadWorker.enqueue(this) }
- importModel.setOnClickListener {
- if (modelDownloadAvailable && !modelLoading) modelFolderPicker.launch(null)
- }
- transcribeAll.setOnClickListener { startBatchTranscription() }
importAudio.setOnClickListener {
if (!ingestionActive) importPicker.launch(arrayOf("audio/*", "application/ogg"))
}
- selectOutput.setOnClickListener { launchOutputPickerIfEnabled() }
- selectFolder.setOnClickListener { launchFolderPickerIfEnabled() }
- newTab.setOnClickListener {
- selectedTab = MainScreenCatalogTab.NEW
- 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)
}
- processedTab.setOnClickListener {
- selectedTab = MainScreenCatalogTab.PROCESSED
- refreshCatalog()
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ taskStateHost.state.collect(::renderTaskState)
+ }
}
observeModelInstallation()
@@ -224,7 +225,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
override fun onOptionsItemSelected(item: MenuItem): Boolean =
when (item.itemId) {
R.id.menuRefreshFolder -> {
- if (currentControls().refreshEnabled) {
+ if (selectionControls().refreshEnabled) {
scanFolder()
}
true
@@ -245,22 +246,15 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
}
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)
- importModel = findViewById(R.id.importModel)
- transcribeAll = findViewById(R.id.transcribeAll)
- selectOutput = findViewById(R.id.selectOutput)
- selectFolder = findViewById(R.id.selectFolder)
importAudio = findViewById(R.id.importAudio)
- outputName = findViewById(R.id.outputName)
- folderName = findViewById(R.id.folderName)
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
}
private fun bootstrapSelectionIdentities() {
@@ -271,19 +265,17 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
private fun setStoredOutputIdentity(stored: Uri?) {
outputUri = stored
outputAccessReady = false
+ outputDisplayName = null
startupCoordinator.setOutputReady(false)
- outputName.text = getString(
- if (stored == null) R.string.output_not_selected else R.string.output_restoring,
- )
+ publishTaskState()
}
private fun setStoredFolderIdentity(stored: Uri?) {
folderUri = stored
folderAccessReady = false
+ folderDisplayName = null
startupCoordinator.setFolderReady(false)
- folderName.text = getString(
- if (stored == null) R.string.folder_not_selected else R.string.folder_restoring,
- )
+ publishTaskState()
}
private fun restoreSelections(scanFolderOnSuccess: Boolean) {
@@ -297,7 +289,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
} else {
outputAccessReady = false
startupCoordinator.setOutputReady(false)
- outputName.text = getString(R.string.output_restoring)
+ publishTaskState()
folderExecutor.execute {
val result = selectionAccess.validate(
uri = storedOutput,
@@ -324,7 +316,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
outputAccessError = result.error?.message
?: "Output file is temporarily unavailable"
startupCoordinator.setOutputReady(false)
- outputName.text = getString(R.string.output_temporarily_unavailable)
+ outputDisplayName = null
}
PersistedSelectionAccessState.PERMISSION_REVOKED -> {
selectionStore.clearOutputUri()
@@ -333,7 +325,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
?: "Output file access was revoked; select it again"
}
}
- renderStatusBlock()
+ publishTaskState()
updateControls()
evaluateStartupProcessing()
}
@@ -354,8 +346,8 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
folderAccessReady = false
folderChecking = true
startupCoordinator.setFolderReady(false)
- folderName.text = getString(R.string.folder_restoring)
- renderStatusBlock()
+ folderDisplayName = null
+ publishTaskState()
updateControls()
folderExecutor.execute {
val result = selectionAccess.validate(
@@ -387,7 +379,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
folderAccessError = result.error?.message
?: "Audio folder is temporarily unavailable"
startupCoordinator.setFolderReady(false)
- folderName.text = getString(R.string.folder_temporarily_unavailable)
+ folderDisplayName = null
}
PersistedSelectionAccessState.PERMISSION_REVOKED -> {
selectionStore.clearFolderUri()
@@ -396,7 +388,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
?: "Audio folder access was revoked; select it again"
}
}
- renderStatusBlock()
+ publishTaskState()
updateControls()
refreshCatalog()
maybeStartPendingFolderScan()
@@ -440,8 +432,8 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
outputAccessError = null
startupCoordinator.setOutputReady(true)
updateOutputSummary(metadata.displayName)
- statusError = null
- renderStatusBlock()
+ outputAccessError = null
+ publishTaskState()
updateControls()
evaluateStartupProcessing()
}
@@ -451,8 +443,8 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
activityDestroyed ||
validationGeneration != outputValidationGeneration
) return@runOnUiThread
- statusError = it.message ?: "Output file is not writable"
- renderStatusBlock()
+ outputAccessError = it.message ?: "Output file is not writable"
+ publishTaskState()
updateControls()
evaluateStartupProcessing()
}
@@ -472,7 +464,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
validationGeneration != folderValidationGeneration
) return@runOnUiThread
folderChecking = true
- renderStatusBlock()
+ publishTaskState()
updateControls()
}
runCatching {
@@ -491,8 +483,8 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
folderAccessError = null
startupCoordinator.setFolderReady(true)
updateFolderSummary(name)
- statusError = null
- renderStatusBlock()
+ folderAccessError = null
+ publishTaskState()
updateControls()
refreshCatalog()
pendingFolderScanOrigin = FolderScanOrigin.USER
@@ -505,8 +497,8 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
validationGeneration != folderValidationGeneration
) return@runOnUiThread
folderChecking = false
- statusError = it.message ?: "Audio folder is not readable"
- renderStatusBlock()
+ folderAccessError = it.message ?: "Audio folder is not readable"
+ publishTaskState()
updateControls()
evaluateStartupProcessing()
}
@@ -527,15 +519,13 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
if (permission.isFailure) {
setModelUi(
"The selected model folder cannot be accessed after the picker closes",
- loading = false,
canDownload = true,
)
updateControls()
return
}
if (!alreadyPersisted) SpeechModelImportPermission.recordOwned(this, uri)
- statusError = null
- setModelUi("Checking local speech model", loading = true, canDownload = false)
+ setModelUi("Checking local speech model", canDownload = false)
updateControls()
importExecutor.execute {
runCatching {
@@ -554,7 +544,6 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
if (activityDestroyed) return@runOnUiThread
setModelUi(
error.message ?: "The selected model folder is not readable",
- loading = false,
canDownload = true,
)
updateControls()
@@ -587,8 +576,8 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
completedFiles = 0
totalFiles = 0
failedFiles = 0
- statusError = null
- renderStatusBlock()
+ folderAccessError = null
+ publishTaskState()
updateControls()
folderExecutor.execute {
runOnUiThread {
@@ -596,7 +585,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
folderScanQueued = false
scanning = true
scanMessage = "Scanning folder"
- renderStatusBlock()
+ publishTaskState()
updateControls()
}
runCatching {
@@ -608,6 +597,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
if (activityDestroyed) return@runOnUiThread
folderScanQueued = false
scanning = false
+ folderAccessError = null
scanMessage = "Scan complete: $count audio files"
refreshCatalog(startupGeneration)
}
@@ -616,9 +606,10 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
if (activityDestroyed) return@runOnUiThread
folderScanQueued = false
scanning = false
+ folderAccessError = error.message ?: "Folder scan failed"
startupGeneration?.let(startupCoordinator::onStartupScanFailed)
evaluateStartupProcessing()
- showError(error.message ?: "Folder scan failed")
+ updateControls()
}
}
}
@@ -632,17 +623,12 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
val token = CatalogRefreshToken(
generation = ++catalogRefreshGeneration,
sourceScope = activeSourceScope(),
- selectedTab = selectedTab,
)
val startupGenerationForRequest = pendingStartupCatalogGeneration
folderExecutor.execute {
val newEntries = catalog.newEntries(token.sourceScope)
val processedEntries = catalog.processedEntries(token.sourceScope)
- val entries = if (token.selectedTab == MainScreenCatalogTab.NEW) {
- newEntries
- } else {
- processedEntries
- }
+ val entries = (newEntries + processedEntries).distinctBy(AudioCatalogEntry::id)
runOnUiThread {
if (activityDestroyed) return@runOnUiThread
if (
@@ -650,16 +636,12 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
request = token,
currentGeneration = catalogRefreshGeneration,
currentSourceScope = activeSourceScope(),
- currentTab = selectedTab,
)
) return@runOnUiThread
pendingCount = newEntries.count { it.state == AudioFileState.PENDING }
- importInboxAvailable = (newEntries + processedEntries).any {
- it.folderUri == AndroidAudioImportConstants.SOURCE_ID
- }
val catalogFailedCount = processedEntries.count { it.state == AudioFileState.FAILED }
renderEntries(entries)
- renderStatusBlock()
+ publishTaskState()
updateControls()
if (startupGenerationForRequest != null) {
if (pendingStartupCatalogGeneration == startupGenerationForRequest) {
@@ -679,123 +661,8 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
}
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,
- )
- 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)
- }
- })
- }
- 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)
- } else {
- startPreviewPlayback(entry)
- }
- true
- }
- MENU_ENTRY_RETRY -> {
- retryEntry(entry)
- true
- }
- MENU_ENTRY_SHOW_TEXT -> {
- showTranscriptText(entry)
- true
- }
- else -> false
- }
- }
- popup.show()
- return true
+ currentEntries = entries
+ publishTaskState()
}
private fun retryEntry(entry: AudioCatalogEntry) {
@@ -818,20 +685,6 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
.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 {
@@ -846,35 +699,40 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
when (state) {
SpeechModelReadinessState.Checking -> {
modelReady = false
- setModelUi("Checking speech model", loading = true, canDownload = false)
+ modelSetupState = ModelSetupSnapshotState.INSTALLING
+ setModelUi("Checking speech model", canDownload = false)
}
is SpeechModelReadinessState.Ready -> {
modelReady = true
- setModelUi("Speech model ready", loading = false, canDownload = false)
+ modelSetupState = ModelSetupSnapshotState.READY
+ setModelUi("Speech model ready", canDownload = false)
}
SpeechModelReadinessState.Missing -> {
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 -> {
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 -> {
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() {
@@ -892,6 +750,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
WorkInfo.State.RUNNING,
-> {
modelReady = false
+ modelSetupState = ModelSetupSnapshotState.INSTALLING
startupCoordinator.setModelReady(false)
val bytes = info.progress.getLong(SpeechModelInstallationWork.KEY_BYTES_DOWNLOADED, 0)
val total = info.progress.getLong(
@@ -901,10 +760,12 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
modelMessage =
info.progress.getString(SpeechModelInstallationWork.KEY_MESSAGE)
?: "Installing speech model"
- modelLoading = true
modelDownloadAvailable = false
+ modelInstallCanCancel = info.tags.any { tag ->
+ tag.endsWith(SpeechModelDownloadWorker::class.java.simpleName)
+ }
modelDownloadProgress = ((bytes * 100) / total.coerceAtLeast(1)).toInt()
- renderStatusBlock()
+ publishTaskState()
updateControls()
evaluateStartupProcessing()
}
@@ -919,12 +780,12 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
WorkInfo.State.FAILED -> {
SpeechModelImportPermission.releaseOwnedIfUnused(this)
modelReady = false
+ modelSetupState = ModelSetupSnapshotState.INVALID
startupCoordinator.setModelReady(false)
setModelUi(
info.outputData.getString(SpeechModelInstallationWork.KEY_ERROR)
?: "Speech model installation failed",
- false,
- true,
+ canDownload = true,
)
updateControls()
evaluateStartupProcessing()
@@ -951,7 +812,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
evaluateStartupProcessing()
if (infos.isEmpty()) {
transcriptionFinished = false
- renderStatusBlock()
+ publishTaskState()
updateControls()
return@observe
}
@@ -963,7 +824,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
null
} ?: run {
clearTranscriptionProgress()
- renderStatusBlock()
+ publishTaskState()
updateControls()
return@observe
}
@@ -986,6 +847,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
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)
@@ -1000,9 +864,8 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
}
if (transcriptionActive() || transcriptionFinished) {
scanMessage = null
- statusError = null
}
- renderStatusBlock()
+ publishTaskState()
val catalogState = CatalogWorkRefreshKey(
workId = info.id.toString(),
workState = info.state.name,
@@ -1019,19 +882,33 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
}
}
- 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 launchOutputPickerIfEnabled() {
- if (currentControls().outputEnabled) {
+ if (selectionControls().outputEnabled) {
outputPicker.launch(FileSelectionRules.outputMimeTypes)
}
}
private fun launchFolderPickerIfEnabled() {
- if (currentControls().folderEnabled) {
+ if (selectionControls().folderEnabled) {
folderPicker.launch(folderUri)
}
}
@@ -1097,6 +974,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
transcriptionFinished = false
transcriptionPhase = null
transcriptionFilename = null
+ transcriptionEntryId = null
transcriptionIndeterminate = true
transcriptionProgressValue = 0
processedUs = -1L
@@ -1107,58 +985,8 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
}
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
importAudio.isEnabled = !ingestionActive
- importAudio.text = getString(if (ingestionActive) R.string.importing_audio else R.string.import_audio)
- 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
- }
- }
- }
- }
- }
+ publishTaskState()
invalidateOptionsMenu()
}
@@ -1213,106 +1041,155 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
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
- importModel.visibility = if (state.downloadVisible) View.VISIBLE else View.GONE
- importModel.isEnabled = modelDownloadAvailable && !modelLoading
+ private fun publishTaskState() {
+ if (!::importAudio.isInitialized) return
+ val folderState = when {
+ folderAccessError != null -> FolderSetupSnapshotState.ERROR
+ folderUri == null -> FolderSetupSnapshotState.UNSELECTED
+ folderChecking || folderScanQueued || scanning -> FolderSetupSnapshotState.SCANNING
+ folderAccessReady -> FolderSetupSnapshotState.READY
+ else -> FolderSetupSnapshotState.SCANNING
+ }
+ val outputState = when {
+ outputAccessReady -> OutputSetupSnapshotState.READY
+ outputAccessError != null -> OutputSetupSnapshotState.INVALID
+ outputUri == null -> OutputSetupSnapshotState.REQUIRED
+ else -> OutputSetupSnapshotState.REQUIRED
+ }
+ 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,
+ )
+ taskStateHost.update { current ->
+ current.copy(
+ model = ModelSetupSnapshot(
+ state = modelSetupState,
+ detail = modelMessage.takeUnless { modelSetupState == ModelSetupSnapshotState.READY },
+ 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,
+ refreshFolderVisible = folderUri != null,
+ refreshFolderEnabled = selectionControls().refreshEnabled,
+ )
+ }
+ }
+
+ 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 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 -> if (selectionControls().refreshEnabled) scanFolder()
+ 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 setOptionalText(view: TextView, value: String?) {
- view.text = value.orEmpty()
- view.visibility = if (value.isNullOrBlank()) View.GONE else View.VISIBLE
+ 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.refreshFolderVisible
+ isEnabled = state.refreshFolderEnabled
+ }
}
private fun updateOutputSummary(displayName: String?) {
- outputName.text = if (displayName.isNullOrBlank()) {
- getString(R.string.output_not_selected)
- } else {
- getString(R.string.output_selected, displayName)
- }
+ outputDisplayName = displayName
+ publishTaskState()
}
private fun updateFolderSummary(displayName: String?) {
- folderName.text = if (displayName.isNullOrBlank()) {
- getString(R.string.folder_not_selected)
- } else {
- getString(R.string.folder_selected, displayName)
- }
+ 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,
- modelInstallationState = when {
- modelLoading -> SpeechModelInstallationState.INSTALLING
- modelReady -> SpeechModelInstallationState.INSTALLED
- modelDownloadAvailable -> SpeechModelInstallationState.NOT_INSTALLED
- else -> SpeechModelInstallationState.INVALID
- },
- modelRuntimeState = if (
- transcriptionState == TranscriptionObservationState.ACTIVE &&
- transcriptionPhase?.contains("model", ignoreCase = true) == true
- ) SpeechModelRuntimeState.LOADING else SpeechModelRuntimeState.UNLOADED,
- modelDownloadAvailable = modelDownloadAvailable,
- modelDownloadProgress = modelDownloadProgress,
- outputSelected = outputUri != null,
- folderSelected = folderUri != null,
- outputReady = outputAccessReady,
- folderReady = folderAccessReady,
- 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 ?: outputAccessError ?: folderAccessError,
- selectedTab = selectedTab,
- displayedRowCount = entries.size,
- activePreviewEntryId = previewEntryId,
- previewState = previewState,
- rows = entries.map { it.toMainScreenRowInput() },
- importInboxAvailable = importInboxAvailable,
- ),
- )
-
private fun activeSourceScope(): AudioCatalogSourceScope =
AudioCatalogSourceScope.of(
listOfNotNull(
@@ -1393,15 +1270,6 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
}
}
- private fun AudioCatalogEntry.toMainScreenRowInput(): MainScreenRowInput =
- MainScreenRowInput(
- entryId = id,
- state = state,
- hasTranscriptText = !transcriptText.isNullOrBlank(),
- )
-
- private data class PreviewButtonTag(val entryId: Long)
- private data class RetryButtonTag(val entryId: Long)
private enum class FolderScanOrigin {
STARTUP,
@@ -1409,9 +1277,6 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
}
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"
val ACTIVE_WORK_STATES = setOf(
WorkInfo.State.ENQUEUED,
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..ea73775
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt
@@ -0,0 +1,306 @@
+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
+}
+
+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)
+ }
+ }
+
+ 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)
+
+ 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)
+ }
+
+ private 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?) {
+ 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 e241cdd..8df6e49 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/TranscriptionWorker.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/TranscriptionWorker.kt
@@ -36,7 +36,7 @@ class TranscriptionWorker(
val modelRepository = SpeechModelRepository(
applicationContext.noBackupFilesDir.resolve("models"),
)
- publish("Preparing speech model", null, 0, 0, null, null)
+ 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") }
@@ -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,6 +257,7 @@ 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
@@ -262,6 +271,13 @@ class TranscriptionWorker(
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?,
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/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 86a4b5e..19fd10c 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,156 +1,77 @@
-
+ android:layout_height="match_parent">
-
+ android:layout_height="match_parent"
+ android:orientation="vertical">
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ android:layout_weight="1"
+ android:text="@string/new_label" />
-
+ android:layout_weight="1"
+ android:text="@string/processed" />
-
+ android:layout_weight="1"
+ android:text="@string/all_label" />
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
diff --git a/app/src/main/res/layout/row_batch_action.xml b/app/src/main/res/layout/row_batch_action.xml
new file mode 100644
index 0000000..fdc2bbf
--- /dev/null
+++ b/app/src/main/res/layout/row_batch_action.xml
@@ -0,0 +1,7 @@
+
+
diff --git a/app/src/main/res/layout/row_empty_task.xml b/app/src/main/res/layout/row_empty_task.xml
new file mode 100644
index 0000000..bcd0316
--- /dev/null
+++ b/app/src/main/res/layout/row_empty_task.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/row_task.xml b/app/src/main/res/layout/row_task.xml
new file mode 100644
index 0000000..8d45d33
--- /dev/null
+++ b/app/src/main/res/layout/row_task.xml
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 51ba3d6..30663c9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -52,6 +52,11 @@
Install from folder
New
Processed
+ All
No new audio files
Transcribe all
+
+ - Transcribe all (%1$d file)
+ - Transcribe all (%1$d files)
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 4b3968d..99935dd 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -6,4 +6,9 @@
-
\ No newline at end of file
+
+
+
diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt
new file mode 100644
index 0000000..2361b40
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt
@@ -0,0 +1,184 @@
+package me.maxistar.voiceinbox
+
+import androidx.lifecycle.SavedStateHandle
+import me.maxistar.voiceinbox.core.AudioCatalogEntry
+import me.maxistar.voiceinbox.core.AudioFileFingerprint
+import me.maxistar.voiceinbox.core.AudioFileState
+import me.maxistar.voiceinbox.core.AudioTaskPresentation
+import me.maxistar.voiceinbox.core.AudioTaskState
+import me.maxistar.voiceinbox.core.FolderSetupSnapshot
+import me.maxistar.voiceinbox.core.FolderSetupSnapshotState
+import me.maxistar.voiceinbox.core.ModelSetupSnapshot
+import me.maxistar.voiceinbox.core.ModelSetupSnapshotState
+import me.maxistar.voiceinbox.core.OutputSetupSnapshot
+import me.maxistar.voiceinbox.core.OutputSetupSnapshotState
+import me.maxistar.voiceinbox.core.PreviewPlaybackState
+import me.maxistar.voiceinbox.core.PreviewTaskSnapshot
+import me.maxistar.voiceinbox.core.SetupTaskPresentation
+import me.maxistar.voiceinbox.core.TaskActionKind
+import me.maxistar.voiceinbox.core.TaskListFilter
+import me.maxistar.voiceinbox.core.TranscriptionTaskSnapshot
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class AndroidMainScreenStateHostTest {
+ @Test
+ fun mapperPreservesSetupAndSourceAwareModelProgress() {
+ val state = AndroidTaskListSnapshotMapper.state(
+ AndroidMainScreenInput(
+ model = ModelSetupSnapshot(
+ state = ModelSetupSnapshotState.INSTALLING,
+ detail = "Installing from selected folder",
+ installationPhase = "Verifying local model",
+ progressPercent = 63,
+ ),
+ output = OutputSetupSnapshot(OutputSetupSnapshotState.INVALID, "Output unavailable"),
+ folder = FolderSetupSnapshot(FolderSetupSnapshotState.SCANNING, "Checking recordings"),
+ ),
+ )
+
+ val tasks = state.taskList.tasks.filterIsInstance()
+ assertEquals(listOf("setup:model", "setup:output", "setup:folder"), tasks.map { it.stableId })
+ assertEquals("Verifying local model", tasks.first().progress?.phase)
+ assertEquals(63, tasks.first().progress?.percent)
+ assertEquals("Output unavailable", tasks[1].errorMessage)
+ assertEquals("Scanning audio folder", tasks[2].progress?.phase)
+ }
+
+ @Test
+ fun mapperPreservesCatalogIdentitySourcesOrderingAndOutcomes() {
+ val entries = listOf(
+ entry(1, AudioFileState.PENDING, AndroidAudioImportConstants.SOURCE_ID, modified = 10),
+ entry(2, AudioFileState.PROCESSING, "content://folder", modified = 20),
+ entry(3, AudioFileState.PROCESSED, AndroidAudioImportConstants.SOURCE_ID, modified = 30, processed = 50, transcript = "hello"),
+ entry(4, AudioFileState.FAILED, "content://folder", modified = 40, processed = 60, error = "No speech detected"),
+ entry(5, AudioFileState.MISSING, "content://folder", modified = 70, error = "Missing"),
+ )
+ val state = AndroidTaskListSnapshotMapper.state(
+ readyInput(filter = TaskListFilter.ALL, entries = entries),
+ )
+
+ assertEquals(entries.associateBy { it.id }, state.entriesById)
+ val tasks = state.taskList.tasks.filterIsInstance()
+ assertEquals(listOf(5L, 4L, 3L, 2L, 1L), tasks.map { it.entryId })
+ assertEquals(AudioTaskState.NO_SPEECH, tasks.single { it.entryId == 4L }.state)
+ assertEquals(TaskActionKind.SHOW_TEXT, tasks.single { it.entryId == 3L }.actions.first().kind)
+ assertEquals("1 KiB", tasks.single { it.entryId == 1L }.detail)
+ }
+
+ @Test
+ fun mapperAssignsPreviewAndActiveTranscriptionToStableEntry() {
+ val state = AndroidTaskListSnapshotMapper.state(
+ readyInput(
+ entries = listOf(
+ entry(1, AudioFileState.PENDING, AndroidAudioImportConstants.SOURCE_ID, modified = 10),
+ entry(2, AudioFileState.PENDING, "content://folder", modified = 20),
+ ),
+ preview = PreviewTaskSnapshot(2, PreviewPlaybackState.PLAYING),
+ transcription = TranscriptionTaskSnapshot(
+ active = true,
+ activeEntryId = 1,
+ phase = "Transcribing",
+ percent = 25,
+ completedFiles = 0,
+ totalFiles = 2,
+ ),
+ ),
+ )
+
+ val tasks = state.taskList.tasks.filterIsInstance()
+ val active = tasks.single { it.entryId == 1L }
+ assertEquals(AudioTaskState.PROCESSING, active.state)
+ assertEquals("Transcribing", active.progress?.phase)
+ assertEquals(25, active.progress?.percent)
+ val preview = tasks.single { it.entryId == 2L }
+ assertEquals(TaskActionKind.STOP, preview.actions.last().kind)
+ assertTrue(preview.actions.last().enabled)
+ }
+
+ @Test
+ fun preClaimPreparationUsesDeterministicOldestEligibleEntry() {
+ val state = AndroidTaskListSnapshotMapper.state(
+ readyInput(
+ entries = listOf(
+ entry(1, AudioFileState.PENDING, AndroidAudioImportConstants.SOURCE_ID, modified = 100),
+ entry(2, AudioFileState.PENDING, "content://folder", modified = 50),
+ ),
+ transcription = TranscriptionTaskSnapshot(active = true, phase = "Preparing speech model"),
+ ),
+ )
+
+ val owner = state.taskList.tasks.filterIsInstance().single { it.progress != null }
+ assertEquals(2L, owner.entryId)
+ assertEquals(AudioTaskState.PROCESSING, owner.state)
+ }
+
+ @Test
+ fun stateHostPublishesImmutableUpdatesAndRestoresFilter() {
+ val savedState = SavedStateHandle()
+ val host = AndroidMainScreenStateHost(savedState)
+ assertEquals(TaskListFilter.NEW, host.state.value.taskList.filter)
+
+ host.replace(readyInput(filter = TaskListFilter.PROCESSED, entries = listOf(entry(1, AudioFileState.FAILED))))
+ val first = host.state.value
+ assertEquals(TaskListFilter.PROCESSED, first.taskList.filter)
+ assertEquals(listOf("audio:1"), first.taskList.tasks.map { it.stableId })
+
+ host.update { it.copy(entries = emptyList()) }
+ assertTrue(host.state.value.taskList.tasks.isEmpty())
+ assertEquals(listOf("audio:1"), first.taskList.tasks.map { it.stableId })
+
+ val recreated = AndroidMainScreenStateHost(savedState)
+ assertEquals(TaskListFilter.PROCESSED, recreated.state.value.taskList.filter)
+ }
+
+ @Test
+ fun completedSetupAndOptionalFolderAreHidden() {
+ val state = AndroidTaskListSnapshotMapper.state(readyInput())
+
+ assertTrue(state.taskList.tasks.isEmpty())
+ assertNull(state.taskList.tasks.filterIsInstance().firstOrNull())
+ assertTrue(state.taskList.emptyActions.any { it.kind == TaskActionKind.IMPORT_AUDIO })
+ }
+
+ private fun readyInput(
+ filter: TaskListFilter = TaskListFilter.NEW,
+ entries: List = emptyList(),
+ preview: PreviewTaskSnapshot = PreviewTaskSnapshot(),
+ transcription: TranscriptionTaskSnapshot = TranscriptionTaskSnapshot(),
+ ) = AndroidMainScreenInput(
+ filter = filter,
+ model = ModelSetupSnapshot(ModelSetupSnapshotState.READY),
+ output = OutputSetupSnapshot(OutputSetupSnapshotState.READY),
+ folder = FolderSetupSnapshot(FolderSetupSnapshotState.READY),
+ entries = entries,
+ preview = preview,
+ transcription = transcription,
+ transcriptionEligible = true,
+ )
+
+ private fun entry(
+ id: Long,
+ state: AudioFileState,
+ source: String = AndroidAudioImportConstants.SOURCE_ID,
+ modified: Long = id,
+ processed: Long? = null,
+ error: String? = null,
+ transcript: String? = null,
+ ) = AudioCatalogEntry(
+ id = id,
+ folderUri = source,
+ documentUri = "content://audio/$id",
+ displayName = "$id.ogg",
+ mimeType = "audio/ogg",
+ fingerprint = AudioFileFingerprint(sizeBytes = 1024, modifiedMillis = modified),
+ state = state,
+ stateBeforeMissing = null,
+ lastError = error,
+ processedAtMillis = processed,
+ transcriptText = transcript,
+ )
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidTaskActionRouterTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidTaskActionRouterTest.kt
new file mode 100644
index 0000000..20fa69a
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidTaskActionRouterTest.kt
@@ -0,0 +1,116 @@
+package me.maxistar.voiceinbox
+
+import me.maxistar.voiceinbox.core.AudioCatalogEntry
+import me.maxistar.voiceinbox.core.AudioFileFingerprint
+import me.maxistar.voiceinbox.core.AudioFileState
+import me.maxistar.voiceinbox.core.FolderSetupSnapshot
+import me.maxistar.voiceinbox.core.FolderSetupSnapshotState
+import me.maxistar.voiceinbox.core.ModelSetupSnapshot
+import me.maxistar.voiceinbox.core.ModelSetupSnapshotState
+import me.maxistar.voiceinbox.core.OutputSetupSnapshot
+import me.maxistar.voiceinbox.core.OutputSetupSnapshotState
+import me.maxistar.voiceinbox.core.TaskActionKind
+import me.maxistar.voiceinbox.core.TaskListFilter
+import me.maxistar.voiceinbox.core.TranscriptionTaskSnapshot
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class AndroidTaskActionRouterTest {
+ @Test
+ fun routesEnabledSetupEmptyBatchAndResolvedEntryActions() {
+ var state = state(modelReady = false)
+ val calls = mutableListOf>()
+ val router = AndroidTaskActionRouter({ state }) { kind, entry -> calls += kind to entry }
+
+ assertTrue(router.route(request("setup:model", null, TaskActionKind.IMPORT_MODEL)))
+ assertEquals(TaskActionKind.IMPORT_MODEL, calls.last().first)
+ assertNull(calls.last().second)
+
+ state = state(entries = listOf(entry(9)))
+ assertTrue(router.route(request("audio:9", 9, TaskActionKind.TRANSCRIBE)))
+ assertEquals(9L, calls.last().second?.id)
+ assertTrue(router.route(request(TaskListDisplayItem.BatchAction.STABLE_KEY, null, TaskActionKind.TRANSCRIBE_ALL)))
+
+ state = state()
+ assertTrue(router.route(request(TaskListDisplayItem.Empty.STABLE_KEY, null, TaskActionKind.IMPORT_AUDIO)))
+ }
+
+ @Test
+ fun rejectsStaleDisabledConflictingAndMismatchedClicks() {
+ var state = state(entries = listOf(entry(9)))
+ var callCount = 0
+ val router = AndroidTaskActionRouter({ state }) { _, _ -> callCount++ }
+ val transcribe = request("audio:9", 9, TaskActionKind.TRANSCRIBE)
+
+ state = state(
+ entries = listOf(entry(9)),
+ transcription = TranscriptionTaskSnapshot(active = true, activeEntryId = 9),
+ )
+ assertFalse(router.route(transcribe))
+ assertFalse(router.route(request("audio:9", 10, TaskActionKind.PLAY)))
+ assertFalse(router.route(request("audio:missing", 9, TaskActionKind.PLAY)))
+ assertEquals(0, callCount)
+
+ state = state(entries = listOf(entry(9)), eligible = false)
+ assertFalse(router.route(transcribe))
+ assertFalse(router.route(request(TaskListDisplayItem.BatchAction.STABLE_KEY, null, TaskActionKind.TRANSCRIBE_ALL)))
+ }
+
+ @Test
+ fun resolvesRetryAndShowTextFromCurrentCatalogState() {
+ val failed = entry(4, AudioFileState.FAILED)
+ var routed: Pair? = null
+ var state = state(filter = TaskListFilter.PROCESSED, entries = listOf(failed))
+ val router = AndroidTaskActionRouter({ state }) { kind, entry -> routed = kind to entry }
+
+ assertTrue(router.route(request("audio:4", 4, TaskActionKind.RETRY_TRANSCRIPTION)))
+ assertEquals(failed, routed?.second)
+
+ val processed = entry(4, AudioFileState.PROCESSED, transcript = "hello")
+ state = state(filter = TaskListFilter.PROCESSED, entries = listOf(processed))
+ assertTrue(router.route(request("audio:4", 4, TaskActionKind.SHOW_TEXT)))
+ assertEquals(processed, routed?.second)
+ }
+
+ private fun state(
+ modelReady: Boolean = true,
+ filter: TaskListFilter = TaskListFilter.NEW,
+ entries: List = emptyList(),
+ transcription: TranscriptionTaskSnapshot = TranscriptionTaskSnapshot(),
+ eligible: Boolean = true,
+ ) = AndroidTaskListSnapshotMapper.state(
+ AndroidMainScreenInput(
+ filter = filter,
+ model = ModelSetupSnapshot(if (modelReady) ModelSetupSnapshotState.READY else ModelSetupSnapshotState.REQUIRED),
+ output = OutputSetupSnapshot(OutputSetupSnapshotState.READY),
+ folder = FolderSetupSnapshot(FolderSetupSnapshotState.READY),
+ entries = entries,
+ transcription = transcription,
+ transcriptionEligible = eligible,
+ ),
+ )
+
+ private fun request(stableId: String, entryId: Long?, kind: TaskActionKind) =
+ AndroidTaskActionRequest(stableId, entryId, kind)
+
+ private fun entry(
+ id: Long,
+ state: AudioFileState = AudioFileState.PENDING,
+ transcript: String? = null,
+ ) = AudioCatalogEntry(
+ id = id,
+ folderUri = AndroidAudioImportConstants.SOURCE_ID,
+ documentUri = "content://audio/$id",
+ displayName = "$id.ogg",
+ mimeType = "audio/ogg",
+ fingerprint = AudioFileFingerprint(1024, id),
+ state = state,
+ stateBeforeMissing = null,
+ lastError = null,
+ processedAtMillis = id.takeIf { state == AudioFileState.PROCESSED || state == AudioFileState.FAILED },
+ transcriptText = transcript,
+ )
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/CatalogRefreshPolicyTest.kt b/app/src/test/java/me/maxistar/voiceinbox/CatalogRefreshPolicyTest.kt
index d336655..e42015b 100644
--- a/app/src/test/java/me/maxistar/voiceinbox/CatalogRefreshPolicyTest.kt
+++ b/app/src/test/java/me/maxistar/voiceinbox/CatalogRefreshPolicyTest.kt
@@ -1,7 +1,6 @@
package me.maxistar.voiceinbox
import me.maxistar.voiceinbox.core.AudioCatalogSourceScope
-import me.maxistar.voiceinbox.core.MainScreenCatalogTab
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -35,27 +34,19 @@ class CatalogRefreshPolicyTest {
}
@Test
- fun refreshTokenRejectsOldGenerationScopeAndTab() {
+ fun refreshTokenRejectsOldGenerationAndScope() {
val scope = AudioCatalogSourceScope.of(listOf("imports", "folder"))
- val token = CatalogRefreshToken(3, scope, MainScreenCatalogTab.NEW)
+ val token = CatalogRefreshToken(3, scope)
- assertTrue(
- CatalogRefreshPolicy.isCurrent(token, 3, scope, MainScreenCatalogTab.NEW),
- )
- assertFalse(
- CatalogRefreshPolicy.isCurrent(token, 4, scope, MainScreenCatalogTab.NEW),
- )
+ assertTrue(CatalogRefreshPolicy.isCurrent(token, 3, scope))
+ assertFalse(CatalogRefreshPolicy.isCurrent(token, 4, scope))
assertFalse(
CatalogRefreshPolicy.isCurrent(
token,
3,
AudioCatalogSourceScope.single("imports"),
- MainScreenCatalogTab.NEW,
),
)
- assertFalse(
- CatalogRefreshPolicy.isCurrent(token, 3, scope, MainScreenCatalogTab.PROCESSED),
- )
}
private fun workKey(
diff --git a/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt b/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt
new file mode 100644
index 0000000..5c74213
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt
@@ -0,0 +1,99 @@
+package me.maxistar.voiceinbox
+
+import me.maxistar.voiceinbox.core.AudioFileState
+import me.maxistar.voiceinbox.core.AudioTaskSnapshot
+import me.maxistar.voiceinbox.core.FolderSetupSnapshot
+import me.maxistar.voiceinbox.core.FolderSetupSnapshotState
+import me.maxistar.voiceinbox.core.ModelSetupSnapshot
+import me.maxistar.voiceinbox.core.ModelSetupSnapshotState
+import me.maxistar.voiceinbox.core.OutputSetupSnapshot
+import me.maxistar.voiceinbox.core.OutputSetupSnapshotState
+import me.maxistar.voiceinbox.core.TaskActionKind
+import me.maxistar.voiceinbox.core.TaskListFilter
+import me.maxistar.voiceinbox.core.TaskListInput
+import me.maxistar.voiceinbox.core.TaskListPresentationController
+import me.maxistar.voiceinbox.core.TranscriptionTaskSnapshot
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class TaskListDisplayItemsTest {
+ @Test
+ fun setupBatchAndAudioItemsHaveStableKindsAndPlacement() {
+ val items = items(
+ filter = TaskListFilter.NEW,
+ model = ModelSetupSnapshot(ModelSetupSnapshotState.REQUIRED, downloadAvailable = true),
+ output = OutputSetupSnapshot(OutputSetupSnapshotState.REQUIRED),
+ audio = listOf(pending(7)),
+ )
+
+ assertEquals(
+ listOf(
+ TaskListDisplayItem.Setup::class,
+ TaskListDisplayItem.Setup::class,
+ TaskListDisplayItem.BatchAction::class,
+ TaskListDisplayItem.Audio::class,
+ ),
+ items.map { it::class },
+ )
+ assertEquals(listOf("setup:model", "setup:output", "action:transcribe-all", "audio:7"), items.map { it.stableKey })
+ assertEquals(1, (items[2] as TaskListDisplayItem.BatchAction).eligibleCount)
+ }
+
+ @Test
+ fun filterAndContentChangesPreserveIdentityButUpdateContents() {
+ val idle = items(audio = listOf(pending(7))).single { it is TaskListDisplayItem.Audio }
+ val active = items(
+ audio = listOf(pending(7)),
+ transcription = TranscriptionTaskSnapshot(active = true, activeEntryId = 7, phase = "Transcribing", percent = 20),
+ ).single { it is TaskListDisplayItem.Audio }
+
+ assertTrue(TaskListDisplayItemDiff.areItemsTheSame(idle, active))
+ assertFalse(TaskListDisplayItemDiff.areContentsTheSame(idle, active))
+ assertEquals("Transcribing", (active as TaskListDisplayItem.Audio).task.progress?.phase)
+ assertEquals(20, active.task.progress?.percent)
+
+ val processed = items(filter = TaskListFilter.PROCESSED, audio = listOf(pending(7)))
+ assertTrue(processed.single() is TaskListDisplayItem.Empty)
+ assertNotEquals(idle.stableKey, processed.single().stableKey)
+ }
+
+ @Test
+ fun emptyStateCarriesOnlyPresentedTypedActions() {
+ val item = items().single() as TaskListDisplayItem.Empty
+
+ assertEquals("No new tasks", item.message)
+ assertEquals(
+ listOf(TaskActionKind.IMPORT_AUDIO, TaskActionKind.SELECT_FOLDER),
+ item.actions.map { it.kind },
+ )
+ }
+
+ private fun items(
+ filter: TaskListFilter = TaskListFilter.NEW,
+ model: ModelSetupSnapshot = ModelSetupSnapshot(ModelSetupSnapshotState.READY),
+ output: OutputSetupSnapshot = OutputSetupSnapshot(OutputSetupSnapshotState.READY),
+ audio: List = emptyList(),
+ transcription: TranscriptionTaskSnapshot = TranscriptionTaskSnapshot(),
+ ): List = TaskListDisplayItems.from(
+ TaskListPresentationController.state(
+ TaskListInput(
+ filter = filter,
+ model = model,
+ output = output,
+ folder = FolderSetupSnapshot(FolderSetupSnapshotState.READY),
+ audio = audio,
+ transcription = transcription,
+ ),
+ ),
+ )
+
+ private fun pending(id: Long) = AudioTaskSnapshot(
+ entryId = id,
+ title = "$id.ogg",
+ state = AudioFileState.PENDING,
+ importedAtMillis = id,
+ )
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 921b511..034e7d1 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -10,6 +10,8 @@ material = "1.13.0"
activity = "1.13.0"
constraintlayout = "2.2.1"
sqldelight = "2.1.0"
+lifecycle = "2.9.4"
+recyclerview = "1.2.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -24,6 +26,9 @@ sqldelight-android-driver = { group = "app.cash.sqldelight", name = "android-dri
sqldelight-native-driver = { group = "app.cash.sqldelight", name = "native-driver", version.ref = "sqldelight" }
sqldelight-runtime = { group = "app.cash.sqldelight", name = "runtime", version.ref = "sqldelight" }
sqldelight-sqlite-driver = { group = "app.cash.sqldelight", name = "sqlite-driver", version.ref = "sqldelight" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
+androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/iosApp/VoiceInbox/ContentView.swift b/iosApp/VoiceInbox/ContentView.swift
index e1d0826..74d158d 100644
--- a/iosApp/VoiceInbox/ContentView.swift
+++ b/iosApp/VoiceInbox/ContentView.swift
@@ -12,10 +12,7 @@ struct ContentView: View {
@StateObject private var transcriber = IosSingleFileTranscriptionController()
@StateObject private var startupPolicyStore = IosStartupProcessingPolicyStore()
@State private var selectedTab = IosShellCatalogSelection.new
- @State private var showingImporter = false
- @State private var showingInboxFolderPicker = false
- @State private var showingOutputPicker = false
- @State private var showingModelImporter = false
+ @State private var presentedPicker: IosPresentedPicker?
@State private var shownTranscript: String?
@State private var startupProcessingChecked = false
@State private var startupFolderRefreshChecked = false
@@ -33,6 +30,7 @@ struct ContentView: View {
modelStatus: speechModelStore.status,
modelMessage: speechModelStore.message,
modelInstalling: speechModelStore.isInstalling,
+ modelInstallationPhase: speechModelStore.downloadProgress?.message ?? speechModelStore.message,
modelDownloadAvailable: modelDownloadAvailable,
modelDownloadProgress: speechModelStore.downloadProgress?.percent,
modelCanCancel: speechModelStore.canCancelDownload,
@@ -68,6 +66,7 @@ struct ContentView: View {
Button(action.label) {
perform(action: action, task: nil, screen: screen)
}
+ .buttonStyle(.borderless)
.disabled(!action.enabled)
}
}
@@ -105,7 +104,7 @@ struct ContentView: View {
Section {
Button {
- showingImporter = true
+ presentPicker(.audioFiles)
} label: {
Label("Import Audio Files", systemImage: "square.and.arrow.down")
}
@@ -121,6 +120,7 @@ struct ContentView: View {
}
}
.navigationTitle("Voice Inbox")
+ .navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
NavigationLink {
@@ -129,10 +129,10 @@ struct ContentView: View {
outputStore: outputStore,
startupPolicyStore: startupPolicyStore,
selectInboxFolder: {
- showingInboxFolderPicker = true
+ presentPicker(.audioFolder)
},
selectOutputFile: {
- showingOutputPicker = true
+ presentPicker(.outputFile)
}
)
} label: {
@@ -141,30 +141,30 @@ struct ContentView: View {
.accessibilityLabel("Settings")
}
}
- .sheet(isPresented: $showingImporter) {
- IosAudioDocumentPicker { urls in
- importStore.importFiles(from: urls)
- showingImporter = false
- selectedTab = .new
- }
- }
- .sheet(isPresented: $showingInboxFolderPicker) {
- IosSpeechModelDirectoryPicker { url in
- importStore.selectInboxFolder(url)
- showingInboxFolderPicker = false
- selectedTab = .new
- }
- }
- .sheet(isPresented: $showingOutputPicker) {
- IosOutputDocumentPicker { url in
- outputStore.selectOutputFile(url)
- showingOutputPicker = false
- }
- }
- .sheet(isPresented: $showingModelImporter) {
- IosSpeechModelDirectoryPicker { url in
- speechModelStore.installModel(from: url)
- showingModelImporter = false
+ .sheet(item: $presentedPicker) { picker in
+ switch picker {
+ case .audioFiles:
+ IosAudioDocumentPicker { urls in
+ importStore.importFiles(from: urls)
+ presentedPicker = nil
+ selectedTab = .new
+ }
+ case .audioFolder:
+ IosSpeechModelDirectoryPicker { url in
+ importStore.selectInboxFolder(url)
+ presentedPicker = nil
+ selectedTab = .new
+ }
+ case .outputFile:
+ IosOutputDocumentPicker { url in
+ outputStore.selectOutputFile(url)
+ presentedPicker = nil
+ }
+ case .speechModelFolder:
+ IosSpeechModelDirectoryPicker { url in
+ speechModelStore.installModel(from: url)
+ presentedPicker = nil
+ }
}
}
.onChange(of: importStore.files) { files in
@@ -264,18 +264,18 @@ struct ContentView: View {
case .modelDownload:
speechModelStore.downloadModel()
case .modelImport:
- showingModelImporter = true
+ presentPicker(.speechModelFolder)
case .modelCancel:
speechModelStore.cancelDownload()
case .outputSelection:
- showingOutputPicker = true
+ presentPicker(.outputFile)
case .folderSelection:
- showingInboxFolderPicker = true
+ presentPicker(.audioFolder)
case .folderRefresh:
importStore.refreshInboxFolder()
selectedTab = .new
case .audioImport:
- showingImporter = true
+ presentPicker(.audioFiles)
case .transcribe, .retry, .play, .stop, .showText:
guard let audioTask = task as? AudioTaskPresentation,
let file = screen.filesById[audioTask.entryId] else { return }
@@ -283,6 +283,11 @@ struct ContentView: View {
}
}
+ private func presentPicker(_ picker: IosPresentedPicker) {
+ guard presentedPicker == nil else { return }
+ presentedPicker = picker
+ }
+
private func performAudio(action: TaskActionKind, file: IosImportedAudioFile) {
switch action {
case .play:
@@ -412,6 +417,15 @@ struct ContentView: View {
}
+private enum IosPresentedPicker: String, Identifiable {
+ case audioFiles
+ case audioFolder
+ case outputFile
+ case speechModelFolder
+
+ var id: String { rawValue }
+}
+
private struct TaskListRow: View {
let task: TaskPresentation
let onAction: (TaskActionPresentation) -> Void
diff --git a/iosApp/VoiceInbox/IosMainScreenShellState.swift b/iosApp/VoiceInbox/IosMainScreenShellState.swift
index 5fab936..a7bb138 100644
--- a/iosApp/VoiceInbox/IosMainScreenShellState.swift
+++ b/iosApp/VoiceInbox/IosMainScreenShellState.swift
@@ -72,6 +72,7 @@ final class IosMainScreenShellState {
modelStatus: IosSpeechModelStatus,
modelMessage: String?,
modelInstalling: Bool,
+ modelInstallationPhase: String?,
modelDownloadAvailable: Bool,
modelDownloadProgress: Int?,
modelCanCancel: Bool,
@@ -91,6 +92,7 @@ final class IosMainScreenShellState {
status: modelStatus,
message: modelMessage,
installing: modelInstalling,
+ installationPhase: modelInstallationPhase,
downloadAvailable: modelDownloadAvailable,
progress: modelDownloadProgress,
canCancel: modelCanCancel
@@ -110,7 +112,8 @@ final class IosMainScreenShellState {
lastError: file.lastError,
hasTranscriptText: file.transcriptText?.isEmpty == false,
noSpeech: Self.isNoSpeech(file.lastError),
- eligibleForTranscription: actionsEnabled
+ eligibleForTranscription: actionsEnabled,
+ eligibleForPreview: actionsEnabled
)
},
preview: PreviewTaskSnapshot(
@@ -141,6 +144,7 @@ final class IosMainScreenShellState {
status: IosSpeechModelStatus,
message: String?,
installing: Bool,
+ installationPhase: String?,
downloadAvailable: Bool,
progress: Int?,
canCancel: Bool
@@ -158,6 +162,7 @@ final class IosMainScreenShellState {
return ModelSetupSnapshot(
state: state,
detail: status.detail ?? message,
+ installationPhase: installing ? installationPhase : nil,
progressPercent: progress.map { KotlinInt(int: Int32($0)) },
downloadAvailable: downloadAvailable,
canCancel: canCancel
diff --git a/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift b/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift
index 610746d..d3a95cf 100644
--- a/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift
+++ b/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift
@@ -163,6 +163,7 @@ final class DeferredSpeechModelLoadingTests: XCTestCase {
modelStatus: modelStatus,
modelMessage: nil,
modelInstalling: false,
+ modelInstallationPhase: nil,
modelDownloadAvailable: false,
modelDownloadProgress: nil,
modelCanCancel: false,
@@ -203,6 +204,7 @@ final class DeferredSpeechModelLoadingTests: XCTestCase {
),
modelMessage: nil,
modelInstalling: false,
+ modelInstallationPhase: nil,
modelDownloadAvailable: false,
modelDownloadProgress: nil,
modelCanCancel: false,
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCase.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCase.kt
index cde6165..eecd85c 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCase.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCase.kt
@@ -146,6 +146,7 @@ class BatchTranscriptionUseCase(
catalog.claimPending(input.sourceScope)
} else if (completed == 0) {
catalog.claimFailed(input.sourceScope, input.retryEntryId)
+ ?: catalog.claimPending(input.sourceScope, input.retryEntryId)
} else {
null
}
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt
index a37806b..ef278df 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt
@@ -40,6 +40,7 @@ enum class TaskActionKind {
SELECT_FOLDER,
REFRESH_FOLDER,
TRANSCRIBE,
+ TRANSCRIBE_ALL,
RETRY_TRANSCRIPTION,
PLAY,
STOP,
@@ -110,6 +111,7 @@ enum class ModelSetupSnapshotState {
data class ModelSetupSnapshot(
val state: ModelSetupSnapshotState,
val detail: String? = null,
+ val installationPhase: String? = null,
val progressPercent: Int? = null,
val downloadAvailable: Boolean = false,
val canCancel: Boolean = false,
@@ -149,6 +151,7 @@ data class AudioTaskSnapshot(
val hasTranscriptText: Boolean = false,
val noSpeech: Boolean = false,
val eligibleForTranscription: Boolean = true,
+ val eligibleForPreview: Boolean = true,
)
data class PreviewTaskSnapshot(
@@ -270,8 +273,15 @@ object TaskListPresentationController {
},
title = "Install Speech Model",
detail = snapshot.detail,
- badge = if (active) "Downloading" else if (error) "Needs attention" else "Required",
- progress = if (active) TaskProgressPresentation("Downloading model", snapshot.progressPercent) else null,
+ badge = if (active) "Installing" else if (error) "Needs attention" else "Required",
+ progress = if (active) {
+ TaskProgressPresentation(
+ snapshot.installationPhase?.takeIf(String::isNotBlank) ?: "Installing model",
+ snapshot.progressPercent,
+ )
+ } else {
+ null
+ },
errorMessage = snapshot.detail.takeIf { error },
actions = actions,
)
@@ -369,7 +379,7 @@ object TaskListPresentationController {
TaskActionPresentation(
if (isPreviewing) TaskActionKind.STOP else TaskActionKind.PLAY,
if (isPreviewing) "Stop" else "Play",
- !input.transcription.active,
+ isPreviewing || (snapshot.eligibleForPreview && !input.transcription.active),
),
)
}
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCaseTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCaseTest.kt
index a48d015..4958817 100644
--- a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCaseTest.kt
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/BatchTranscriptionUseCaseTest.kt
@@ -51,6 +51,24 @@ class BatchTranscriptionUseCaseTest {
assertEquals(BatchTranscriptionResult(1, 1, 0, "Completed 1 of 1", 100, false), result)
}
+ @Test
+ fun singleEntryRequestClaimsOnlyTheRequestedPendingEntry() {
+ val catalog = FakeCatalog(
+ entry(1, "first.wav", AudioFileState.PENDING),
+ entry(2, "selected.wav", AudioFileState.PENDING),
+ )
+ val transcriber = FakeTranscriber(
+ 2L to SingleFileTranscriptionResult(10, 5, "selected"),
+ )
+
+ val result = useCase(catalog, transcriber).transcribe(input(retryEntryId = 2)) {}
+
+ assertEquals(listOf(2L), transcriber.transcribedIds)
+ assertEquals(AudioFileState.PENDING, catalog.entry(1).state)
+ assertEquals(AudioFileState.PROCESSED, catalog.entry(2).state)
+ assertEquals(1, result.total)
+ }
+
@Test
fun failedFileIsMarkedAndTranscribeAllContinues() {
val catalog = FakeCatalog(
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt
index a8f710d..78e09c9 100644
--- a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt
@@ -38,6 +38,60 @@ class TaskListPresentationControllerTest {
assertTrue(state.tasks.none { it.stableId == "setup:folder" })
}
+ @Test
+ fun activeModelInstallationUsesSuppliedPhaseAndNeutralFallback() {
+ val download = assertIs(
+ state(
+ model = ModelSetupSnapshot(
+ state = ModelSetupSnapshotState.INSTALLING,
+ installationPhase = "Downloading encoder.onnx",
+ progressPercent = 42,
+ canCancel = true,
+ ),
+ ).tasks.single(),
+ )
+
+ assertEquals("Installing", download.badge)
+ assertEquals("Downloading encoder.onnx", download.progress?.phase)
+ assertEquals(42, download.progress?.percent)
+ assertEquals(listOf(TaskActionKind.CANCEL_MODEL_DOWNLOAD), download.actions.map { it.kind })
+
+ val localImport = assertIs(
+ state(
+ model = ModelSetupSnapshot(
+ state = ModelSetupSnapshotState.INSTALLING,
+ installationPhase = "Verifying local model",
+ progressPercent = 75,
+ ),
+ ).tasks.single(),
+ )
+ assertEquals("Verifying local model", localImport.progress?.phase)
+ assertTrue(localImport.actions.isEmpty())
+
+ val fallback = assertIs(
+ state(model = ModelSetupSnapshot(ModelSetupSnapshotState.INSTALLING)).tasks.single(),
+ )
+ assertEquals("Installing model", fallback.progress?.phase)
+ assertNull(fallback.progress?.percent)
+ }
+
+ @Test
+ fun invalidModelOffersOnlyCurrentlySupportedRecoveryActions() {
+ val task = assertIs(
+ state(
+ model = ModelSetupSnapshot(
+ state = ModelSetupSnapshotState.INVALID,
+ detail = "Verification failed",
+ downloadAvailable = false,
+ ),
+ ).tasks.single(),
+ )
+
+ assertEquals("Verification failed", task.errorMessage)
+ assertFalse(task.actions.single { it.kind == TaskActionKind.RETRY_MODEL_DOWNLOAD }.enabled)
+ assertTrue(task.actions.single { it.kind == TaskActionKind.IMPORT_MODEL }.enabled)
+ }
+
@Test
fun filtersApplyOpenTerminalAndAllRetentionRules() {
val audio = listOf(
From fe06bc70ef5594afa39b05e6e612950bd4322051 Mon Sep 17 00:00:00 2001
From: Max Starikov
Date: Thu, 16 Jul 2026 13:26:21 +0200
Subject: [PATCH 09/10] fix folder button
---
.../maxistar/voiceinbox/TaskListDisplayItemsTest.kt | 2 +-
.../core/TaskListPresentationController.kt | 10 ++++++----
.../core/TaskListPresentationControllerTest.kt | 12 ++++++++++--
3 files changed, 17 insertions(+), 7 deletions(-)
diff --git a/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt b/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt
index 5c74213..0870917 100644
--- a/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt
+++ b/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt
@@ -66,7 +66,7 @@ class TaskListDisplayItemsTest {
assertEquals("No new tasks", item.message)
assertEquals(
- listOf(TaskActionKind.IMPORT_AUDIO, TaskActionKind.SELECT_FOLDER),
+ listOf(TaskActionKind.IMPORT_AUDIO),
item.actions.map { it.kind },
)
}
diff --git a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt
index ef278df..40ac0fc 100644
--- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt
+++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt
@@ -215,10 +215,12 @@ object TaskListPresentationController {
tasks = tasks,
emptyMessage = if (tasks.isEmpty()) emptyMessage(input.filter) else null,
emptyActions = if (tasks.isEmpty() && input.filter != TaskListFilter.PROCESSED) {
- listOf(
- TaskActionPresentation(TaskActionKind.IMPORT_AUDIO, "Import Audio Files"),
- TaskActionPresentation(TaskActionKind.SELECT_FOLDER, "Select Audio Folder"),
- )
+ buildList {
+ add(TaskActionPresentation(TaskActionKind.IMPORT_AUDIO, "Import Audio Files"))
+ if (input.folder.state == FolderSetupSnapshotState.UNSELECTED) {
+ add(TaskActionPresentation(TaskActionKind.SELECT_FOLDER, "Select Audio Folder"))
+ }
+ }
} else {
emptyList()
},
diff --git a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt
index 78e09c9..228e1f3 100644
--- a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt
+++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt
@@ -204,12 +204,20 @@ class TaskListPresentationControllerTest {
}
@Test
- fun emptyStateIsSpecificToFilterAndOffersImportOnlyForOpenLists() {
+ fun emptyStateOffersFolderSelectionOnlyWhenFolderIsUnselected() {
val newState = state(TaskListFilter.NEW)
+ val withoutFolder = state(
+ filter = TaskListFilter.NEW,
+ folder = FolderSetupSnapshot(FolderSetupSnapshotState.UNSELECTED),
+ )
val processed = state(TaskListFilter.PROCESSED)
assertEquals("No new tasks", newState.emptyMessage)
- assertEquals(listOf(TaskActionKind.IMPORT_AUDIO, TaskActionKind.SELECT_FOLDER), newState.emptyActions.map { it.kind })
+ assertEquals(listOf(TaskActionKind.IMPORT_AUDIO), newState.emptyActions.map { it.kind })
+ assertEquals(
+ listOf(TaskActionKind.IMPORT_AUDIO, TaskActionKind.SELECT_FOLDER),
+ withoutFolder.emptyActions.map { it.kind },
+ )
assertEquals("No processed audio files", processed.emptyMessage)
assertTrue(processed.emptyActions.isEmpty())
}
From b65ab7df60e7e997b6d9f43256149f75b6104500 Mon Sep 17 00:00:00 2001
From: Max Starikov
Date: Fri, 17 Jul 2026 15:33:06 +0200
Subject: [PATCH 10/10] refresh folder improvement
---
.../MainActivityInstrumentedTest.kt | 179 +++++++++
.../AndroidFolderSyncCoordinator.kt | 48 +++
.../voiceinbox/AndroidMainScreenStateHost.kt | 64 +++-
.../me/maxistar/voiceinbox/MainActivity.kt | 343 +++++++++++++++---
.../me/maxistar/voiceinbox/TaskListAdapter.kt | 47 ++-
.../main/res/layout/action_refresh_folder.xml | 9 +
app/src/main/res/menu/main_options.xml | 3 +-
app/src/main/res/values/strings.xml | 2 +
.../AndroidFolderSyncCoordinatorTest.kt | 75 ++++
.../AndroidMainScreenStateHostTest.kt | 100 +++++
.../voiceinbox/AndroidTaskActionRouterTest.kt | 1 +
.../voiceinbox/TaskListDisplayItemsTest.kt | 30 ++
12 files changed, 829 insertions(+), 72 deletions(-)
create mode 100644 app/src/main/java/me/maxistar/voiceinbox/AndroidFolderSyncCoordinator.kt
create mode 100644 app/src/main/res/layout/action_refresh_folder.xml
create mode 100644 app/src/test/java/me/maxistar/voiceinbox/AndroidFolderSyncCoordinatorTest.kt
diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
index b271e00..31694a1 100644
--- a/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
+++ b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt
@@ -27,6 +27,7 @@ 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
@@ -206,6 +207,180 @@ class MainActivityInstrumentedTest {
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 ->
+ assertEquals(
+ AndroidFolderSyncPresentation.ACCESSIBILITY_REFRESH,
+ refreshAction(activity).contentDescription,
+ )
+ }
+
+ scenario.onActivity { activity ->
+ stateHost(activity).update { input ->
+ input.copy(
+ folderSync = AndroidFolderSyncPresentation(
+ visible = true,
+ active = true,
+ accessibilityLabel = AndroidFolderSyncPresentation.ACCESSIBILITY_REFRESHING,
+ ),
+ )
+ }
+ stateHost(activity).update { input ->
+ input.copy(folderSync = AndroidFolderSyncPresentation(visible = true, enabled = true))
+ }
+ }
+ Thread.sleep(220)
+ scenario.onActivity { activity -> assertEquals(0f, refreshAction(activity).rotation) }
+
+ 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 progressHeavyUpdatesKeepTheSameRowAndActionView() {
+ clearActivityState()
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ awaitActivity(scenario) { activity -> stateHost(activity).currentInput.hydration.modelKnown }
+ scenario.onActivity { activity ->
+ 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),
+ ),
+ )
+ }
+ awaitActivity(scenario) { activity -> displayItems(activity).singleOrNull()?.stableKey == "setup:model" }
+
+ 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
WorkManager.getInstance(context).cancelUniqueWork(TranscriptionWorker.UNIQUE_WORK_NAME).result.get(30, TimeUnit.SECONDS)
@@ -255,6 +430,10 @@ class MainActivityInstrumentedTest {
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 }
diff --git a/app/src/main/java/me/maxistar/voiceinbox/AndroidFolderSyncCoordinator.kt b/app/src/main/java/me/maxistar/voiceinbox/AndroidFolderSyncCoordinator.kt
new file mode 100644
index 0000000..1c3be06
--- /dev/null
+++ b/app/src/main/java/me/maxistar/voiceinbox/AndroidFolderSyncCoordinator.kt
@@ -0,0 +1,48 @@
+package me.maxistar.voiceinbox
+
+enum class AndroidFolderSyncPhase {
+ IDLE,
+ VALIDATING,
+ QUEUED,
+ SCANNING,
+ REFRESHING_CATALOG,
+}
+
+data class AndroidFolderSyncState(
+ val generation: Long = 0,
+ val phase: AndroidFolderSyncPhase = AndroidFolderSyncPhase.IDLE,
+) {
+ val active: Boolean get() = phase != AndroidFolderSyncPhase.IDLE
+}
+
+class AndroidFolderSyncCoordinator {
+ var state: AndroidFolderSyncState = AndroidFolderSyncState()
+ private set
+
+ fun begin(phase: AndroidFolderSyncPhase = AndroidFolderSyncPhase.VALIDATING): Long {
+ val generation = state.generation + 1
+ state = AndroidFolderSyncState(generation, phase)
+ return generation
+ }
+
+ fun transition(generation: Long, phase: AndroidFolderSyncPhase): Boolean {
+ if (!isCurrent(generation) || phase == AndroidFolderSyncPhase.IDLE) return false
+ state = state.copy(phase = phase)
+ return true
+ }
+
+ fun complete(generation: Long): Boolean {
+ if (!isCurrent(generation)) return false
+ state = state.copy(phase = AndroidFolderSyncPhase.IDLE)
+ return true
+ }
+
+ fun fail(generation: Long): Boolean = complete(generation)
+
+ fun reset() {
+ state = AndroidFolderSyncState(state.generation + 1, AndroidFolderSyncPhase.IDLE)
+ }
+
+ fun isCurrent(generation: Long): Boolean =
+ state.active && state.generation == generation
+}
diff --git a/app/src/main/java/me/maxistar/voiceinbox/AndroidMainScreenStateHost.kt b/app/src/main/java/me/maxistar/voiceinbox/AndroidMainScreenStateHost.kt
index 6116d34..795d378 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/AndroidMainScreenStateHost.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/AndroidMainScreenStateHost.kt
@@ -32,17 +32,45 @@ data class AndroidMainScreenInput(
val transcriptionEligible: Boolean = false,
val previewEligible: Boolean = true,
val importEnabled: Boolean = true,
- val refreshFolderVisible: Boolean = false,
- val refreshFolderEnabled: Boolean = false,
+ 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 refreshFolderVisible: Boolean,
- val refreshFolderEnabled: Boolean,
-)
+ val folderSync: AndroidFolderSyncPresentation,
+) {
+ val refreshFolderVisible: Boolean get() = folderSync.visible
+ val refreshFolderEnabled: Boolean get() = folderSync.enabled
+}
object AndroidTaskListSnapshotMapper {
fun state(input: AndroidMainScreenInput): AndroidMainScreenState {
@@ -61,22 +89,31 @@ object AndroidTaskListSnapshotMapper {
eligibleForPreview = input.previewEligible,
)
}
- return AndroidMainScreenState(
- taskList = TaskListPresentationController.state(
+ val taskList = TaskListPresentationController.state(
TaskListInput(
filter = input.filter,
- model = input.model,
- output = input.output,
- folder = input.folder,
+ 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,
- refreshFolderVisible = input.refreshFolderVisible,
- refreshFolderEnabled = input.refreshFolderEnabled,
+ folderSync = input.folderSync,
)
}
@@ -97,6 +134,7 @@ class AndroidMainScreenStateHost(
) : 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
diff --git a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
index 03a45aa..ea1bd12 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt
@@ -6,8 +6,11 @@ import android.content.Intent
import android.media.MediaPlayer
import android.net.Uri
import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
import android.view.Menu
import android.view.MenuItem
+import android.view.View
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
@@ -21,6 +24,7 @@ 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
@@ -96,10 +100,22 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
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(
@@ -152,6 +168,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
startupCoordinator = StartupProcessingCoordinator.restore(
savedInstanceState?.getString(STATE_STARTUP_PROCESSING_STAGE),
)
+ restoreRetainedPresentation()
taskActionRouter = AndroidTaskActionRouter(
currentState = { taskStateHost.state.value },
gateway = AndroidTaskActionGateway(::performTaskAction),
@@ -213,6 +230,13 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
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
}
@@ -225,9 +249,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
override fun onOptionsItemSelected(item: MenuItem): Boolean =
when (item.itemId) {
R.id.menuRefreshFolder -> {
- if (selectionControls().refreshEnabled) {
- scanFolder()
- }
+ dispatchManualFolderRefresh()
true
}
R.id.menuSettings -> {
@@ -239,6 +261,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
override fun onDestroy() {
activityDestroyed = true
+ refreshIndicatorHandler.removeCallbacksAndMessages(null)
+ refreshIndicatorAnimator?.cancel()
+ refreshIndicatorAnimator = null
stopPreviewPlayback(render = false)
folderExecutor.shutdown()
importExecutor.shutdown()
@@ -255,6 +280,22 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
taskAdapter = TaskListAdapter(::handleTaskAction)
taskList.layoutManager = LinearLayoutManager(this)
taskList.adapter = taskAdapter
+ (taskList.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false
+ }
+
+ 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() {
@@ -266,6 +307,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
outputUri = stored
outputAccessReady = false
outputDisplayName = null
+ outputPresentationKnown = stored == null
startupCoordinator.setOutputReady(false)
publishTaskState()
}
@@ -274,6 +316,11 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
folderUri = stored
folderAccessReady = false
folderDisplayName = null
+ folderPresentationKnown = stored == null
+ if (stored == null) {
+ pendingFolderSyncGeneration = null
+ taskStateHost.folderSyncCoordinator.reset()
+ }
startupCoordinator.setFolderReady(false)
publishTaskState()
}
@@ -285,9 +332,12 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
if (storedOutput == null) {
outputAccessError = null
outputAccessReady = false
+ outputPresentationKnown = true
startupCoordinator.setOutputReady(false)
+ publishTaskState()
} else {
outputAccessReady = false
+ outputPresentationKnown = false
startupCoordinator.setOutputReady(false)
publishTaskState()
folderExecutor.execute {
@@ -304,6 +354,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
outputGeneration != outputValidationGeneration ||
outputUri != storedOutput
) return@runOnUiThread
+ outputPresentationKnown = true
when (result.state) {
PersistedSelectionAccessState.VALID -> {
outputAccessReady = true
@@ -320,9 +371,12 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
}
PersistedSelectionAccessState.PERMISSION_REVOKED -> {
selectionStore.clearOutputUri()
- setStoredOutputIdentity(null)
+ outputUri = null
+ outputAccessReady = false
+ outputDisplayName = null
outputAccessError = result.error?.message
?: "Output file access was revoked; select it again"
+ startupCoordinator.setOutputReady(false)
}
}
publishTaskState()
@@ -339,12 +393,19 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
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()
@@ -363,7 +424,11 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
folderGeneration != folderValidationGeneration ||
folderUri != storedFolder
) return@runOnUiThread
+ if (!taskStateHost.folderSyncCoordinator.isCurrent(folderSyncGeneration)) {
+ return@runOnUiThread
+ }
folderChecking = false
+ folderPresentationKnown = true
when (result.state) {
PersistedSelectionAccessState.VALID -> {
folderAccessReady = true
@@ -372,6 +437,13 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
updateFolderSummary(result.value)
if (scanFolderOnSuccess) {
pendingFolderScanOrigin = FolderScanOrigin.STARTUP
+ pendingFolderSyncGeneration = folderSyncGeneration
+ taskStateHost.folderSyncCoordinator.transition(
+ folderSyncGeneration,
+ AndroidFolderSyncPhase.QUEUED,
+ )
+ } else {
+ taskStateHost.folderSyncCoordinator.complete(folderSyncGeneration)
}
}
PersistedSelectionAccessState.TEMPORARILY_UNAVAILABLE -> {
@@ -380,17 +452,25 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
?: "Audio folder is temporarily unavailable"
startupCoordinator.setFolderReady(false)
folderDisplayName = null
+ taskStateHost.folderSyncCoordinator.fail(folderSyncGeneration)
}
PersistedSelectionAccessState.PERMISSION_REVOKED -> {
selectionStore.clearFolderUri()
- setStoredFolderIdentity(null)
+ 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()
- refreshCatalog()
+ if (result.state != PersistedSelectionAccessState.VALID || !scanFolderOnSuccess) {
+ refreshCatalog()
+ }
maybeStartPendingFolderScan()
evaluateStartupProcessing()
}
@@ -401,11 +481,18 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
val origin = pendingFolderScanOrigin ?: return
if (
!folderAccessReady ||
- transcriptionState == TranscriptionObservationState.UNKNOWN ||
- transcriptionActive()
+ transcriptionState == TranscriptionObservationState.UNKNOWN
) return
+ if (transcriptionActive()) {
+ pendingFolderSyncGeneration?.let(taskStateHost.folderSyncCoordinator::complete)
+ pendingFolderSyncGeneration = null
+ publishTaskState()
+ return
+ }
+ val folderSyncGeneration = pendingFolderSyncGeneration
pendingFolderScanOrigin = null
- scanFolder(origin)
+ pendingFolderSyncGeneration = null
+ scanFolder(origin, folderSyncGeneration)
}
private fun acceptOutput(uri: Uri) {
@@ -430,6 +517,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
outputUri = uri
outputAccessReady = true
outputAccessError = null
+ outputPresentationKnown = true
startupCoordinator.setOutputReady(true)
updateOutputSummary(metadata.displayName)
outputAccessError = null
@@ -444,6 +532,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
validationGeneration != outputValidationGeneration
) return@runOnUiThread
outputAccessError = it.message ?: "Output file is not writable"
+ outputPresentationKnown = true
publishTaskState()
updateControls()
evaluateStartupProcessing()
@@ -454,6 +543,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
private fun acceptFolder(uri: Uri) {
val validationGeneration = ++folderValidationGeneration
+ val folderSyncGeneration = taskStateHost.folderSyncCoordinator.begin()
runCatching {
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
@@ -464,6 +554,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
validationGeneration != folderValidationGeneration
) return@runOnUiThread
folderChecking = true
+ folderPresentationKnown = false
publishTaskState()
updateControls()
}
@@ -480,14 +571,19 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
folderChecking = false
folderUri = uri
folderAccessReady = true
+ folderPresentationKnown = true
folderAccessError = null
startupCoordinator.setFolderReady(true)
updateFolderSummary(name)
folderAccessError = null
publishTaskState()
updateControls()
- refreshCatalog()
pendingFolderScanOrigin = FolderScanOrigin.USER
+ pendingFolderSyncGeneration = folderSyncGeneration
+ taskStateHost.folderSyncCoordinator.transition(
+ folderSyncGeneration,
+ AndroidFolderSyncPhase.QUEUED,
+ )
maybeStartPendingFolderScan()
}
}.onFailure {
@@ -497,7 +593,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
validationGeneration != folderValidationGeneration
) return@runOnUiThread
folderChecking = false
+ folderPresentationKnown = true
folderAccessError = it.message ?: "Audio folder is not readable"
+ taskStateHost.folderSyncCoordinator.fail(folderSyncGeneration)
publishTaskState()
updateControls()
evaluateStartupProcessing()
@@ -517,6 +615,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
)
}
if (permission.isFailure) {
+ modelPresentationKnown = true
+ modelReady = false
+ modelSetupState = ModelSetupSnapshotState.INVALID
setModelUi(
"The selected model folder cannot be accessed after the picker closes",
canDownload = true,
@@ -525,6 +626,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
return
}
if (!alreadyPersisted) SpeechModelImportPermission.recordOwned(this, uri)
+ modelPresentationKnown = true
+ modelReady = false
+ modelSetupState = ModelSetupSnapshotState.INSTALLING
setModelUi("Checking local speech model", canDownload = false)
updateControls()
importExecutor.execute {
@@ -542,6 +646,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
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,
@@ -552,16 +659,23 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
}
}
- private fun scanFolder(origin: FolderScanOrigin = FolderScanOrigin.USER) {
+ private fun scanFolder(
+ origin: FolderScanOrigin = FolderScanOrigin.USER,
+ existingSyncGeneration: Long? = null,
+ ) {
val folder = folderUri ?: 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)
@@ -577,6 +691,10 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
totalFiles = 0
failedFiles = 0
folderAccessError = null
+ taskStateHost.folderSyncCoordinator.transition(
+ folderSyncGeneration,
+ AndroidFolderSyncPhase.QUEUED,
+ )
publishTaskState()
updateControls()
folderExecutor.execute {
@@ -585,6 +703,10 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
folderScanQueued = false
scanning = true
scanMessage = "Scanning folder"
+ taskStateHost.folderSyncCoordinator.transition(
+ folderSyncGeneration,
+ AndroidFolderSyncPhase.SCANNING,
+ )
publishTaskState()
updateControls()
}
@@ -599,7 +721,11 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
scanning = false
folderAccessError = null
scanMessage = "Scan complete: $count audio files"
- refreshCatalog(startupGeneration)
+ taskStateHost.folderSyncCoordinator.transition(
+ folderSyncGeneration,
+ AndroidFolderSyncPhase.REFRESHING_CATALOG,
+ )
+ refreshCatalog(startupGeneration, folderSyncGeneration)
}
}.onFailure { error ->
runOnUiThread {
@@ -607,15 +733,21 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
folderScanQueued = false
scanning = false
folderAccessError = error.message ?: "Folder scan failed"
+ folderPresentationKnown = true
+ taskStateHost.folderSyncCoordinator.fail(folderSyncGeneration)
startupGeneration?.let(startupCoordinator::onStartupScanFailed)
evaluateStartupProcessing()
updateControls()
+ publishTaskState()
}
}
}
}
- private fun refreshCatalog(startupGeneration: Long? = null) {
+ private fun refreshCatalog(
+ startupGeneration: Long? = null,
+ folderSyncGeneration: Long? = null,
+ ) {
if (activityDestroyed) return
if (startupGeneration != null) {
pendingStartupCatalogGeneration = startupGeneration
@@ -626,45 +758,66 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
)
val startupGenerationForRequest = pendingStartupCatalogGeneration
folderExecutor.execute {
- val newEntries = catalog.newEntries(token.sourceScope)
- val processedEntries = catalog.processedEntries(token.sourceScope)
- val entries = (newEntries + processedEntries).distinctBy(AudioCatalogEntry::id)
- runOnUiThread {
- if (activityDestroyed) return@runOnUiThread
- if (
- !CatalogRefreshPolicy.isCurrent(
- request = token,
- currentGeneration = catalogRefreshGeneration,
- currentSourceScope = activeSourceScope(),
- )
- ) return@runOnUiThread
- pendingCount = newEntries.count { it.state == AudioFileState.PENDING }
- val catalogFailedCount = processedEntries.count { it.state == AudioFileState.FAILED }
- renderEntries(entries)
- publishTaskState()
- updateControls()
- if (startupGenerationForRequest != null) {
- if (pendingStartupCatalogGeneration == startupGenerationForRequest) {
- pendingStartupCatalogGeneration = null
+ runCatching {
+ val newEntries = catalog.newEntries(token.sourceScope)
+ val processedEntries = catalog.processedEntries(token.sourceScope)
+ Triple(
+ newEntries,
+ processedEntries,
+ (newEntries + processedEntries).distinctBy(AudioCatalogEntry::id),
+ )
+ }.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
+ }
+ 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 {
+ startupCoordinator.onCatalogRefreshed(pendingCount, catalogFailedCount)
+ }
+ evaluateStartupProcessing()
+ }
+ }.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()
}
- startupCoordinator.onStartupCatalogReady(
- startupGenerationForRequest,
- pendingCount,
- catalogFailedCount,
- )
- } else {
- startupCoordinator.onCatalogRefreshed(pendingCount, catalogFailedCount)
}
- evaluateStartupProcessing()
}
}
}
- private fun renderEntries(entries: List) {
- currentEntries = entries
- publishTaskState()
- }
-
private fun retryEntry(entry: AudioCatalogEntry) {
val output = outputUri ?: return
stopPreviewPlayback(render = true)
@@ -699,25 +852,29 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
when (state) {
SpeechModelReadinessState.Checking -> {
modelReady = false
- modelSetupState = ModelSetupSnapshotState.INSTALLING
+ modelPresentationKnown = false
setModelUi("Checking speech model", canDownload = false)
}
is SpeechModelReadinessState.Ready -> {
+ modelPresentationKnown = true
modelReady = true
modelSetupState = ModelSetupSnapshotState.READY
setModelUi("Speech model ready", canDownload = false)
}
SpeechModelReadinessState.Missing -> {
+ modelPresentationKnown = true
modelReady = false
modelSetupState = ModelSetupSnapshotState.REQUIRED
setModelUi("Speech model is not installed", canDownload = true)
}
is SpeechModelReadinessState.Invalid -> {
+ modelPresentationKnown = true
modelReady = false
modelSetupState = ModelSetupSnapshotState.INVALID
setModelUi("Invalid speech model: ${state.reason}", canDownload = true)
}
is SpeechModelReadinessState.Failed -> {
+ modelPresentationKnown = true
modelReady = false
modelSetupState = ModelSetupSnapshotState.INVALID
setModelUi(state.message, canDownload = true)
@@ -749,6 +906,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
WorkInfo.State.BLOCKED,
WorkInfo.State.RUNNING,
-> {
+ modelPresentationKnown = true
modelReady = false
modelSetupState = ModelSetupSnapshotState.INSTALLING
startupCoordinator.setModelReady(false)
@@ -779,6 +937,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
}
WorkInfo.State.FAILED -> {
SpeechModelImportPermission.releaseOwnedIfUnused(this)
+ modelPresentationKnown = true
modelReady = false
modelSetupState = ModelSetupSnapshotState.INVALID
startupCoordinator.setModelReady(false)
@@ -899,7 +1058,8 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
},
)
- private fun folderBusy(): Boolean = folderChecking || folderScanQueued || scanning
+ private fun folderBusy(): Boolean =
+ folderChecking || folderScanQueued || scanning || taskStateHost.folderSyncCoordinator.state.active
private fun launchOutputPickerIfEnabled() {
if (selectionControls().outputEnabled) {
@@ -1046,15 +1206,14 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
val folderState = when {
folderAccessError != null -> FolderSetupSnapshotState.ERROR
folderUri == null -> FolderSetupSnapshotState.UNSELECTED
- folderChecking || folderScanQueued || scanning -> FolderSetupSnapshotState.SCANNING
folderAccessReady -> FolderSetupSnapshotState.READY
- else -> FolderSetupSnapshotState.SCANNING
+ else -> FolderSetupSnapshotState.READY
}
val outputState = when {
outputAccessReady -> OutputSetupSnapshotState.READY
outputAccessError != null -> OutputSetupSnapshotState.INVALID
outputUri == null -> OutputSetupSnapshotState.REQUIRED
- else -> OutputSetupSnapshotState.REQUIRED
+ else -> OutputSetupSnapshotState.READY
}
val audioInputAvailable = folderUri?.let { folderAccessReady } ?: currentEntries.any {
it.folderUri == AndroidAudioImportConstants.SOURCE_ID
@@ -1077,11 +1236,13 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
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 = modelMessage.takeUnless { modelSetupState == ModelSetupSnapshotState.READY },
+ detail = androidModelTaskDetail(modelSetupState, modelMessage),
installationPhase = modelMessage.takeIf {
modelSetupState == ModelSetupSnapshotState.INSTALLING
},
@@ -1106,8 +1267,22 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
transcriptionEligible = eligible,
previewEligible = !folderBusy() && !transcriptionActive(),
importEnabled = !ingestionActive,
- refreshFolderVisible = folderUri != null,
- refreshFolderEnabled = selectionControls().refreshEnabled,
+ 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
+ },
+ ),
)
}
}
@@ -1140,7 +1315,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
TaskActionKind.CANCEL_MODEL_DOWNLOAD -> SpeechModelDownloadWorker.cancel(this)
TaskActionKind.SELECT_OUTPUT -> launchOutputPickerIfEnabled()
TaskActionKind.SELECT_FOLDER -> launchFolderPickerIfEnabled()
- TaskActionKind.REFRESH_FOLDER -> if (selectionControls().refreshEnabled) scanFolder()
+ TaskActionKind.REFRESH_FOLDER -> dispatchManualFolderRefresh()
TaskActionKind.IMPORT_AUDIO -> if (!ingestionActive) {
importPicker.launch(arrayOf("audio/*", "application/ogg"))
}
@@ -1168,11 +1343,62 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
private fun updateMenu(menu: Menu) {
val state = taskStateHost.state.value
menu.findItem(R.id.menuRefreshFolder)?.apply {
- isVisible = state.refreshFolderVisible
- isEnabled = state.refreshFolderEnabled
+ isVisible = state.folderSync.visible
+ isEnabled = state.folderSync.enabled
+ }
+ renderRefreshIndicator(state.folderSync)
+ }
+
+ 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 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()
@@ -1278,6 +1504,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen
private companion object {
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,
diff --git a/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt b/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt
index ea73775..787eec7 100644
--- a/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt
+++ b/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt
@@ -88,6 +88,28 @@ object TaskListDisplayItemDiff : DiffUtil.ItemCallback() {
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(
@@ -132,6 +154,23 @@ class TaskListAdapter(
}
}
+ 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,
@@ -158,6 +197,8 @@ class TaskListAdapter(
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
@@ -169,7 +210,7 @@ class TaskListAdapter(
bindActions(task, entryId)
}
- private fun bindProgress(value: TaskProgressPresentation?) {
+ fun bindProgress(value: TaskProgressPresentation?) {
progressPhase.isVisible = value != null
progress.isVisible = value != null
if (value == null) {
@@ -183,6 +224,10 @@ class TaskListAdapter(
}
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(
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/menu/main_options.xml b/app/src/main/res/menu/main_options.xml
index 4d40672..d394fcf 100644
--- a/app/src/main/res/menu/main_options.xml
+++ b/app/src/main/res/menu/main_options.xml
@@ -5,7 +5,8 @@
android:id="@+id/menuRefreshFolder"
android:icon="@drawable/ic_refresh_24"
android:title="@string/menu_refresh_folder"
- app:showAsAction="ifRoom" />
+ app:actionLayout="@layout/action_refresh_folder"
+ app:showAsAction="always" />
- Select audio folder
Change audio folder
Refresh
+ Audio folder refresh is idle
+ Refreshing audio folder
Settings
Settings
Storage
diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidFolderSyncCoordinatorTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidFolderSyncCoordinatorTest.kt
new file mode 100644
index 0000000..a2538a7
--- /dev/null
+++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidFolderSyncCoordinatorTest.kt
@@ -0,0 +1,75 @@
+package me.maxistar.voiceinbox
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class AndroidFolderSyncCoordinatorTest {
+ @Test
+ fun oneGenerationTraversesTheCompletePipeline() {
+ val coordinator = AndroidFolderSyncCoordinator()
+
+ val generation = coordinator.begin()
+ assertEquals(AndroidFolderSyncPhase.VALIDATING, coordinator.state.phase)
+ assertTrue(coordinator.transition(generation, AndroidFolderSyncPhase.QUEUED))
+ assertTrue(coordinator.transition(generation, AndroidFolderSyncPhase.SCANNING))
+ assertTrue(coordinator.transition(generation, AndroidFolderSyncPhase.REFRESHING_CATALOG))
+ assertTrue(coordinator.complete(generation))
+ assertEquals(AndroidFolderSyncPhase.IDLE, coordinator.state.phase)
+ }
+
+ @Test
+ fun newerGenerationRejectsStaleCallbacks() {
+ val coordinator = AndroidFolderSyncCoordinator()
+ val stale = coordinator.begin()
+ val current = coordinator.begin(AndroidFolderSyncPhase.SCANNING)
+
+ assertFalse(coordinator.transition(stale, AndroidFolderSyncPhase.REFRESHING_CATALOG))
+ assertFalse(coordinator.complete(stale))
+ assertTrue(coordinator.isCurrent(current))
+ assertEquals(AndroidFolderSyncPhase.SCANNING, coordinator.state.phase)
+ }
+
+ @Test
+ fun failureAndResetEndActivityAndInvalidateCallbacks() {
+ val coordinator = AndroidFolderSyncCoordinator()
+ val failed = coordinator.begin(AndroidFolderSyncPhase.SCANNING)
+ assertTrue(coordinator.fail(failed))
+ assertFalse(coordinator.state.active)
+
+ val reset = coordinator.begin()
+ coordinator.reset()
+ assertFalse(coordinator.isCurrent(reset))
+ assertFalse(coordinator.state.active)
+ }
+
+ @Test
+ fun everyActiveStageCanFailAndFastSyncCanFinishBeforeRendering() {
+ AndroidFolderSyncPhase.entries
+ .filterNot { it == AndroidFolderSyncPhase.IDLE }
+ .forEach { phase ->
+ val coordinator = AndroidFolderSyncCoordinator()
+ val generation = coordinator.begin(phase)
+
+ assertTrue(coordinator.fail(generation))
+ assertEquals(AndroidFolderSyncPhase.IDLE, coordinator.state.phase)
+ }
+
+ val coordinator = AndroidFolderSyncCoordinator()
+ val fast = coordinator.begin()
+ assertTrue(coordinator.complete(fast))
+ assertFalse(coordinator.state.active)
+ }
+
+ @Test
+ fun duplicateRequestSupersedesPriorGenerationWithoutCompletingTheNewOne() {
+ val coordinator = AndroidFolderSyncCoordinator()
+ val first = coordinator.begin(AndroidFolderSyncPhase.QUEUED)
+ val duplicate = coordinator.begin(AndroidFolderSyncPhase.QUEUED)
+
+ assertFalse(coordinator.complete(first))
+ assertTrue(coordinator.isCurrent(duplicate))
+ assertEquals(AndroidFolderSyncPhase.QUEUED, coordinator.state.phase)
+ }
+}
diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt
index 2361b40..1230776 100644
--- a/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt
+++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt
@@ -37,6 +37,7 @@ class AndroidMainScreenStateHostTest {
),
output = OutputSetupSnapshot(OutputSetupSnapshotState.INVALID, "Output unavailable"),
folder = FolderSetupSnapshot(FolderSetupSnapshotState.SCANNING, "Checking recordings"),
+ hydration = hydrated(),
),
)
@@ -48,6 +49,30 @@ class AndroidMainScreenStateHostTest {
assertEquals("Scanning audio folder", tasks[2].progress?.phase)
}
+ @Test
+ fun activeModelPhaseIsNotRepeatedAsTaskDetail() {
+ val message = "Downloading encoder-model.int8.onnx"
+ assertNull(androidModelTaskDetail(ModelSetupSnapshotState.INSTALLING, message))
+ assertEquals(message, androidModelTaskDetail(ModelSetupSnapshotState.REQUIRED, message))
+ assertEquals(message, androidModelTaskDetail(ModelSetupSnapshotState.INVALID, message))
+ val input = readyInput().copy(
+ model = ModelSetupSnapshot(
+ state = ModelSetupSnapshotState.INSTALLING,
+ detail = null,
+ installationPhase = message,
+ progressPercent = 42,
+ ),
+ )
+
+ val task = AndroidTaskListSnapshotMapper.state(input)
+ .taskList.tasks
+ .filterIsInstance()
+ .single { it.stableId == "setup:model" }
+
+ assertNull(task.detail)
+ assertEquals(message, task.progress?.phase)
+ }
+
@Test
fun mapperPreservesCatalogIdentitySourcesOrderingAndOutcomes() {
val entries = listOf(
@@ -144,6 +169,73 @@ class AndroidMainScreenStateHostTest {
assertTrue(state.taskList.emptyActions.any { it.kind == TaskActionKind.IMPORT_AUDIO })
}
+ @Test
+ fun pendingHydrationSuppressesFalseSetupAndFinalEmptyState() {
+ val state = AndroidTaskListSnapshotMapper.state(
+ AndroidMainScreenInput(
+ model = ModelSetupSnapshot(ModelSetupSnapshotState.REQUIRED),
+ output = OutputSetupSnapshot(OutputSetupSnapshotState.REQUIRED),
+ folder = FolderSetupSnapshot(FolderSetupSnapshotState.SCANNING),
+ hydration = AndroidMainScreenHydration(),
+ ),
+ )
+
+ assertTrue(state.taskList.tasks.isEmpty())
+ assertNull(state.taskList.emptyMessage)
+ assertTrue(state.taskList.emptyActions.isEmpty())
+ }
+
+ @Test
+ fun knownMissingAndInvalidSetupAppearBeforeCatalogHydration() {
+ val state = AndroidTaskListSnapshotMapper.state(
+ AndroidMainScreenInput(
+ model = ModelSetupSnapshot(ModelSetupSnapshotState.REQUIRED),
+ output = OutputSetupSnapshot(OutputSetupSnapshotState.INVALID, "Permission revoked"),
+ folder = FolderSetupSnapshot(FolderSetupSnapshotState.ERROR, "Folder unavailable"),
+ hydration = AndroidMainScreenHydration(
+ modelKnown = true,
+ outputKnown = true,
+ folderKnown = true,
+ ),
+ ),
+ )
+
+ assertEquals(
+ listOf("setup:model", "setup:output", "setup:folder"),
+ state.taskList.tasks.map { it.stableId },
+ )
+ assertNull(state.taskList.emptyMessage)
+ }
+
+ @Test
+ fun folderSyncPresentationIsRendererIndependentAndCopiedToState() {
+ val sync = AndroidFolderSyncPresentation(
+ visible = true,
+ active = true,
+ enabled = false,
+ accessibilityLabel = AndroidFolderSyncPresentation.ACCESSIBILITY_REFRESHING,
+ )
+ val state = AndroidTaskListSnapshotMapper.state(readyInput().copy(folderSync = sync))
+
+ assertEquals(sync, state.folderSync)
+ assertTrue(state.refreshFolderVisible)
+ assertFalse(state.refreshFolderEnabled)
+ }
+
+ @Test
+ fun hydratedCatalogPublishesEmptyStateAndRetainedEntriesRemainVisibleWhileRevalidating() {
+ val empty = AndroidTaskListSnapshotMapper.state(readyInput())
+ assertEquals("No new tasks", empty.taskList.emptyMessage)
+
+ val retained = AndroidTaskListSnapshotMapper.state(
+ readyInput(entries = listOf(entry(1, AudioFileState.PENDING))).copy(
+ hydration = AndroidMainScreenHydration(catalogKnown = true),
+ ),
+ )
+ assertEquals(listOf("audio:1"), retained.taskList.tasks.map { it.stableId })
+ assertNull(retained.taskList.emptyMessage)
+ }
+
private fun readyInput(
filter: TaskListFilter = TaskListFilter.NEW,
entries: List = emptyList(),
@@ -158,6 +250,14 @@ class AndroidMainScreenStateHostTest {
preview = preview,
transcription = transcription,
transcriptionEligible = true,
+ hydration = hydrated(),
+ )
+
+ private fun hydrated() = AndroidMainScreenHydration(
+ modelKnown = true,
+ outputKnown = true,
+ folderKnown = true,
+ catalogKnown = true,
)
private fun entry(
diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidTaskActionRouterTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidTaskActionRouterTest.kt
index 20fa69a..5ab31cb 100644
--- a/app/src/test/java/me/maxistar/voiceinbox/AndroidTaskActionRouterTest.kt
+++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidTaskActionRouterTest.kt
@@ -90,6 +90,7 @@ class AndroidTaskActionRouterTest {
entries = entries,
transcription = transcription,
transcriptionEligible = eligible,
+ hydration = AndroidMainScreenHydration(true, true, true, true),
),
)
diff --git a/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt b/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt
index 0870917..4634874 100644
--- a/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt
+++ b/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt
@@ -12,6 +12,7 @@ import me.maxistar.voiceinbox.core.TaskActionKind
import me.maxistar.voiceinbox.core.TaskListFilter
import me.maxistar.voiceinbox.core.TaskListInput
import me.maxistar.voiceinbox.core.TaskListPresentationController
+import me.maxistar.voiceinbox.core.TaskProgressPresentation
import me.maxistar.voiceinbox.core.TranscriptionTaskSnapshot
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -60,6 +61,35 @@ class TaskListDisplayItemsTest {
assertNotEquals(idle.stableKey, processed.single().stableKey)
}
+ @Test
+ fun diffUsesTypedPayloadOnlyForProgressOnlyChanges() {
+ val original = items(audio = listOf(pending(7)))
+ .filterIsInstance()
+ .single()
+ val progress = TaskProgressPresentation(
+ phase = "Transcribing",
+ percent = 35,
+ processedUs = 3_000_000,
+ durationUs = 10_000_000,
+ completedFiles = 1,
+ totalFiles = 3,
+ )
+ val progressUpdate = original.copy(task = original.task.copy(progress = progress))
+ val badgeUpdate = progressUpdate.copy(task = progressUpdate.task.copy(badge = "Changed"))
+ val actionsUpdate = progressUpdate.copy(task = progressUpdate.task.copy(actions = emptyList()))
+
+ assertEquals(
+ TaskListChangePayload.Progress(progress),
+ TaskListDisplayItemDiff.getChangePayload(original, progressUpdate),
+ )
+ assertEquals(null, TaskListDisplayItemDiff.getChangePayload(progressUpdate, badgeUpdate))
+ assertEquals(null, TaskListDisplayItemDiff.getChangePayload(progressUpdate, actionsUpdate))
+ assertEquals(
+ TaskListAdapter.stableLongId(original.stableKey),
+ TaskListAdapter.stableLongId(progressUpdate.stableKey),
+ )
+ }
+
@Test
fun emptyStateCarriesOnlyPresentedTypedActions() {
val item = items().single() as TaskListDisplayItem.Empty