From 549549d4ae5ed59b9912df34a7edb6356a580e84 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Fri, 18 Sep 2026 18:48:00 +0200 Subject: [PATCH 01/11] add russian translation --- .../AndroidLocalizationInstrumentedTest.kt | 34 +++ .../MainActivityInstrumentedTest.kt | 10 +- .../voiceinbox/AndroidInlineOnboarding.kt | 34 +-- .../voiceinbox/AndroidTaskTextResolver.kt | 56 +++++ .../AndroidVoiceKeyboardDiscovery.kt | 8 +- .../voiceinbox/AudioImportCoordinator.kt | 13 + .../me/maxistar/voiceinbox/MainActivity.kt | 68 +++--- .../maxistar/voiceinbox/SettingsActivity.kt | 16 +- .../voiceinbox/SpeechModelDownloadWorker.kt | 22 +- .../voiceinbox/SpeechModelImportWorker.kt | 30 ++- .../voiceinbox/SpeechModelInstallationWork.kt | 4 +- .../me/maxistar/voiceinbox/TaskListAdapter.kt | 43 ++-- .../voiceinbox/TranscriptionWorker.kt | 32 ++- app/src/main/res/values-ru/strings.xml | 230 ++++++++++++++++++ app/src/main/res/values/strings.xml | 110 +++++++++ .../AndroidAudioMetadataFormatterTest.kt | 2 +- .../voiceinbox/AndroidInlineOnboardingTest.kt | 4 +- .../AndroidMainScreenStateHostTest.kt | 17 +- .../voiceinbox/TaskListDisplayItemsTest.kt | 26 +- iosApp/VoiceInbox/ContentView.swift | 20 +- .../core/TaskListPresentationController.kt | 195 ++++++++++----- .../TaskListPresentationControllerTest.kt | 36 ++- 22 files changed, 784 insertions(+), 226 deletions(-) create mode 100644 app/src/androidTest/java/me/maxistar/voiceinbox/AndroidLocalizationInstrumentedTest.kt create mode 100644 app/src/main/java/me/maxistar/voiceinbox/AndroidTaskTextResolver.kt create mode 100644 app/src/main/res/values-ru/strings.xml diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/AndroidLocalizationInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/AndroidLocalizationInstrumentedTest.kt new file mode 100644 index 0000000..e76dfad --- /dev/null +++ b/app/src/androidTest/java/me/maxistar/voiceinbox/AndroidLocalizationInstrumentedTest.kt @@ -0,0 +1,34 @@ +package me.maxistar.voiceinbox + +import android.content.Context +import android.content.res.Configuration +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import me.maxistar.voiceinbox.core.TaskText +import me.maxistar.voiceinbox.core.TaskTextKey +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Locale + +@RunWith(AndroidJUnit4::class) +class AndroidLocalizationInstrumentedTest { + @Test + fun taskTextResolverAndPluralResourcesFollowTheRequestedLocale() { + val english = localizedContext(Locale.US) + val russian = localizedContext(Locale("ru", "RU")) + val taskTitle = TaskText(TaskTextKey.INSTALL_SPEECH_MODEL, "Install Speech Model") + + assertEquals("Install Speech Model", AndroidTaskTextResolver.resolve(english.resources, taskTitle)) + assertEquals("Установить речевую модель", AndroidTaskTextResolver.resolve(russian.resources, taskTitle)) + assertEquals("Расшифровать все (1 файл)", russian.resources.getQuantityString(R.plurals.transcribe_all_count, 1, 1)) + assertEquals("Расшифровать все (2 файла)", russian.resources.getQuantityString(R.plurals.transcribe_all_count, 2, 2)) + assertEquals("Расшифровать все (5 файлов)", russian.resources.getQuantityString(R.plurals.transcribe_all_count, 5, 5)) + } + + private fun localizedContext(locale: Locale): Context { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val configuration = Configuration(context.resources.configuration).apply { setLocale(locale) } + return context.createConfigurationContext(configuration) + } +} diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt index ad40f18..e825155 100644 --- a/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt +++ b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt @@ -202,10 +202,10 @@ class MainActivityInstrumentedTest { 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 } + val rows = audioRows(activity).associateBy { it.task.title.fallback } 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("silent.ogg").task.badge.fallback == "No speech" && rows.getValue("done.ogg").task.actions.any { it.kind == TaskActionKind.SHOW_TEXT } } } @@ -430,7 +430,7 @@ class MainActivityInstrumentedTest { assertTrue(items.take(hintIndex).all { it is TaskListDisplayItem.Setup }) val hint = items[hintIndex] as TaskListDisplayItem.OnboardingHint assertEquals(TaskActionKind.DOWNLOAD_MODEL, hint.presentation.action?.kind) - assertTrue(hint.presentation.downloadDisclosure?.contains("downloads") == true) + assertEquals(R.string.onboarding_download_disclosure, hint.presentation.downloadDisclosureRes) } scenario.onActivity { activity -> @@ -454,7 +454,7 @@ class MainActivityInstrumentedTest { val hint = items.filterIsInstance().single() assertTrue(model.task.progress != null) assertTrue(model.task.actions.any { it.kind == TaskActionKind.CANCEL_MODEL_DOWNLOAD }) - assertEquals("Installing speech model…", hint.presentation.action?.label) + assertEquals(R.string.onboarding_action_installing, hint.presentation.action?.labelRes) } scenario.onActivity { activity -> @@ -694,7 +694,7 @@ class MainActivityInstrumentedTest { private fun audioRows(activity: MainActivity): List = displayItems(activity).filterIsInstance() - private fun audioTitles(activity: MainActivity): List = audioRows(activity).map { it.task.title } + private fun audioTitles(activity: MainActivity): List = audioRows(activity).map { it.task.title.fallback } private fun descendants(root: android.view.ViewGroup): Sequence = sequence { for (index in 0 until root.childCount) { diff --git a/app/src/main/java/me/maxistar/voiceinbox/AndroidInlineOnboarding.kt b/app/src/main/java/me/maxistar/voiceinbox/AndroidInlineOnboarding.kt index b6deaec..45dc3d9 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/AndroidInlineOnboarding.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/AndroidInlineOnboarding.kt @@ -75,22 +75,22 @@ enum class AndroidOnboardingStepKind { data class AndroidOnboardingChecklistStep( val kind: AndroidOnboardingStepKind, - val label: String, + val labelRes: Int, val complete: Boolean, val optional: Boolean = false, ) data class AndroidOnboardingHintAction( - val label: String, + val labelRes: Int, val enabled: Boolean, val kind: TaskActionKind, ) data class AndroidOnboardingHintPresentation( val visible: Boolean = false, - val title: String = "Set up Voice Inbox", - val explanation: String = "Follow these steps, or use the setup tasks above in any order.", - val downloadDisclosure: String? = null, + val titleRes: Int = R.string.onboarding_title, + val explanationRes: Int = R.string.onboarding_explanation, + val downloadDisclosureRes: Int? = null, val steps: List = emptyList(), val action: AndroidOnboardingHintAction? = null, ) { @@ -122,24 +122,24 @@ object AndroidOnboardingHintPresenter { val steps = listOf( AndroidOnboardingChecklistStep( kind = AndroidOnboardingStepKind.MODEL, - label = "Install speech model", + labelRes = R.string.onboarding_step_model, complete = model.state == ModelSetupSnapshotState.READY, ), AndroidOnboardingChecklistStep( kind = AndroidOnboardingStepKind.OUTPUT, - label = "Configure automatic transcript export · Optional", + labelRes = R.string.onboarding_step_output, complete = output.state == OutputSetupSnapshotState.READY, optional = true, ), AndroidOnboardingChecklistStep( kind = AndroidOnboardingStepKind.FOLDER, - label = "Select audio folder · Optional", + labelRes = R.string.onboarding_step_folder, complete = folder.state == FolderSetupSnapshotState.READY, optional = true, ), AndroidOnboardingChecklistStep( kind = AndroidOnboardingStepKind.KEYBOARD, - label = "Enable voice keyboard · Optional", + labelRes = R.string.onboarding_step_keyboard, complete = keyboardStatus != AndroidVoiceKeyboardStatus.DISABLED, optional = true, ), @@ -148,7 +148,7 @@ object AndroidOnboardingHintPresenter { return AndroidOnboardingHintPresentation( visible = true, steps = steps, - downloadDisclosure = "Start setup downloads the speech model to this device." + downloadDisclosureRes = R.string.onboarding_download_disclosure .takeIf { action.enabled && action.kind in setOf( TaskActionKind.DOWNLOAD_MODEL, @@ -194,12 +194,12 @@ object AndroidOnboardingHintPresenter { keyboardStatus: AndroidVoiceKeyboardStatus, ): AndroidOnboardingHintAction = when { model.state == ModelSetupSnapshotState.INSTALLING -> AndroidOnboardingHintAction( - label = "Installing speech model…", + labelRes = R.string.onboarding_action_installing, enabled = false, kind = TaskActionKind.DOWNLOAD_MODEL, ) model.state != ModelSetupSnapshotState.READY && model.downloadAvailable -> AndroidOnboardingHintAction( - label = if (model.state == ModelSetupSnapshotState.INVALID) "Retry setup" else "Start setup", + labelRes = if (model.state == ModelSetupSnapshotState.INVALID) R.string.onboarding_action_retry else R.string.onboarding_action_start, enabled = true, kind = if (model.state == ModelSetupSnapshotState.INVALID) { TaskActionKind.RETRY_MODEL_DOWNLOAD @@ -208,27 +208,27 @@ object AndroidOnboardingHintPresenter { }, ) model.state != ModelSetupSnapshotState.READY -> AndroidOnboardingHintAction( - label = "Install model from folder", + labelRes = R.string.onboarding_action_import_model, enabled = true, kind = TaskActionKind.IMPORT_MODEL, ) output.state != OutputSetupSnapshotState.READY -> AndroidOnboardingHintAction( - label = "Choose export document", + labelRes = R.string.onboarding_action_select_output, enabled = true, kind = TaskActionKind.SELECT_OUTPUT, ) folder.state != FolderSetupSnapshotState.READY -> AndroidOnboardingHintAction( - label = "Select audio folder", + labelRes = R.string.onboarding_action_select_folder, enabled = true, kind = TaskActionKind.SELECT_FOLDER, ) keyboardStatus == AndroidVoiceKeyboardStatus.DISABLED -> AndroidOnboardingHintAction( - label = "Enable voice keyboard", + labelRes = R.string.onboarding_action_enable_keyboard, enabled = true, kind = TaskActionKind.ENABLE_VOICE_KEYBOARD, ) else -> AndroidOnboardingHintAction( - label = "Ready to transcribe", + labelRes = R.string.onboarding_action_ready, enabled = false, kind = TaskActionKind.IMPORT_AUDIO, ) diff --git a/app/src/main/java/me/maxistar/voiceinbox/AndroidTaskTextResolver.kt b/app/src/main/java/me/maxistar/voiceinbox/AndroidTaskTextResolver.kt new file mode 100644 index 0000000..e5344b7 --- /dev/null +++ b/app/src/main/java/me/maxistar/voiceinbox/AndroidTaskTextResolver.kt @@ -0,0 +1,56 @@ +package me.maxistar.voiceinbox + +import android.content.res.Resources +import me.maxistar.voiceinbox.core.TaskText +import me.maxistar.voiceinbox.core.TaskTextKey + +/** Resolves locale-neutral shared task text at the Android boundary. */ +internal object AndroidTaskTextResolver { + fun resolve(resources: Resources, value: TaskText): String = when (value.key) { + TaskTextKey.OPAQUE -> value.fallback + TaskTextKey.INSTALL_SPEECH_MODEL -> resources.getString(R.string.task_install_speech_model) + TaskTextKey.AUTOMATIC_TRANSCRIPT_EXPORT -> resources.getString(R.string.task_automatic_transcript_export) + TaskTextKey.OUTPUT_OPTIONAL_DETAIL -> resources.getString(R.string.task_output_optional_detail) + TaskTextKey.REFRESH_AUDIO_FOLDER -> resources.getString(R.string.task_refresh_audio_folder) + TaskTextKey.RESTORE_AUDIO_FOLDER_ACCESS -> resources.getString(R.string.task_restore_audio_folder_access) + TaskTextKey.REQUIRED -> resources.getString(R.string.task_required) + TaskTextKey.OPTIONAL -> resources.getString(R.string.task_optional) + TaskTextKey.INSTALLING -> resources.getString(R.string.task_installing) + TaskTextKey.SCANNING -> resources.getString(R.string.task_scanning) + TaskTextKey.NEEDS_ATTENTION -> resources.getString(R.string.task_needs_attention) + TaskTextKey.NEW -> resources.getString(R.string.new_label) + TaskTextKey.PROCESSING -> resources.getString(R.string.task_processing) + TaskTextKey.PROCESSED -> resources.getString(R.string.processed) + TaskTextKey.FAILED -> resources.getString(R.string.task_failed) + TaskTextKey.NO_SPEECH -> resources.getString(R.string.task_no_speech) + TaskTextKey.CANCEL -> resources.getString(R.string.voice_keyboard_cancel) + TaskTextKey.RETRY_DOWNLOAD -> resources.getString(R.string.task_retry_download) + TaskTextKey.DOWNLOAD -> resources.getString(R.string.task_download) + TaskTextKey.CREATE_NEW -> resources.getString(R.string.settings_output_create_new) + TaskTextKey.CHOOSE_EXISTING -> resources.getString(R.string.settings_output_choose_existing) + TaskTextKey.HIDE -> resources.getString(R.string.task_hide) + TaskTextKey.SELECT_FOLDER -> resources.getString(R.string.task_select_folder) + TaskTextKey.RETRY -> resources.getString(R.string.task_retry) + TaskTextKey.TRANSCRIBE -> resources.getString(R.string.task_transcribe) + TaskTextKey.SHOW_TEXT -> resources.getString(R.string.task_show_text) + TaskTextKey.STOP -> resources.getString(R.string.voice_keyboard_stop) + TaskTextKey.PLAY -> resources.getString(R.string.task_play) + TaskTextKey.IMPORT_AUDIO_FILES -> resources.getString(R.string.task_import_audio_files) + TaskTextKey.SELECT_AUDIO_FOLDER -> resources.getString(R.string.select_audio_folder) + TaskTextKey.NO_NEW_TASKS -> resources.getString(R.string.task_no_new_tasks) + TaskTextKey.NO_PROCESSED_AUDIO -> resources.getString(R.string.task_no_processed_audio) + TaskTextKey.NO_TASKS -> resources.getString(R.string.task_no_tasks) + TaskTextKey.INSTALLING_MODEL -> resources.getString(R.string.task_installing_model) + TaskTextKey.SCANNING_AUDIO_FOLDER -> resources.getString(R.string.task_scanning_audio_folder) + TaskTextKey.PREPARING_SPEECH_MODEL -> resources.getString(R.string.task_preparing_speech_model) + TaskTextKey.CHOOSE_MODEL -> resources.getString(R.string.task_choose_model) + TaskTextKey.SELECTED_MODEL_DETAIL -> resources.getString( + R.string.task_selected_model_detail, + value.arguments[0], + value.arguments[1], + value.arguments[2], + value.arguments[3], + value.arguments[4], + ) + } +} diff --git a/app/src/main/java/me/maxistar/voiceinbox/AndroidVoiceKeyboardDiscovery.kt b/app/src/main/java/me/maxistar/voiceinbox/AndroidVoiceKeyboardDiscovery.kt index cccf3bb..54cc1d0 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/AndroidVoiceKeyboardDiscovery.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/AndroidVoiceKeyboardDiscovery.kt @@ -147,11 +147,11 @@ class AndroidVoiceKeyboardDiscoveryStore( data class AndroidVoiceKeyboardDiscoveryPresentation( val visible: Boolean = false, - val title: String = "Dictate in any app", - val explanation: String = "Use Voice Inbox as a compact voice keyboard. Recognition runs locally after model setup.", - val setupLabel: String = "Set up keyboard", + val titleRes: Int = R.string.voice_keyboard_discovery_title, + val explanationRes: Int = R.string.voice_keyboard_discovery_explanation, + val setupLabelRes: Int = R.string.voice_keyboard_discovery_setup, val setupAction: TaskActionKind = TaskActionKind.ENABLE_VOICE_KEYBOARD, - val documentationLabel: String = "Learn more", + val documentationLabelRes: Int = R.string.voice_keyboard_discovery_documentation, ) { companion object { val HIDDEN = AndroidVoiceKeyboardDiscoveryPresentation() diff --git a/app/src/main/java/me/maxistar/voiceinbox/AudioImportCoordinator.kt b/app/src/main/java/me/maxistar/voiceinbox/AudioImportCoordinator.kt index ea8b828..74ad2ef 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/AudioImportCoordinator.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/AudioImportCoordinator.kt @@ -1,5 +1,6 @@ package me.maxistar.voiceinbox +import android.content.res.Resources import android.content.ContentResolver import android.content.Context import android.content.Intent @@ -61,6 +62,18 @@ data class AudioImportSummary( } if (outcomes.isEmpty()) append(" • no audio received") } + + fun localizedMessage(resources: Resources): String = buildString { + append(resources.getString(R.string.audio_import_summary, importedCount)) + if (duplicateCount > 0) append(" • ${resources.getString(R.string.audio_import_duplicates, duplicateCount)}") + if (rejectedCount == 1) { + val rejection = outcomes.filterIsInstance().single() + append(" • ${resources.getString(R.string.audio_import_rejected_reason, rejection.reason)}") + } else if (rejectedCount > 1) { + append(" • ${resources.getString(R.string.audio_import_rejected_count, rejectedCount)}") + } + if (outcomes.isEmpty()) append(" • ${resources.getString(R.string.audio_import_none_received)}") + } } interface AudioImportStorage { diff --git a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt index dbf75ed..87514af 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt @@ -86,7 +86,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen private var currentSessionTranscriptionWorkId: UUID? = null private var currentSessionObservedActiveTranscription = false private val transcriptionHandoff = AndroidTranscriptionHandoffCoordinator() - private var modelMessage = "Checking speech model" + private var modelMessage = "" private var modelDownloadAvailable = false private var modelDownloadProgress: Int? = null private var modelInstallCanCancel = false @@ -448,7 +448,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen PersistedSelectionAccessState.TEMPORARILY_UNAVAILABLE -> { outputAccessReady = false outputAccessError = result.error?.message - ?: "Output file is temporarily unavailable" + ?: getString(R.string.error_output_temporarily_unavailable) startupCoordinator.setOutputReady(false) outputDisplayName = null } @@ -458,7 +458,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen outputAccessReady = false outputDisplayName = null outputAccessError = result.error?.message - ?: "Output file access was revoked; select it again" + ?: getString(R.string.error_output_access_revoked) startupCoordinator.setOutputReady(false) } } @@ -530,9 +530,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen } } PersistedSelectionAccessState.TEMPORARILY_UNAVAILABLE -> { - folderAccessReady = false - folderAccessError = result.error?.message - ?: "Audio folder is temporarily unavailable" + folderAccessReady = false + folderAccessError = result.error?.message + ?: getString(R.string.error_folder_temporarily_unavailable) startupCoordinator.setFolderReady(false) folderDisplayName = null taskStateHost.folderSyncCoordinator.fail(folderSyncGeneration) @@ -542,8 +542,8 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen folderUri = null folderAccessReady = false folderDisplayName = null - folderAccessError = result.error?.message - ?: "Audio folder access was revoked; select it again" + folderAccessError = result.error?.message + ?: getString(R.string.error_folder_access_revoked) pendingFolderSyncGeneration = null startupCoordinator.setFolderReady(false) taskStateHost.folderSyncCoordinator.fail(folderSyncGeneration) @@ -614,7 +614,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen activityDestroyed || validationGeneration != outputValidationGeneration ) return@runOnUiThread - outputAccessError = it.message ?: "Output file is not writable" + outputAccessError = it.message ?: getString(R.string.error_output_not_writable) outputPresentationKnown = true publishTaskState() updateControls() @@ -677,7 +677,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen ) return@runOnUiThread folderChecking = false folderPresentationKnown = true - folderAccessError = it.message ?: "Audio folder is not readable" + folderAccessError = it.message ?: getString(R.string.error_audio_folder_not_readable) taskStateHost.folderSyncCoordinator.fail(folderSyncGeneration) publishTaskState() updateControls() @@ -702,7 +702,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen modelReady = false modelSetupState = ModelSetupSnapshotState.INVALID setModelUi( - "The selected model folder cannot be accessed after the picker closes", + getString(R.string.error_model_folder_access), canDownload = true, ) updateControls() @@ -727,7 +727,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen modelReady = false modelSetupState = ModelSetupSnapshotState.INVALID setModelUi( - error.message ?: "The selected model folder is not readable", + error.message ?: getString(R.string.error_model_folder_not_readable), canDownload = true, ) updateControls() @@ -748,14 +748,14 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen val size = SpeechModelRepository.formatBytes(descriptor.approximateDownloadBytes) val maturity = descriptor.maturity.name.lowercase().replaceFirstChar(Char::uppercase) val dialog = MaterialAlertDialogBuilder(this) - .setTitle("Install ${descriptor.displayName}?") - .setMessage("$maturity model · ${descriptor.languages.summary} · approximately $size") + .setTitle(getString(R.string.confirm_install_model, descriptor.displayName)) + .setMessage(getString(R.string.confirm_install_model_detail, maturity, descriptor.languages.summary, size)) .setNegativeButton(android.R.string.cancel) { _, _ -> pendingModelPackageUri = null pendingModelPackageCatalogId = null SpeechModelImportPermission.releaseOwnedIfUnused(this) } - .setPositiveButton("Install", null) + .setPositiveButton(R.string.model_install, null) .create() dialog.setOnShowListener { val confirm = dialog.getButton(AlertDialog.BUTTON_POSITIVE) @@ -767,7 +767,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen modelPresentationKnown = true modelReady = false modelSetupState = ModelSetupSnapshotState.INSTALLING - setModelUi("Installing ${descriptor.displayName}", canDownload = false) + setModelUi(getString(R.string.installing_model_named, descriptor.displayName), canDownload = false) updateControls() SpeechModelImportWorker.enqueue(this, uri, descriptor) dialog.dismiss() @@ -819,7 +819,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen if (activityDestroyed) return@runOnUiThread folderScanQueued = false scanning = true - scanMessage = "Scanning folder" + scanMessage = getString(R.string.folder_scanning) taskStateHost.folderSyncCoordinator.transition( folderSyncGeneration, AndroidFolderSyncPhase.SCANNING, @@ -837,7 +837,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen folderScanQueued = false scanning = false folderAccessError = null - scanMessage = "Scan complete: $count audio files" + scanMessage = getString(R.string.folder_scan_complete, count) taskStateHost.folderSyncCoordinator.transition( folderSyncGeneration, AndroidFolderSyncPhase.REFRESHING_CATALOG, @@ -849,7 +849,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen if (activityDestroyed) return@runOnUiThread folderScanQueued = false scanning = false - folderAccessError = error.message ?: "Folder scan failed" + folderAccessError = error.message ?: getString(R.string.error_folder_scan_failed) folderPresentationKnown = true taskStateHost.folderSyncCoordinator.fail(folderSyncGeneration) startupGeneration?.let(startupCoordinator::onStartupScanFailed) @@ -924,7 +924,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen if (folderSyncGeneration != null && taskStateHost.folderSyncCoordinator.fail(folderSyncGeneration) ) { - folderAccessError = error.message ?: "Audio catalog refresh failed" + folderAccessError = error.message ?: getString(R.string.error_audio_catalog_refresh_failed) folderPresentationKnown = true startupGeneration?.let(startupCoordinator::onStartupScanFailed) publishTaskState() @@ -975,25 +975,25 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen SpeechModelReadinessState.Checking -> { modelReady = false modelPresentationKnown = false - setModelUi("Checking speech model", canDownload = false) + setModelUi(getString(R.string.model_checking), canDownload = false) } is SpeechModelReadinessState.Ready -> { modelPresentationKnown = true modelReady = true modelSetupState = ModelSetupSnapshotState.READY - setModelUi("Speech model ready", canDownload = false) + setModelUi(getString(R.string.model_ready), canDownload = false) } SpeechModelReadinessState.Missing -> { modelPresentationKnown = true modelReady = false modelSetupState = ModelSetupSnapshotState.REQUIRED - setModelUi("Speech model is not installed", canDownload = true) + setModelUi(getString(R.string.model_missing), canDownload = true) } is SpeechModelReadinessState.Invalid -> { modelPresentationKnown = true modelReady = false modelSetupState = ModelSetupSnapshotState.INVALID - setModelUi("Invalid speech model: ${state.reason}", canDownload = true) + setModelUi(getString(R.string.model_invalid, state.reason), canDownload = true) } is SpeechModelReadinessState.Failed -> { modelPresentationKnown = true @@ -1039,7 +1039,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen ) modelMessage = info.progress.getString(SpeechModelInstallationWork.KEY_MESSAGE) - ?: "Installing speech model" + ?: getString(R.string.model_installing) modelDownloadAvailable = false modelInstallCanCancel = info.tags.any { tag -> tag.endsWith(SpeechModelDownloadWorker::class.java.simpleName) @@ -1065,7 +1065,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen startupCoordinator.setModelReady(false) setModelUi( info.outputData.getString(SpeechModelInstallationWork.KEY_ERROR) - ?: "Speech model installation failed", + ?: getString(R.string.model_installation_failed), canDownload = true, ) updateControls() @@ -1142,9 +1142,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen observedActive = workActive, activeEntryId = transcriptionEntryId, ) ?: when (info.state) { - WorkInfo.State.SUCCEEDED -> "Completed" + WorkInfo.State.SUCCEEDED -> getString(R.string.transcription_completed) WorkInfo.State.FAILED -> - data.getString(TranscriptionWorker.KEY_ERROR) ?: "Failed" + data.getString(TranscriptionWorker.KEY_ERROR) ?: getString(R.string.task_failed) else -> info.state.name } transcriptionIndeterminate = @@ -1333,14 +1333,14 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen } player.setOnErrorListener { failed, _, _ -> if (previewPlayer == failed) { - showError("Cannot play ${entry.displayName}") + showError(getString(R.string.error_cannot_play, entry.displayName)) stopPreviewPlayback(render = true) } true } player.prepareAsync() }.onFailure { - showError("Cannot play ${entry.displayName}") + showError(getString(R.string.error_cannot_play, entry.displayName)) stopPreviewPlayback(render = true) } } @@ -1610,7 +1610,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen val choices = SpeechModelCatalog.modelsFor(SpeechModelPlatform.ANDROID) .filter { it.distribution.networkDownloadAvailable } MaterialAlertDialogBuilder(this) - .setTitle("Download speech model") + .setTitle(R.string.download_speech_model) .setSingleChoiceItems( choices.map { it.displayName }.toTypedArray(), choices.indexOfFirst { it.catalogId == selectedDownloadModel.catalogId }, @@ -1765,8 +1765,8 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen AudioImportSummary( listOf( AudioImportItemOutcome.Rejected( - displayName = "shared audio", - reason = error.message ?: "Audio import failed", + displayName = getString(R.string.audio_import_default_name), + reason = error.message ?: getString(R.string.audio_import_failed), ), ), ) @@ -1777,7 +1777,7 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen runOnUiThread { if (activityDestroyed) return@runOnUiThread ingestionActive = false - Toast.makeText(this, summary.message(), Toast.LENGTH_LONG).show() + Toast.makeText(this, summary.localizedMessage(resources), Toast.LENGTH_LONG).show() refreshCatalog() if (queuedImportUris.isNotEmpty()) { val queued = queuedImportUris.toList() diff --git a/app/src/main/java/me/maxistar/voiceinbox/SettingsActivity.kt b/app/src/main/java/me/maxistar/voiceinbox/SettingsActivity.kt index 26089d8..0af7961 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/SettingsActivity.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/SettingsActivity.kt @@ -207,7 +207,7 @@ class SettingsActivity : AppCompatActivity() { if (activityDestroyed) return@runOnUiThread Toast.makeText( this, - error.message ?: "Output file is not writable", + error.message ?: getString(R.string.error_output_not_writable), Toast.LENGTH_LONG, ).show() renderStorage() @@ -259,7 +259,7 @@ class SettingsActivity : AppCompatActivity() { if (activityDestroyed) return@runOnUiThread Toast.makeText( this, - error.message ?: "Audio folder is not readable", + error.message ?: getString(R.string.error_audio_folder_not_readable), Toast.LENGTH_LONG, ).show() renderStorage() @@ -296,8 +296,8 @@ class SettingsActivity : AppCompatActivity() { modelDetail.text = when (val state = SpeechModelRepository.forActive( noBackupFilesDir.resolve("models"), ).inspectLightweight()) { - is InstalledSpeechModelState.Ready -> "Active: ${state.descriptor.displayName}. Select a supported local model package to replace it." - else -> "Select a supported local model package." + is InstalledSpeechModelState.Ready -> getString(R.string.settings_model_active, state.descriptor.displayName) + else -> getString(R.string.settings_model_select) } } @@ -310,7 +310,7 @@ class SettingsActivity : AppCompatActivity() { outputDetail.text = runCatching { getString(R.string.output_selected, documentAccess.metadata(stored).displayName) }.getOrElse { - getString(R.string.output_selected, stored.lastPathSegment ?: "document") + getString(R.string.output_selected, stored.lastPathSegment ?: getString(R.string.output_default_name)) } } @@ -323,7 +323,7 @@ class SettingsActivity : AppCompatActivity() { folderDetail.text = runCatching { getString(R.string.folder_selected, folderScanner.folderName(stored)) }.getOrElse { - getString(R.string.folder_selected, stored.lastPathSegment ?: "audio folder") + getString(R.string.folder_selected, stored.lastPathSegment ?: getString(R.string.audio_folder_default_name)) } } @@ -365,7 +365,7 @@ class SettingsActivity : AppCompatActivity() { private fun appVersionName(): String = runCatching { - packageManager.getPackageInfo(packageName, 0).versionName ?: "Unknown" - }.getOrDefault("Unknown") + packageManager.getPackageInfo(packageName, 0).versionName ?: getString(R.string.unknown_value) + }.getOrDefault(getString(R.string.unknown_value)) } diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt index cccb464..7a6dfb3 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt @@ -29,7 +29,7 @@ class SpeechModelDownloadWorker( modelVersion = inputData.getString(KEY_MODEL_VERSION).orEmpty(), ), SpeechModelPlatform.ANDROID, - ) ?: throw IllegalArgumentException("Selected speech model is unavailable for download") + ) ?: throw IllegalArgumentException(applicationContext.getString(R.string.error_model_unavailable_download)) } private val repository = SpeechModelRepository( root = applicationContext.noBackupFilesDir.resolve("models"), @@ -45,7 +45,7 @@ class SpeechModelDownloadWorker( return try { installModel() } catch (error: IllegalArgumentException) { - failure(error.message ?: "Selected speech model is unavailable for download") + failure(error.message ?: applicationContext.getString(R.string.error_model_unavailable_download)) } catch (error: ForegroundPromotionException) { failure(error.userMessage) } @@ -56,17 +56,17 @@ class SpeechModelDownloadWorker( worker = this, context = applicationContext, progress = 0, - message = "Preparing model download", + message = applicationContext.getString(R.string.model_download_preparing), source = SpeechModelInstallationWork.Source.NETWORK_DOWNLOAD, ) repository.prepareForInstall().getOrElse { - return failure(it.message ?: "Model installation preflight failed") + return failure(it.message ?: applicationContext.getString(R.string.error_model_preflight)) } var completedBytes = repository.manifest.files .filter(repository::isValidStagingFile) .sumOf { it.sizeBytes } - publishProgress(completedBytes, "Downloading speech model") + publishProgress(completedBytes, applicationContext.getString(R.string.model_downloading)) for (entry in repository.manifest.files) { currentCoroutineContext().ensureActive() @@ -84,7 +84,7 @@ class SpeechModelDownloadWorker( "Failed to accept ${entry.name}" } completedBytes += entry.sizeBytes - publishProgress(completedBytes, "Verified ${entry.name}") + publishProgress(completedBytes, applicationContext.getString(R.string.model_file_verified, entry.name)) lastFailure = null break } catch (error: ForegroundPromotionException) { @@ -101,14 +101,14 @@ class SpeechModelDownloadWorker( } if (lastFailure != null) { return failure( - "Failed to download ${entry.name}: ${lastFailure.message ?: "unknown error"}", + applicationContext.getString(R.string.error_model_download, entry.name, lastFailure.message ?: applicationContext.getString(R.string.unknown_error)), ) } } - publishProgress(repository.manifest.totalSizeBytes, "Activating speech model") + publishProgress(repository.manifest.totalSizeBytes, applicationContext.getString(R.string.model_activating)) val installedDirectory = repository.activate().getOrElse { - return failure(it.message ?: "Failed to activate speech model") + return failure(it.message ?: applicationContext.getString(R.string.error_model_activate)) } SpeechModelWarmup.invalidate() return Result.success(workDataOf(KEY_MODEL_PATH to installedDirectory.absolutePath)) @@ -139,7 +139,7 @@ class SpeechModelDownloadWorker( if (fileBytes - lastReported >= PROGRESS_STEP_BYTES) { publishProgress( completedBytes + fileBytes, - "Downloading ${entry.name}", + applicationContext.getString(R.string.model_file_downloading, entry.name), ) lastReported = fileBytes } @@ -187,7 +187,7 @@ class SpeechModelDownloadWorker( private const val PROGRESS_STEP_BYTES = 2L * 1024L * 1024L fun enqueue(context: Context, descriptor: SpeechModelDescriptor = SpeechModelCatalog.defaultModel) { require(descriptor.distribution.networkDownloadAvailable) { - "Selected speech model is unavailable for download" + context.getString(R.string.error_model_unavailable_download) } val request = OneTimeWorkRequestBuilder() .setInputData( diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt index ca6adb4..5c3f2e1 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt @@ -16,14 +16,14 @@ class SpeechModelImportWorker( ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { val treeUri = inputData.getString(KEY_TREE_URI)?.let(Uri::parse) - ?: return failure("No model folder was selected") + ?: return failure(applicationContext.getString(R.string.error_model_folder_missing)) val catalogId = inputData.getString(KEY_CATALOG_ID) - ?: return failure("No speech model was selected") + ?: return failure(applicationContext.getString(R.string.error_model_missing)) val modelVersion = inputData.getString(KEY_MODEL_VERSION) - ?: return failure("No speech model version was selected") + ?: return failure(applicationContext.getString(R.string.error_model_version_missing)) val descriptor = SpeechModelCatalog.resolveInstallation(catalogId, modelVersion) ?.takeIf { it.distribution.localImportAvailable } - ?: return failure("The selected speech model is no longer supported") + ?: return failure(applicationContext.getString(R.string.error_model_unsupported)) val repository = SpeechModelRepository( root = applicationContext.noBackupFilesDir.resolve("models"), descriptor = descriptor, @@ -33,14 +33,14 @@ class SpeechModelImportWorker( worker = this, context = applicationContext, progress = 0, - message = "Preparing local speech model", + message = applicationContext.getString(R.string.model_import_preparing), source = SpeechModelInstallationWork.Source.LOCAL_IMPORT, ) val installed = SpeechModelLocalImporter( resolver = applicationContext.contentResolver, repository = repository, ).import(treeUri.toString()) { progress -> publishProgress(progress, repository) }.getOrElse { - return failure(it.message ?: "Could not import speech model") + return failure(it.message ?: applicationContext.getString(R.string.error_model_import)) } SpeechModelWarmup.invalidate() Result.success( @@ -63,18 +63,32 @@ class SpeechModelImportWorker( workDataOf( SpeechModelInstallationWork.KEY_BYTES_DOWNLOADED to progress.bytesCopied, SpeechModelInstallationWork.KEY_TOTAL_BYTES to total, - SpeechModelInstallationWork.KEY_MESSAGE to progress.message, + SpeechModelInstallationWork.KEY_MESSAGE to localizedProgressMessage(progress.message), ), ) SpeechModelInstallationWork.promote( worker = this, context = applicationContext, progress = percent, - message = progress.message, + message = localizedProgressMessage(progress.message), source = SpeechModelInstallationWork.Source.LOCAL_IMPORT, ) } + private fun localizedProgressMessage(message: String): String = when { + message == "Preparing local speech model" -> applicationContext.getString(R.string.model_import_preparing) + message == "Activating speech model" -> applicationContext.getString(R.string.model_activating) + message.startsWith("Copying ") -> applicationContext.getString( + R.string.model_file_downloading, + message.removePrefix("Copying "), + ) + message.startsWith("Verified ") -> applicationContext.getString( + R.string.model_file_verified, + message.removePrefix("Verified "), + ) + else -> message + } + private fun failure(message: String): Result = Result.failure( SpeechModelInstallationWork.failureData(message), ) diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt index f926901..74dc61f 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt @@ -41,14 +41,14 @@ object SpeechModelInstallationWork { manager.createNotificationChannel( NotificationChannel( NOTIFICATION_CHANNEL, - "Speech model installation", + context.getString(R.string.notification_model_installation), NotificationManager.IMPORTANCE_LOW, ), ) } val notification = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL) .setSmallIcon(android.R.drawable.stat_sys_download) - .setContentTitle("Voice Inbox") + .setContentTitle(context.getString(R.string.app_name)) .setContentText(message) .setOnlyAlertOnce(true) .setOngoing(true) diff --git a/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt b/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt index d4f734a..d08b2ef 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/TaskListAdapter.kt @@ -19,6 +19,7 @@ import me.maxistar.voiceinbox.core.TaskActionPresentation import me.maxistar.voiceinbox.core.TaskListState import me.maxistar.voiceinbox.core.TaskPresentation import me.maxistar.voiceinbox.core.TaskProgressPresentation +import me.maxistar.voiceinbox.core.TaskText sealed class TaskListDisplayItem { abstract val stableKey: String @@ -67,7 +68,7 @@ sealed class TaskListDisplayItem { } data class Empty( - val message: String, + val message: TaskText, val actions: List, ) : TaskListDisplayItem() { override val stableKey: String = STABLE_KEY @@ -248,12 +249,12 @@ class TaskListAdapter( private var boundActions: List? = null protected fun bindTask(task: TaskPresentation, entryId: Long?) { - itemView.contentDescription = task.title - title.text = task.title - badge.text = task.badge - bindOptional(detail, task.detail) + itemView.contentDescription = AndroidTaskTextResolver.resolve(itemView.resources, task.title) + title.text = AndroidTaskTextResolver.resolve(itemView.resources, task.title) + badge.text = AndroidTaskTextResolver.resolve(itemView.resources, task.badge) + bindOptional(detail, task.detail?.let { AndroidTaskTextResolver.resolve(itemView.resources, it) }) bindProgress(task.progress) - bindOptional(error, task.errorMessage) + bindOptional(error, task.errorMessage?.let { AndroidTaskTextResolver.resolve(itemView.resources, it) }) val dismissAction = task.actions.firstOrNull { it.kind == TaskActionKind.HIDE_OUTPUT } dismiss.isVisible = dismissAction != null dismiss.setOnClickListener { @@ -271,7 +272,7 @@ class TaskListAdapter( progressMeta.isVisible = false return } - progressPhase.text = value.phase + progressPhase.text = AndroidTaskTextResolver.resolve(itemView.resources, value.phase) progress.isIndeterminate = value.percent == null progress.progress = value.percent ?: 0 bindOptional(progressMeta, progressMetadata(value)) @@ -290,7 +291,7 @@ class TaskListAdapter( visibleActions.forEachIndexed { index, action -> actions.addView( MaterialButton(actions.context).apply { - text = action.label + text = AndroidTaskTextResolver.resolve(actions.resources, action.text) isEnabled = action.enabled layoutParams = LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, @@ -354,15 +355,15 @@ class TaskListAdapter( fun bind(item: TaskListDisplayItem.OnboardingHint) { val presentation = item.presentation - title.text = presentation.title - explanation.text = presentation.explanation - disclosure.text = presentation.downloadDisclosure.orEmpty() - disclosure.isVisible = presentation.downloadDisclosure != null + title.setText(presentation.titleRes) + explanation.setText(presentation.explanationRes) + presentation.downloadDisclosureRes?.let(disclosure::setText) + disclosure.isVisible = presentation.downloadDisclosureRes != null bindStep(modelStep, presentation.steps.firstOrNull { it.kind == AndroidOnboardingStepKind.MODEL }) bindStep(outputStep, presentation.steps.firstOrNull { it.kind == AndroidOnboardingStepKind.OUTPUT }) bindStep(folderStep, presentation.steps.firstOrNull { it.kind == AndroidOnboardingStepKind.FOLDER }) bindStep(keyboardStep, presentation.steps.firstOrNull { it.kind == AndroidOnboardingStepKind.KEYBOARD }) - action.text = presentation.action?.label.orEmpty() + presentation.action?.labelRes?.let(action::setText) action.isEnabled = presentation.action?.enabled == true action.isVisible = presentation.action != null action.setOnClickListener { @@ -377,11 +378,11 @@ class TaskListAdapter( step ?: return view.text = itemView.resources.getString( if (step.complete) R.string.onboarding_step_complete else R.string.onboarding_step_incomplete, - step.label, + itemView.resources.getString(step.labelRes), ) view.contentDescription = itemView.resources.getString( if (step.complete) R.string.onboarding_step_complete_accessibility else R.string.onboarding_step_incomplete_accessibility, - step.label, + itemView.resources.getString(step.labelRes), ) } } @@ -402,10 +403,10 @@ class TaskListAdapter( fun bind(item: TaskListDisplayItem.KeyboardDiscovery) { val presentation = item.presentation - title.text = presentation.title - explanation.text = presentation.explanation - setup.text = presentation.setupLabel - documentation.text = presentation.documentationLabel + title.setText(presentation.titleRes) + explanation.setText(presentation.explanationRes) + setup.setText(presentation.setupLabelRes) + documentation.setText(presentation.documentationLabelRes) setup.setOnClickListener { onAction(AndroidTaskActionRequest(item.stableKey, null, presentation.setupAction)) } @@ -429,12 +430,12 @@ class TaskListAdapter( private val actions: LinearLayout = itemView.findViewById(R.id.emptyActions) fun bind(item: TaskListDisplayItem.Empty) { - message.text = item.message + message.text = AndroidTaskTextResolver.resolve(itemView.resources, item.message) actions.removeAllViews() item.actions.forEachIndexed { index, action -> actions.addView( MaterialButton(actions.context).apply { - text = action.label + text = AndroidTaskTextResolver.resolve(actions.resources, action.text) isEnabled = action.enabled layoutParams = LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, diff --git a/app/src/main/java/me/maxistar/voiceinbox/TranscriptionWorker.kt b/app/src/main/java/me/maxistar/voiceinbox/TranscriptionWorker.kt index 95836fe..1feb526 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/TranscriptionWorker.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/TranscriptionWorker.kt @@ -34,15 +34,15 @@ class TranscriptionWorker( try { SpeechModelInstallationWork.promote( worker = this@TranscriptionWorker, - foregroundInfo = foreground("Preparing transcription", 0, true), + foregroundInfo = foreground(applicationContext.getString(R.string.transcription_preparing), 0, true), source = SpeechModelInstallationWork.Source.TRANSCRIPTION, ) val modelRepository = SpeechModelRepository.forActive( applicationContext.noBackupFilesDir.resolve("models"), ) - publish("Preparing speech model", null, null, 0, 0, null, null) + publish(applicationContext.getString(R.string.transcription_preparing_model), null, null, 0, 0, null, null) SpeechModelWarmup.prepare(modelRepository, retryFailed = true).get() - .getOrElse { return@withContext failure(it.message ?: "Speech model preparation failed") } + .getOrElse { return@withContext failure(it.message ?: applicationContext.getString(R.string.error_speech_model_preparation_failed)) } val batch = BatchTranscriptionUseCase( catalog = catalog, @@ -78,7 +78,7 @@ class TranscriptionWorker( } catch (cancelled: CancellationException) { throw cancelled } catch (error: Throwable) { - failure(error.message ?: "Transcription failed") + failure(error.message ?: applicationContext.getString(R.string.error_transcription_failed)) } } @@ -117,7 +117,7 @@ class TranscriptionWorker( private fun publishAsync(progress: BatchTranscriptionProgress) { publishAsync( - phase = progress.phase, + phase = localizedPhase(progress), activeEntryId = progress.activeEntryId, filename = progress.filename, completed = progress.completed, @@ -129,6 +129,22 @@ class TranscriptionWorker( ) } + private fun localizedPhase(progress: BatchTranscriptionProgress): String = when { + progress.filename == null && progress.failed > 0 -> applicationContext.getString( + R.string.transcription_summary_failed, + progress.completed, + progress.total, + progress.failed, + ) + progress.filename == null -> applicationContext.getString( + R.string.transcription_summary, + progress.completed, + progress.total, + ) + progress.phase == "Transcribing" -> applicationContext.getString(R.string.transcription_progress) + else -> progress.phase + } + private fun publishAsync( phase: String, activeEntryId: Long?, @@ -192,13 +208,13 @@ class TranscriptionWorker( manager.createNotificationChannel( NotificationChannel( NOTIFICATION_CHANNEL, - "Audio transcription", + applicationContext.getString(R.string.notification_audio_transcription), NotificationManager.IMPORTANCE_LOW, ), ) val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL) .setSmallIcon(android.R.drawable.ic_btn_speak_now) - .setContentTitle("Voice Inbox") + .setContentTitle(applicationContext.getString(R.string.app_name)) .setContentText(message) .setOnlyAlertOnce(true) .setOngoing(true) @@ -219,7 +235,7 @@ class TranscriptionWorker( } private fun failure(message: String): Result = - Result.failure(workDataOf(KEY_ERROR to message, KEY_PHASE to "Failed")) + Result.failure(workDataOf(KEY_ERROR to message, KEY_PHASE to applicationContext.getString(R.string.task_failed))) private class AndroidBatchEntryTranscriber( private val transcriber: SingleFileTranscriber, diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..1775391 --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,230 @@ + + Voice Inbox + Клавиатура Voice Inbox + Офлайн-диктовка + Нажмите для записи или удерживайте во время речи + Подготовка речевой модели… + Слушаю… Отпустите, чтобы расшифровать. + Слушаю… Нажмите «Стоп», когда закончите. + Слушаю… Отпустите, чтобы расшифровать. Осталось: %1$s. + Слушаю… Нажмите «Стоп», когда закончите. Осталось: %1$s. + Расшифровка на устройстве… + Текст готов. Выберите поле для вставки или закройте результат. + Речь не обнаружена. Попробуйте ещё раз. + Требуется доступ к микрофону. Откройте Voice Inbox и предоставьте его. + Микрофон недоступен. Попробуйте ещё раз. + Требуется готовая речевая модель. Откройте Voice Inbox, чтобы установить или восстановить её. + Не удалось расшифровать запись. Попробуйте ещё раз. + Запись + Стоп + Отмена + Закрыть + Открыть настройку Voice Inbox + Удалить + Пробел + Ввод + Далее + Вернуться к предыдущей клавиатуре + Доступ к микрофону предоставлен. Вернитесь к клавиатуре Voice Inbox. + Доступ к микрофону не предоставлен. + Скрыть предложение клавиатуры + Настройки клавиатуры Android недоступны на этом устройстве. Откройте руководство по клавиатуре, чтобы настроить её вручную. + Выбрать файл вывода + Изменить файл вывода + Выбрать папку с аудио + Изменить папку с аудио + Обновить + Папка с аудио не обновляется + Обновление папки с аудио + Настройки + Документация + Настройки + Хранилище + Папка с аудио + Автоматический экспорт расшифровок + Настроить автоматический экспорт расшифровок + Создать новый + Выбрать существующий + Не экспортировать + О приложении + Версия: %1$s + Сайт + Правовая информация + Нет приложения для открытия этой ссылки. + Активна: %1$s. Выберите поддерживаемый локальный пакет модели, чтобы заменить её. + Выберите поддерживаемый локальный пакет модели. + В файл вывода нельзя записать + Папка с аудио недоступна для чтения + Проверка речевой модели + Речевая модель готова + Речевая модель не установлена + Недопустимая речевая модель: %1$s + Установка речевой модели + Не удалось установить речевую модель + Установка речевой модели + Расшифровка аудио + Подготовка загрузки модели + Загрузка речевой модели + Активация речевой модели + Подготовка локальной речевой модели + Подготовка расшифровки + Подготовка речевой модели + Не удалось расшифровать аудио + Не удалось подготовить речевую модель + Загрузка %1$s + Проверен файл %1$s + Выбранная речевая модель недоступна для загрузки + Не удалось подготовить установку модели + Не удалось загрузить %1$s: %2$s + Не удалось активировать речевую модель + Папка модели не выбрана + Речевая модель не выбрана + Версия речевой модели не выбрана + Выбранная речевая модель больше не поддерживается + Не удалось импортировать речевую модель + неизвестная ошибка + Голосовая клавиатура + Компактная Android-клавиатура для диктовки в любом редактируемом поле. Распознавание работает локально после настройки модели; это не полноценная клавиатура для набора текста. + Не включена + Включена · Выберите её в переключателе клавиатур Android + Готова · Выбрана сейчас + Включить + Выбрать клавиатуру + Изменить клавиатуру + Руководство по клавиатуре + Ночная расшифровка + Автоматически искать и расшифровывать новые заметки около выбранного времени. + Время запуска: %1$02d:%2$02d + Изменить время запуска + Android может отложить фоновую работу для экономии заряда. По умолчанию функция выключена. + Обработка при запуске + Выберите, что делать, когда Voice Inbox находит ожидающие заметки при открытии. + Спрашивать каждый раз + Расшифровывать автоматически + Оставлять файлы в очереди + Обработка при запуске работает только при открытии приложения. Ночная расшифровка выполняется отдельно в фоне. + Обработать ожидающие заметки? + + %1$d файл готов к расшифровке. + %1$d файла готовы к расшифровке. + %1$d файлов готовы к расшифровке. + %1$d файла готовы к расшифровке. + + Всегда делать так при запуске Voice Inbox + Обработать + Оставить в очереди + Вывод: не выбран + Вывод: %1$s + Вывод: восстановление доступа… + Вывод: временно недоступен + Выбрать файл вывода + Папка с аудио: не выбрана + Папка с аудио: %1$s + Папка с аудио: восстановление доступа… + Папка с аудио: временно недоступна + Выбрать папку с аудио + Импортировать аудио + Импорт аудио… + Не удалось прочитать переданное аудио. + Поделиться + Закрыть + Текст расшифровки с возможностью выделения + Проверка речевой модели + Скачать речевую модель + Установить из папки + Новые + Готовые + Все + Нет новых аудиофайлов + Расшифровать все + Установить речевую модель + Автоматический экспорт расшифровок + Необязательно. Расшифровки хранятся в Voice Inbox. + %1$s · %2$s · %3$s · загрузка %4$s · свободно %5$s + Выбрать модель + Обновить папку с аудио + Восстановить доступ к папке с аудио + Обязательно + Необязательно + Установка + Сканирование + Требуется внимание + Обработка + Ошибка + Речь не обнаружена + Повторить загрузку + Скачать + Скрыть + Выбрать папку + Повторить + Расшифровать + Показать текст + Воспроизвести + Импортировать аудиофайлы + Нет новых задач + Нет обработанных аудиофайлов + Нет задач + Установка модели + Сканирование папки с аудио + Подготовка речевой модели + Скрыть руководство по настройке + Настройте Voice Inbox + Выполните эти шаги или задачи настройки выше в любом порядке. + Настройка загрузит речевую модель на это устройство. + Установить речевую модель + Экспорт · необязательно + Папка с аудио · необязательно + Клавиатура · необязательно + Установка речевой модели… + Повторить настройку + Начать настройку + Установить модель из папки + Выбрать файл экспорта + Выбрать папку с аудио + Включить голосовую клавиатуру + Готово к расшифровке + Диктуйте в любом приложении + Используйте Voice Inbox как компактную голосовую клавиатуру. Распознавание работает локально после настройки модели. + Настроить клавиатуру + Подробнее + Скрыть автоматический экспорт расшифровок + Файл экспорта временно недоступен + Доступ к файлу экспорта отозван; выберите его снова + Папка с аудио временно недоступна + Доступ к папке с аудио отозван; выберите ее снова + После закрытия выбора папки доступ к модели недоступен + Не удалось прочитать выбранную папку с моделью + Установить %1$s? + Модель %1$s · %2$s · примерно %3$s + Установка %1$s + Сканирование папки + Сканирование завершено: аудиофайлов %1$d + Не удалось просканировать папку + Не удалось обновить каталог аудио + Готово + Расшифровка + Готово: %1$d из %2$d + Готово: %1$d из %2$d, ошибок: %3$d + Не удалось воспроизвести %1$s + переданное аудио + Не удалось импортировать аудио + Импортировано: %1$d + Уже добавлено: %1$d + Отклонено: %1$s + Отклонено: %1$d + Аудио не получено + Установить + Неизвестно + документ + папка с аудио + ✓ %1$s + ○ %1$s + Выполнено: %1$s + Не выполнено: %1$s + + Расшифровать все (%1$d файл) + Расшифровать все (%1$d файла) + Расшифровать все (%1$d файлов) + Расшифровать все (%1$d файла) + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 488ad68..ab23797 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -51,6 +51,38 @@ Website Legal information No app can open this link. + Active: %1$s. Select a supported local model package to replace it. + Select a supported local model package. + Output file is not writable + Audio folder is not readable + Checking speech model + Speech model ready + Speech model is not installed + Invalid speech model: %1$s + Installing speech model + Speech model installation failed + Speech model installation + Audio transcription + Preparing model download + Downloading speech model + Activating speech model + Preparing local speech model + Preparing transcription + Preparing speech model + Transcription failed + Speech model preparation failed + Downloading %1$s + Verified %1$s + Selected speech model is unavailable for download + Model installation preflight failed + Failed to download %1$s: %2$s + Failed to activate speech model + No model folder was selected + No speech model was selected + No speech model version was selected + The selected speech model is no longer supported + Could not import speech model + unknown error Voice keyboard Compact Android dictation for any editable field. Recognition runs locally after model setup; this is not a full typing keyboard. Not enabled @@ -103,8 +135,86 @@ All No new audio files Transcribe all + Install Speech Model + Automatic Transcript Export + Optional. Transcripts are stored in Voice Inbox. + %1$s · %2$s · %3$s · %4$s download · %5$s free + Choose Model + Refresh Audio Folder + Restore Audio Folder Access + Required + Optional + Installing + Scanning + Needs attention + Processing + Failed + No speech + Retry Download + Download + Hide + Select Folder + Retry + Transcribe + Show Text + Play + Import Audio Files + No new tasks + No processed audio files + No tasks + Installing model + Scanning audio folder + Preparing speech model Dismiss setup guide + Set up Voice Inbox + Follow these steps, or use the setup tasks above in any order. + Start setup downloads the speech model to this device. + Install speech model + Configure automatic transcript export · Optional + Select audio folder · Optional + Enable voice keyboard · Optional + Installing speech model… + Retry setup + Start setup + Install model from folder + Choose export document + Select audio folder + Enable voice keyboard + Ready to transcribe + Dictate in any app + Use Voice Inbox as a compact voice keyboard. Recognition runs locally after model setup. + Set up keyboard + Learn more Hide automatic transcript export + Output file is temporarily unavailable + Output file access was revoked; select it again + Audio folder is temporarily unavailable + Audio folder access was revoked; select it again + The selected model folder cannot be accessed after the picker closes + The selected model folder is not readable + Install %1$s? + %1$s model · %2$s · approximately %3$s + Installing %1$s + Scanning folder + Scan complete: %1$d audio files + Folder scan failed + Audio catalog refresh failed + Completed + Transcribing + Completed %1$d of %2$d + Completed %1$d of %2$d, %3$d failed + Cannot play %1$s + shared audio + Audio import failed + Imported %1$d + %1$d already added + rejected: %1$s + %1$d rejected + no audio received + Install + Unknown + document + audio folder ✓ %1$s ○ %1$s Completed: %1$s diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidAudioMetadataFormatterTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidAudioMetadataFormatterTest.kt index 7fe85d9..adc2b6a 100644 --- a/app/src/test/java/me/maxistar/voiceinbox/AndroidAudioMetadataFormatterTest.kt +++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidAudioMetadataFormatterTest.kt @@ -70,6 +70,6 @@ class AndroidAudioMetadataFormatterTest { ).taskList.tasks.single().detail assertEquals(processed, all) - assertTrue(requireNotNull(processed).endsWith(" • 1 KiB • 1:05")) + assertTrue(requireNotNull(processed).fallback.endsWith(" • 1 KiB • 1:05")) } } diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidInlineOnboardingTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidInlineOnboardingTest.kt index 44d438f..f63f989 100644 --- a/app/src/test/java/me/maxistar/voiceinbox/AndroidInlineOnboardingTest.kt +++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidInlineOnboardingTest.kt @@ -40,7 +40,7 @@ class AndroidInlineOnboardingTest { @Test fun modelActionUsesDownloadRetryManualImportAndNeutralInstallingState() { assertEquals(TaskActionKind.DOWNLOAD_MODEL, present().action?.kind) - assertEquals("Start setup", present().action?.label) + assertEquals(R.string.onboarding_action_start, present().action?.labelRes) val invalid = present( model = ModelSetupSnapshot(ModelSetupSnapshotState.INVALID, downloadAvailable = true), @@ -56,7 +56,7 @@ class AndroidInlineOnboardingTest { model = ModelSetupSnapshot(ModelSetupSnapshotState.INSTALLING, downloadAvailable = true), ) assertFalse(installing.action!!.enabled) - assertEquals("Installing speech model…", installing.action?.label) + assertEquals(R.string.onboarding_action_installing, installing.action?.labelRes) } @Test diff --git a/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt b/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt index c146ff3..ca031dc 100644 --- a/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt +++ b/app/src/test/java/me/maxistar/voiceinbox/AndroidMainScreenStateHostTest.kt @@ -17,6 +17,7 @@ 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.TaskTextKey import me.maxistar.voiceinbox.core.TranscriptionTaskSnapshot import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -43,10 +44,10 @@ class AndroidMainScreenStateHostTest { 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("Verifying local model", tasks.first().progress?.phase?.fallback) assertEquals(63, tasks.first().progress?.percent) - assertEquals("Output unavailable", tasks[1].errorMessage) - assertEquals("Scanning audio folder", tasks[2].progress?.phase) + assertEquals("Output unavailable", tasks[1].errorMessage?.fallback) + assertEquals(TaskTextKey.SCANNING_AUDIO_FOLDER, tasks[2].progress?.phase?.key) } @Test @@ -70,7 +71,7 @@ class AndroidMainScreenStateHostTest { .single { it.stableId == "setup:model" } assertNull(task.detail) - assertEquals(message, task.progress?.phase) + assertEquals(message, task.progress?.phase?.fallback) } @Test @@ -91,7 +92,7 @@ class AndroidMainScreenStateHostTest { 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) - assertTrue(requireNotNull(tasks.single { it.entryId == 1L }.detail).endsWith(" • 1 KiB")) + assertTrue(requireNotNull(tasks.single { it.entryId == 1L }.detail).fallback.endsWith(" • 1 KiB")) } @Test @@ -117,7 +118,7 @@ class AndroidMainScreenStateHostTest { val tasks = state.taskList.tasks.filterIsInstance() val active = tasks.single { it.entryId == 1L } assertEquals(AudioTaskState.PROCESSING, active.state) - assertEquals("Transcribing", active.progress?.phase) + assertEquals("Transcribing", active.progress?.phase?.fallback) assertEquals(25, active.progress?.percent) val preview = tasks.single { it.entryId == 2L } assertEquals(TaskActionKind.STOP, preview.actions.last().kind) @@ -163,7 +164,7 @@ class AndroidMainScreenStateHostTest { assertNull(tasks.single { it.entryId == 1L }.progress) val owner = tasks.single { it.entryId == 2L } assertEquals(AudioTaskState.PROCESSING, owner.state) - assertEquals("Preparing speech model", owner.progress?.phase) + assertEquals("Preparing speech model", owner.progress?.phase?.fallback) } @Test @@ -318,7 +319,7 @@ class AndroidMainScreenStateHostTest { @Test fun hydratedCatalogPublishesEmptyStateAndRetainedEntriesRemainVisibleWhileRevalidating() { val empty = AndroidTaskListSnapshotMapper.state(readyInput()) - assertEquals("No new tasks", empty.taskList.emptyMessage) + assertEquals(TaskTextKey.NO_NEW_TASKS, empty.taskList.emptyMessage?.key) val retained = AndroidTaskListSnapshotMapper.state( readyInput(entries = listOf(entry(1, AudioFileState.PENDING))).copy( diff --git a/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt b/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt index bbabdde..f2df141 100644 --- a/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt +++ b/app/src/test/java/me/maxistar/voiceinbox/TaskListDisplayItemsTest.kt @@ -13,6 +13,8 @@ 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.TaskText +import me.maxistar.voiceinbox.core.TaskTextKey import me.maxistar.voiceinbox.core.TranscriptionTaskSnapshot import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -59,7 +61,7 @@ class TaskListDisplayItemsTest { assertTrue(TaskListDisplayItemDiff.areItemsTheSame(idle, active)) assertFalse(TaskListDisplayItemDiff.areContentsTheSame(idle, active)) - assertEquals("Transcribing", (active as TaskListDisplayItem.Audio).task.progress?.phase) + assertEquals("Transcribing", (active as TaskListDisplayItem.Audio).task.progress?.phase?.fallback) assertEquals(20, active.task.progress?.percent) val processed = items(filter = TaskListFilter.PROCESSED, audio = listOf(pending(7))) @@ -73,7 +75,7 @@ class TaskListDisplayItemsTest { .filterIsInstance() .single() val progress = TaskProgressPresentation( - phase = "Transcribing", + phase = TaskText(TaskTextKey.OPAQUE, "Transcribing", listOf("Transcribing")), percent = 35, processedUs = 3_000_000, durationUs = 10_000_000, @@ -81,7 +83,9 @@ class TaskListDisplayItemsTest { totalFiles = 3, ) val progressUpdate = original.copy(task = original.task.copy(progress = progress)) - val badgeUpdate = progressUpdate.copy(task = progressUpdate.task.copy(badge = "Changed")) + val badgeUpdate = progressUpdate.copy( + task = progressUpdate.task.copy(badge = TaskText(TaskTextKey.OPAQUE, "Changed", listOf("Changed"))), + ) val actionsUpdate = progressUpdate.copy(task = progressUpdate.task.copy(actions = emptyList())) assertEquals( @@ -100,7 +104,7 @@ class TaskListDisplayItemsTest { fun emptyStateCarriesOnlyPresentedTypedActions() { val item = items().single() as TaskListDisplayItem.Empty - assertEquals("No new tasks", item.message) + assertEquals(TaskTextKey.NO_NEW_TASKS, item.message.key) assertEquals( listOf(TaskActionKind.IMPORT_AUDIO), item.actions.map { it.kind }, @@ -135,7 +139,7 @@ class TaskListDisplayItemsTest { val changedHint = hint.copy( steps = hint.steps.mapIndexed { index, step -> step.copy(complete = index == 0) }, - action = AndroidOnboardingHintAction("Select output file", true, TaskActionKind.SELECT_OUTPUT), + action = AndroidOnboardingHintAction(R.string.onboarding_action_select_output, true, TaskActionKind.SELECT_OUTPUT), ) val oldItem = empty.first() val newItem = items(onboardingHint = changedHint).first() @@ -164,7 +168,7 @@ class TaskListDisplayItemsTest { ), withBoth.map { it.stableKey }, ) - val changed = discovery.copy(setupLabel = "Choose keyboard") + val changed = discovery.copy(setupLabelRes = R.string.settings_voice_keyboard_choose) assertTrue( TaskListDisplayItemDiff.areItemsTheSame( withBoth[1], @@ -205,12 +209,12 @@ class TaskListDisplayItemsTest { private fun onboardingHint() = AndroidOnboardingHintPresentation( visible = true, steps = listOf( - AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.MODEL, "Install speech model", false), - AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.OUTPUT, "Select transcript output", false), - AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.FOLDER, "Select audio folder · Optional", false, true), - AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.KEYBOARD, "Enable voice keyboard · Optional", false, true), + AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.MODEL, R.string.onboarding_step_model, false), + AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.OUTPUT, R.string.onboarding_step_output, false), + AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.FOLDER, R.string.onboarding_step_folder, false, true), + AndroidOnboardingChecklistStep(AndroidOnboardingStepKind.KEYBOARD, R.string.onboarding_step_keyboard, false, true), ), - action = AndroidOnboardingHintAction("Start setup", true, TaskActionKind.DOWNLOAD_MODEL), + action = AndroidOnboardingHintAction(R.string.onboarding_action_start, true, TaskActionKind.DOWNLOAD_MODEL), ) private fun pending(id: Long) = AudioTaskSnapshot( diff --git a/iosApp/VoiceInbox/ContentView.swift b/iosApp/VoiceInbox/ContentView.swift index 44b3836..8d6ac1e 100644 --- a/iosApp/VoiceInbox/ContentView.swift +++ b/iosApp/VoiceInbox/ContentView.swift @@ -169,10 +169,10 @@ struct ContentView: View { if let emptyMessage = screen.state.emptyMessage { VStack(alignment: .leading, spacing: 8) { - Text(emptyMessage) + Text(emptyMessage.fallback) .foregroundStyle(.secondary) ForEach(Array(screen.state.emptyActions.enumerated()), id: \.offset) { _, action in - Button(action.label) { + Button(action.text.fallback) { perform(action: action, task: nil, screen: screen) } .buttonStyle(.borderless) @@ -610,16 +610,16 @@ private struct TaskListRow: View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { - Text(task.title) + Text(task.title.fallback) .font(.headline) - if let detail = task.detail, !detail.isEmpty { - Text(detail) + if let detail = task.detail, !detail.fallback.isEmpty { + Text(detail.fallback) .font(.subheadline) .foregroundStyle(.secondary) } } Spacer() - Text(task.badge) + Text(task.badge.fallback) .font(.caption) .padding(.horizontal, 8) .padding(.vertical, 4) @@ -637,8 +637,8 @@ private struct TaskListRow: View { } } - if let error = task.errorMessage, !error.isEmpty { - Text(error) + if let error = task.errorMessage, !error.fallback.isEmpty { + Text(error.fallback) .font(.footnote) .foregroundStyle(.red) } @@ -661,7 +661,7 @@ private struct TaskListRow: View { if !visibleActions.isEmpty { HStack { ForEach(Array(visibleActions.enumerated()), id: \.offset) { _, action in - Button(action.label) { onAction(action) } + Button(action.text.fallback) { onAction(action) } .disabled(!action.enabled) .accessibilityIdentifier("task-action-\(task.stableId)-\(action.kind.name.lowercased())") } @@ -674,7 +674,7 @@ private struct TaskListRow: View { } private func progressLabel(_ progress: TaskProgressPresentation) -> String { - var parts = [progress.phase] + var parts = [progress.phase.fallback] if let completed = progress.completedFiles?.int32Value, let total = progress.totalFiles?.int32Value, total > 0 { 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 5e0e6f8..c0bcdfa 100644 --- a/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt +++ b/shared/src/commonMain/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationController.kt @@ -54,14 +54,72 @@ enum class TaskActionKind { OPEN_VOICE_KEYBOARD_DOCUMENTATION, } +/** + * Stable, locale-neutral identifiers for copy owned by the task-list presentation. + * + * [fallback] keeps iOS English-only until it adopts a native localization layer; + * Android resolves [TaskText.key] through its resources instead. + */ +enum class TaskTextKey { + OPAQUE, + INSTALL_SPEECH_MODEL, + AUTOMATIC_TRANSCRIPT_EXPORT, + OUTPUT_OPTIONAL_DETAIL, + REFRESH_AUDIO_FOLDER, + RESTORE_AUDIO_FOLDER_ACCESS, + REQUIRED, + OPTIONAL, + INSTALLING, + SCANNING, + NEEDS_ATTENTION, + NEW, + PROCESSING, + PROCESSED, + FAILED, + NO_SPEECH, + CANCEL, + RETRY_DOWNLOAD, + DOWNLOAD, + CREATE_NEW, + CHOOSE_EXISTING, + HIDE, + SELECT_FOLDER, + RETRY, + TRANSCRIBE, + SHOW_TEXT, + STOP, + PLAY, + IMPORT_AUDIO_FILES, + SELECT_AUDIO_FOLDER, + NO_NEW_TASKS, + NO_PROCESSED_AUDIO, + NO_TASKS, + INSTALLING_MODEL, + SCANNING_AUDIO_FOLDER, + PREPARING_SPEECH_MODEL, + CHOOSE_MODEL, + SELECTED_MODEL_DETAIL, +} + +data class TaskText( + val key: TaskTextKey, + val fallback: String, + val arguments: List = emptyList(), +) + +private fun taskText(key: TaskTextKey, fallback: String, vararg arguments: String) = + TaskText(key, fallback, arguments.toList()) + +private fun opaqueTaskText(value: String) = taskText(TaskTextKey.OPAQUE, value, value) + data class TaskActionPresentation( val kind: TaskActionKind, - val label: String, + val text: TaskText, val enabled: Boolean = true, ) data class TaskProgressPresentation( - val phase: String, + val phase: TaskText, val percent: Int? = null, val processedUs: Long? = null, val durationUs: Long? = null, @@ -72,11 +130,11 @@ data class TaskProgressPresentation( sealed class TaskPresentation { abstract val stableId: String - abstract val title: String - abstract val detail: String? - abstract val badge: String + abstract val title: TaskText + abstract val detail: TaskText? + abstract val badge: TaskText abstract val progress: TaskProgressPresentation? - abstract val errorMessage: String? + abstract val errorMessage: TaskText? abstract val actions: List abstract val retention: TaskRetention } @@ -85,11 +143,11 @@ 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 title: TaskText, + override val detail: TaskText?, + override val badge: TaskText, override val progress: TaskProgressPresentation?, - override val errorMessage: String?, + override val errorMessage: TaskText?, override val actions: List, override val retention: TaskRetention = TaskRetention.UNTIL_COMPLETED, ) : TaskPresentation() @@ -98,11 +156,11 @@ 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 title: TaskText, + override val detail: TaskText?, + override val badge: TaskText, override val progress: TaskProgressPresentation?, - override val errorMessage: String?, + override val errorMessage: TaskText?, override val actions: List, override val retention: TaskRetention = TaskRetention.RETAINED, ) : TaskPresentation() @@ -210,7 +268,7 @@ data class TaskListBatchActionState( data class TaskListState( val filter: TaskListFilter, val tasks: List, - val emptyMessage: String?, + val emptyMessage: TaskText?, val emptyActions: List, val batchAction: TaskListBatchActionState, ) @@ -234,9 +292,9 @@ object TaskListPresentationController { emptyMessage = if (tasks.isEmpty()) emptyMessage(input.filter) else null, emptyActions = if (tasks.isEmpty() && input.filter != TaskListFilter.PROCESSED) { buildList { - add(TaskActionPresentation(TaskActionKind.IMPORT_AUDIO, "Import Audio Files")) + add(TaskActionPresentation(TaskActionKind.IMPORT_AUDIO, taskText(TaskTextKey.IMPORT_AUDIO_FILES, "Import Audio Files"))) if (input.folder.state == FolderSetupSnapshotState.UNSELECTED) { - add(TaskActionPresentation(TaskActionKind.SELECT_FOLDER, "Select Audio Folder")) + add(TaskActionPresentation(TaskActionKind.SELECT_FOLDER, taskText(TaskTextKey.SELECT_AUDIO_FOLDER, "Select Audio Folder"))) } } } else { @@ -271,7 +329,7 @@ object TaskListPresentationController { val error = snapshot.state == ModelSetupSnapshotState.INVALID val actions = when { active && snapshot.canCancel -> listOf( - TaskActionPresentation(TaskActionKind.CANCEL_MODEL_DOWNLOAD, "Cancel"), + TaskActionPresentation(TaskActionKind.CANCEL_MODEL_DOWNLOAD, taskText(TaskTextKey.CANCEL, "Cancel")), ) active -> emptyList() error -> listOf( @@ -280,14 +338,14 @@ object TaskListPresentationController { selectedModelLabel(snapshot), snapshot.downloadChoices.isNotEmpty(), ), - TaskActionPresentation(TaskActionKind.RETRY_MODEL_DOWNLOAD, "Retry Download", snapshot.downloadAvailable), + TaskActionPresentation(TaskActionKind.RETRY_MODEL_DOWNLOAD, taskText(TaskTextKey.RETRY_DOWNLOAD, "Retry Download"), snapshot.downloadAvailable), ) else -> buildList { if (snapshot.downloadChoices.size > 1) { add(TaskActionPresentation(TaskActionKind.SELECT_DOWNLOAD_MODEL, selectedModelLabel(snapshot))) } if (snapshot.downloadAvailable) { - add(TaskActionPresentation(TaskActionKind.DOWNLOAD_MODEL, "Download")) + add(TaskActionPresentation(TaskActionKind.DOWNLOAD_MODEL, taskText(TaskTextKey.DOWNLOAD, "Download"))) } } } @@ -299,35 +357,45 @@ object TaskListPresentationController { error -> SetupTaskState.ERROR else -> SetupTaskState.REQUIRED }, - title = "Install Speech Model", - detail = selectedModelDetail(snapshot) ?: snapshot.detail.takeUnless { active }, - badge = if (active) "Installing" else if (error) "Needs attention" else "Required", + title = taskText(TaskTextKey.INSTALL_SPEECH_MODEL, "Install Speech Model"), + detail = selectedModelDetail(snapshot) ?: snapshot.detail.takeUnless { active }?.let(::opaqueTaskText), + badge = if (active) taskText(TaskTextKey.INSTALLING, "Installing") else if (error) taskText(TaskTextKey.NEEDS_ATTENTION, "Needs attention") else taskText(TaskTextKey.REQUIRED, "Required"), progress = if (active) { TaskProgressPresentation( - snapshot.installationPhase?.takeIf(String::isNotBlank) ?: "Installing model", + snapshot.installationPhase?.takeIf(String::isNotBlank)?.let(::opaqueTaskText) + ?: taskText(TaskTextKey.INSTALLING_MODEL, "Installing model"), snapshot.progressPercent, ) } else { null }, - errorMessage = snapshot.detail.takeIf { error }, + errorMessage = snapshot.detail.takeIf { error }?.let(::opaqueTaskText), actions = actions, ) } - private fun selectedModelDetail(snapshot: ModelSetupSnapshot): String? { + private fun selectedModelDetail(snapshot: ModelSetupSnapshot): TaskText? { val selected = snapshot.selectedModel ?: return null val choice = snapshot.downloadChoices.firstOrNull { it.identity == selected } ?: return null val download = formatBytes(choice.downloadBytes) val storage = formatBytes(choice.requiredStorageBytes) - return "${choice.displayName} · ${choice.languageSummary} · ${choice.maturity} · $download download · $storage free" + return taskText( + TaskTextKey.SELECTED_MODEL_DETAIL, + "${choice.displayName} · ${choice.languageSummary} · ${choice.maturity} · $download download · $storage free", + choice.displayName, + choice.languageSummary, + choice.maturity, + download, + storage, + ) } - private fun selectedModelLabel(snapshot: ModelSetupSnapshot): String = + private fun selectedModelLabel(snapshot: ModelSetupSnapshot): TaskText = snapshot.selectedModel ?.let { selected -> snapshot.downloadChoices.firstOrNull { it.identity == selected } } ?.displayName - ?: "Choose Model" + ?.let(::opaqueTaskText) + ?: taskText(TaskTextKey.CHOOSE_MODEL, "Choose Model") private fun formatBytes(bytes: Long): String = when { bytes >= 1024L * 1024L * 1024L -> "${bytes / (1024L * 1024L * 1024L)} GB" @@ -341,15 +409,16 @@ object TaskListPresentationController { stableId = "setup:output", kind = SetupTaskKind.OUTPUT, state = if (error) SetupTaskState.ERROR else SetupTaskState.REQUIRED, - title = "Automatic Transcript Export", - detail = snapshot.detail ?: "Optional. Transcripts are stored in Voice Inbox.", - badge = if (error) "Needs attention" else "Optional", + title = taskText(TaskTextKey.AUTOMATIC_TRANSCRIPT_EXPORT, "Automatic Transcript Export"), + detail = snapshot.detail?.let(::opaqueTaskText) + ?: taskText(TaskTextKey.OUTPUT_OPTIONAL_DETAIL, "Optional. Transcripts are stored in Voice Inbox."), + badge = if (error) taskText(TaskTextKey.NEEDS_ATTENTION, "Needs attention") else taskText(TaskTextKey.OPTIONAL, "Optional"), progress = null, - errorMessage = snapshot.detail.takeIf { error }, + errorMessage = snapshot.detail.takeIf { error }?.let(::opaqueTaskText), actions = buildList { - add(TaskActionPresentation(TaskActionKind.CREATE_OUTPUT, "Create New")) - add(TaskActionPresentation(TaskActionKind.SELECT_OUTPUT, "Choose Existing")) - if (!error) add(TaskActionPresentation(TaskActionKind.HIDE_OUTPUT, "Hide")) + add(TaskActionPresentation(TaskActionKind.CREATE_OUTPUT, taskText(TaskTextKey.CREATE_NEW, "Create New"))) + add(TaskActionPresentation(TaskActionKind.SELECT_OUTPUT, taskText(TaskTextKey.CHOOSE_EXISTING, "Choose Existing"))) + if (!error) add(TaskActionPresentation(TaskActionKind.HIDE_OUTPUT, taskText(TaskTextKey.HIDE, "Hide"))) }, ) } @@ -362,10 +431,10 @@ object TaskListPresentationController { stableId = "setup:folder", kind = SetupTaskKind.FOLDER, state = SetupTaskState.ACTIVE, - title = "Refresh Audio Folder", - detail = snapshot.detail, - badge = "Scanning", - progress = TaskProgressPresentation("Scanning audio folder"), + title = taskText(TaskTextKey.REFRESH_AUDIO_FOLDER, "Refresh Audio Folder"), + detail = snapshot.detail?.let(::opaqueTaskText), + badge = taskText(TaskTextKey.SCANNING, "Scanning"), + progress = TaskProgressPresentation(taskText(TaskTextKey.SCANNING_AUDIO_FOLDER, "Scanning audio folder")), errorMessage = null, actions = emptyList(), ) @@ -373,14 +442,14 @@ object TaskListPresentationController { stableId = "setup:folder", kind = SetupTaskKind.FOLDER, state = SetupTaskState.ERROR, - title = "Restore Audio Folder Access", - detail = snapshot.detail, - badge = "Needs attention", + title = taskText(TaskTextKey.RESTORE_AUDIO_FOLDER_ACCESS, "Restore Audio Folder Access"), + detail = snapshot.detail?.let(::opaqueTaskText), + badge = taskText(TaskTextKey.NEEDS_ATTENTION, "Needs attention"), progress = null, - errorMessage = snapshot.detail, + errorMessage = snapshot.detail?.let(::opaqueTaskText), actions = listOf( - TaskActionPresentation(TaskActionKind.SELECT_FOLDER, "Select Folder"), - TaskActionPresentation(TaskActionKind.REFRESH_FOLDER, "Retry"), + TaskActionPresentation(TaskActionKind.SELECT_FOLDER, taskText(TaskTextKey.SELECT_FOLDER, "Select Folder")), + TaskActionPresentation(TaskActionKind.REFRESH_FOLDER, taskText(TaskTextKey.RETRY, "Retry")), ), ) } @@ -408,7 +477,7 @@ object TaskListPresentationController { AudioTaskState.PENDING -> add( TaskActionPresentation( TaskActionKind.TRANSCRIBE, - "Transcribe", + taskText(TaskTextKey.TRANSCRIBE, "Transcribe"), snapshot.eligibleForTranscription && !input.transcription.active, ), ) @@ -417,19 +486,19 @@ object TaskListPresentationController { -> add( TaskActionPresentation( TaskActionKind.RETRY_TRANSCRIPTION, - "Retry", + taskText(TaskTextKey.RETRY, "Retry"), snapshot.eligibleForTranscription && !input.transcription.active, ), ) AudioTaskState.SUCCEEDED -> if (snapshot.hasTranscriptText) { - add(TaskActionPresentation(TaskActionKind.SHOW_TEXT, "Show Text")) + add(TaskActionPresentation(TaskActionKind.SHOW_TEXT, taskText(TaskTextKey.SHOW_TEXT, "Show Text"))) } AudioTaskState.PROCESSING -> Unit } add( TaskActionPresentation( if (isPreviewing) TaskActionKind.STOP else TaskActionKind.PLAY, - if (isPreviewing) "Stop" else "Play", + if (isPreviewing) taskText(TaskTextKey.STOP, "Stop") else taskText(TaskTextKey.PLAY, "Play"), isPreviewing || (snapshot.eligibleForPreview && !input.transcription.active), ), ) @@ -443,24 +512,24 @@ object TaskListPresentationController { stableId = "audio:${snapshot.entryId}", entryId = snapshot.entryId, state = presentationState, - title = snapshot.title, - detail = snapshot.detail, + title = opaqueTaskText(snapshot.title), + detail = snapshot.detail?.let(::opaqueTaskText), badge = when (presentationState) { - AudioTaskState.PENDING -> "New" - AudioTaskState.PROCESSING -> "Processing" - AudioTaskState.SUCCEEDED -> "Processed" - AudioTaskState.FAILED -> "Failed" - AudioTaskState.NO_SPEECH -> "No speech" + AudioTaskState.PENDING -> taskText(TaskTextKey.NEW, "New") + AudioTaskState.PROCESSING -> taskText(TaskTextKey.PROCESSING, "Processing") + AudioTaskState.SUCCEEDED -> taskText(TaskTextKey.PROCESSED, "Processed") + AudioTaskState.FAILED -> taskText(TaskTextKey.FAILED, "Failed") + AudioTaskState.NO_SPEECH -> taskText(TaskTextKey.NO_SPEECH, "No speech") }, progress = if (ownsProgress) input.transcription.toProgress() else null, - errorMessage = prerequisiteError ?: snapshot.lastError, + errorMessage = (prerequisiteError ?: snapshot.lastError)?.let(::opaqueTaskText), actions = actions, ) } private fun TranscriptionTaskSnapshot.toProgress(): TaskProgressPresentation = TaskProgressPresentation( - phase = phase ?: "Processing", + phase = phase?.let(::opaqueTaskText) ?: taskText(TaskTextKey.PROCESSING, "Processing"), percent = percent, processedUs = processedUs, durationUs = durationUs, @@ -485,9 +554,9 @@ object TaskListPresentationController { .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" + private fun emptyMessage(filter: TaskListFilter): TaskText = when (filter) { + TaskListFilter.NEW -> taskText(TaskTextKey.NO_NEW_TASKS, "No new tasks") + TaskListFilter.PROCESSED -> taskText(TaskTextKey.NO_PROCESSED_AUDIO, "No processed audio files") + TaskListFilter.ALL -> taskText(TaskTextKey.NO_TASKS, "No audio tasks") } } 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 d7fc2f5..2ac7da0 100644 --- a/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt +++ b/shared/src/commonTest/kotlin/me/maxistar/voiceinbox/core/TaskListPresentationControllerTest.kt @@ -22,7 +22,8 @@ class TaskListPresentationControllerTest { ), ).tasks.single(), ) - assertTrue(required.detail?.contains("Whisper Tiny Multilingual") == true) + assertEquals(TaskTextKey.SELECTED_MODEL_DETAIL, required.detail?.key) + assertEquals("Whisper Tiny Multilingual", required.detail?.arguments?.first()) assertTrue(required.actions.any { it.kind == TaskActionKind.SELECT_DOWNLOAD_MODEL }) val installing = assertIs( @@ -73,7 +74,7 @@ class TaskListPresentationControllerTest { state(output = OutputSetupSnapshot(OutputSetupSnapshotState.REQUIRED)).tasks.single(), ) - assertEquals("Automatic Transcript Export", task.title) + assertEquals(TaskTextKey.AUTOMATIC_TRANSCRIPT_EXPORT, task.title.key) assertEquals( listOf( TaskActionKind.CREATE_OUTPUT, @@ -82,12 +83,16 @@ class TaskListPresentationControllerTest { ), task.actions.map { it.kind }, ) - assertEquals(listOf("Create New", "Choose Existing", "Hide"), task.actions.map { it.label }) + assertEquals( + listOf(TaskTextKey.CREATE_NEW, TaskTextKey.CHOOSE_EXISTING, TaskTextKey.HIDE), + task.actions.map { it.text.key }, + ) val invalid = assertIs( state(output = OutputSetupSnapshot(OutputSetupSnapshotState.INVALID, "Access expired")).tasks.single(), ) - assertEquals("Access expired", invalid.errorMessage) + assertEquals(TaskTextKey.OPAQUE, invalid.errorMessage?.key) + assertEquals("Access expired", invalid.errorMessage?.arguments?.single()) assertEquals( listOf(TaskActionKind.CREATE_OUTPUT, TaskActionKind.SELECT_OUTPUT), invalid.actions.map { it.kind }, @@ -117,8 +122,9 @@ class TaskListPresentationControllerTest { ).tasks.single(), ) - assertEquals("Installing", download.badge) - assertEquals("Downloading encoder.onnx", download.progress?.phase) + assertEquals(TaskTextKey.INSTALLING, download.badge.key) + assertEquals(TaskTextKey.OPAQUE, download.progress?.phase?.key) + assertEquals("Downloading encoder.onnx", download.progress?.phase?.arguments?.single()) assertEquals(42, download.progress?.percent) assertEquals(listOf(TaskActionKind.CANCEL_MODEL_DOWNLOAD), download.actions.map { it.kind }) @@ -133,13 +139,14 @@ class TaskListPresentationControllerTest { ).tasks.single(), ) assertNull(localImport.detail) - assertEquals("Verifying local model", localImport.progress?.phase) + assertEquals(TaskTextKey.OPAQUE, localImport.progress?.phase?.key) + assertEquals("Verifying local model", localImport.progress?.phase?.arguments?.single()) assertTrue(localImport.actions.isEmpty()) val fallback = assertIs( state(model = ModelSetupSnapshot(ModelSetupSnapshotState.INSTALLING)).tasks.single(), ) - assertEquals("Installing model", fallback.progress?.phase) + assertEquals(TaskTextKey.INSTALLING_MODEL, fallback.progress?.phase?.key) assertNull(fallback.progress?.percent) } @@ -155,7 +162,8 @@ class TaskListPresentationControllerTest { ).tasks.single(), ) - assertEquals("Verification failed", task.errorMessage) + assertEquals(TaskTextKey.OPAQUE, task.errorMessage?.key) + assertEquals("Verification failed", task.errorMessage?.arguments?.single()) assertFalse(task.actions.single { it.kind == TaskActionKind.RETRY_MODEL_DOWNLOAD }.enabled) assertFalse(task.actions.any { it.kind == TaskActionKind.IMPORT_MODEL }) } @@ -201,7 +209,8 @@ class TaskListPresentationControllerTest { val task = assertIs(state.tasks.single()) assertEquals(AudioTaskState.PENDING, task.state) - assertEquals("Model could not be loaded", task.errorMessage) + assertEquals(TaskTextKey.OPAQUE, task.errorMessage?.key) + assertEquals("Model could not be loaded", task.errorMessage?.arguments?.single()) assertTrue(state(TaskListFilter.PROCESSED, audio = listOf(audio(7, AudioFileState.PENDING))).tasks.isEmpty()) } @@ -219,7 +228,8 @@ class TaskListPresentationControllerTest { 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) + assertEquals(TaskTextKey.OPAQUE, active.progress?.phase?.key) + assertEquals("Preparing speech model", active.progress?.phase?.arguments?.single()) assertNull(assertIs(byId.getValue("audio:2")).progress) assertEquals("audio:1", active.stableId) } @@ -298,13 +308,13 @@ class TaskListPresentationControllerTest { ) val processed = state(TaskListFilter.PROCESSED) - assertEquals("No new tasks", newState.emptyMessage) + assertEquals(TaskTextKey.NO_NEW_TASKS, newState.emptyMessage?.key) 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) + assertEquals(TaskTextKey.NO_PROCESSED_AUDIO, processed.emptyMessage?.key) assertTrue(processed.emptyActions.isEmpty()) } From c10f2b68995c77c74cfbcb8d546f183fb4406667 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Sat, 19 Sep 2026 04:52:04 +0200 Subject: [PATCH 02/11] add ios localization --- iosApp/VoiceInbox.xcodeproj/project.pbxproj | 9 + iosApp/VoiceInbox/ContentView.swift | 81 +- .../VoiceInbox/IosAudioDocumentPicker.swift | 25 +- iosApp/VoiceInbox/IosAudioImportStore.swift | 14 +- iosApp/VoiceInbox/IosAudioPreviewPlayer.swift | 8 +- iosApp/VoiceInbox/IosInlineOnboarding.swift | 34 +- iosApp/VoiceInbox/IosLocalization.swift | 70 + .../VoiceInbox/IosMainScreenShellState.swift | 6 +- ...IosSingleFileTranscriptionController.swift | 14 +- iosApp/VoiceInbox/IosSpeechModelStore.swift | 24 +- iosApp/VoiceInbox/IosTranscriptReview.swift | 10 +- iosApp/VoiceInbox/Localizable.xcstrings | 2500 +++++++++++++++++ iosApp/VoiceInbox/SettingsView.swift | 52 +- .../DeferredSpeechModelLoadingTests.swift | 2 +- .../IosInlineOnboardingTests.swift | 9 + .../IosTranscriptReviewTests.swift | 6 +- 16 files changed, 2735 insertions(+), 129 deletions(-) create mode 100644 iosApp/VoiceInbox/IosLocalization.swift create mode 100644 iosApp/VoiceInbox/Localizable.xcstrings diff --git a/iosApp/VoiceInbox.xcodeproj/project.pbxproj b/iosApp/VoiceInbox.xcodeproj/project.pbxproj index f8f2280..9dc32be 100644 --- a/iosApp/VoiceInbox.xcodeproj/project.pbxproj +++ b/iosApp/VoiceInbox.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + E50000000000000000000001 /* IosLocalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50000000000000000000002 /* IosLocalization.swift */; }; + E50000000000000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = E50000000000000000000004 /* Localizable.xcstrings */; }; 101010101010101010101001 /* VoiceInboxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101010101010101010101011 /* VoiceInboxApp.swift */; }; 101010101010101010101002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101010101010101010101012 /* ContentView.swift */; }; 101010101010101010101021 /* IosScheduledTranscriptionSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101010101010101010101023 /* IosScheduledTranscriptionSettingsStore.swift */; }; @@ -64,6 +66,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + E50000000000000000000002 /* IosLocalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosLocalization.swift; sourceTree = ""; }; + E50000000000000000000004 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 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 = ""; }; 101010101010101010101012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -144,6 +148,8 @@ children = ( 101010101010101010101011 /* VoiceInboxApp.swift */, 101010101010101010101012 /* ContentView.swift */, + E50000000000000000000002 /* IosLocalization.swift */, + E50000000000000000000004 /* Localizable.xcstrings */, 101010101010101010101030 /* IosAudioDocumentPicker.swift */, 101010101010101010101029 /* IosAudioImportStore.swift */, C30000000000000000000002 /* IosAudioMetadataFormatter.swift */, @@ -268,6 +274,7 @@ hasScannedForEncodings = 0; knownRegions = ( en, + ru, Base, ); mainGroup = 101010101010101010101005; @@ -288,6 +295,7 @@ buildActionMask = 2147483647; files = ( 101010101010101010101033 /* Assets.xcassets in Resources */, + E50000000000000000000003 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -359,6 +367,7 @@ files = ( 101010101010101010101001 /* VoiceInboxApp.swift in Sources */, 101010101010101010101002 /* ContentView.swift in Sources */, + E50000000000000000000001 /* IosLocalization.swift in Sources */, 101010101010101010101028 /* IosAudioDocumentPicker.swift in Sources */, 101010101010101010101027 /* IosAudioImportStore.swift in Sources */, 101010101010101010101031 /* IosAudioPreviewPlayer.swift in Sources */, diff --git a/iosApp/VoiceInbox/ContentView.swift b/iosApp/VoiceInbox/ContentView.swift index 8d6ac1e..ff3365c 100644 --- a/iosApp/VoiceInbox/ContentView.swift +++ b/iosApp/VoiceInbox/ContentView.swift @@ -58,13 +58,13 @@ struct ContentView: View { ) .font(.caption) .foregroundStyle(.secondary) - Text("Installing this model replaces the currently installed model.") + Text(L10n.text("model.replacementWarning", fallback: "Installing this model replaces the currently installed model.")) .font(.caption) .foregroundStyle(.secondary) } HStack { - Button("Install") { + Button(L10n.text("common.install", fallback: "Install")) { speechModelStore.confirmPendingInstallation( candidate: candidate, replacementAllowed: !transcriber.isActive @@ -73,7 +73,7 @@ struct ContentView: View { .buttonStyle(.borderedProminent) .disabled(speechModelStore.isBusy || transcriber.isActive) - Button("Cancel", role: .cancel) { + Button(L10n.text("common.cancel", fallback: "Cancel"), role: .cancel) { speechModelStore.cancelPendingInstallation() } .buttonStyle(.bordered) @@ -96,22 +96,20 @@ struct ContentView: View { .font(.caption) .foregroundStyle(.secondary) if speechModelStore.activeDescriptor != nil { - Text("Downloading this model replaces the currently installed model.") + Text(L10n.text("model.downloadReplacementWarning", fallback: "Downloading this model replaces the currently installed model.")) .font(.caption) .foregroundStyle(.secondary) } } - Button("Download") { + Button(L10n.text("common.download", fallback: "Download")) { speechModelStore.downloadModel(speechModelStore.selectedDownloadModel) } .buttonStyle(.borderedProminent) .disabled(speechModelStore.isBusy) - let modelTask = screen.state.tasks - .compactMap({ $0 as? SetupTaskPresentation }) - .first(where: { $0.kind == .model }) - if let error = modelTask?.errorMessage, !error.isEmpty { - Text(error) + let modelTask = modelSetupTask(in: screen) + if let error = modelTask?.errorMessage, !error.fallback.isEmpty { + Text(IosTaskTextResolver.resolve(error)) .font(.footnote) .foregroundStyle(.red) } @@ -151,7 +149,7 @@ struct ContentView: View { transcribeAll() } label: { Label( - "Transcribe All (\(screen.state.batchAction.eligibleCount))", + L10n.plural("catalog.transcribeAll", count: Int(screen.state.batchAction.eligibleCount), fallback: "Transcribe All (%d)"), systemImage: "text.badge.checkmark" ) } @@ -169,10 +167,10 @@ struct ContentView: View { if let emptyMessage = screen.state.emptyMessage { VStack(alignment: .leading, spacing: 8) { - Text(emptyMessage.fallback) + Text(IosTaskTextResolver.resolve(emptyMessage)) .foregroundStyle(.secondary) ForEach(Array(screen.state.emptyActions.enumerated()), id: \.offset) { _, action in - Button(action.text.fallback) { + Button(IosTaskTextResolver.resolve(action.text)) { perform(action: action, task: nil, screen: screen) } .buttonStyle(.borderless) @@ -186,20 +184,20 @@ struct ContentView: View { Button { presentPicker(.audioFiles) } label: { - Label("Import Audio Files", systemImage: "square.and.arrow.down") + Label(L10n.text("catalog.importAudio", fallback: "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") + Label(L10n.text("catalog.refreshFolder", fallback: "Refresh Audio Folder"), systemImage: "arrow.clockwise") } .disabled(importStore.isScanningFolder) } } } - .navigationTitle("Voice Inbox") + .navigationTitle(L10n.text("app.title", fallback: "Voice Inbox")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { @@ -220,7 +218,7 @@ struct ContentView: View { }, installModelPackage: { guard !transcriber.isActive else { - speechModelStore.message = "Wait for transcription to finish before replacing the speech model." + speechModelStore.message = L10n.text("model.waitForTranscription", fallback: "Wait for transcription to finish before replacing the speech model.") return } presentPicker(.speechModelFolder) @@ -229,7 +227,7 @@ struct ContentView: View { } label: { Image(systemName: "gearshape") } - .accessibilityLabel("Settings") + .accessibilityLabel(L10n.text("settings.title", fallback: "Settings")) } } .sheet(item: $presentedPicker) { picker in @@ -314,7 +312,7 @@ struct ContentView: View { Alert( title: Text(message.title), message: Text(message.text), - dismissButton: .default(Text("OK")) + dismissButton: .default(Text(L10n.text("common.ok", fallback: "OK"))) ) } } @@ -562,15 +560,15 @@ struct ContentView: View { private func startStartupProcessing() { guard importStore.pendingCount > 0 else { return } guard !transcriber.isActive else { - importStore.importMessage = "Found files to process, but transcription is already running." + importStore.importMessage = L10n.text("startup.alreadyRunning", fallback: "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." + importStore.importMessage = L10n.text("startup.backendUnavailable", fallback: "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." + importStore.importMessage = L10n.text("startup.modelNotReady", fallback: "Found files to process, but the speech model is not ready.") return } let outputDocument = outputStore.currentDocument() @@ -588,6 +586,16 @@ struct ContentView: View { ) } + private func modelSetupTask(in screen: IosTaskListScreen) -> SetupTaskPresentation? { + for task in screen.state.tasks { + guard let setupTask = task as? SetupTaskPresentation else { continue } + if setupTask.kind == .model { + return setupTask + } + } + return nil + } + } private enum IosPresentedPicker: String, Identifiable { @@ -610,16 +618,16 @@ private struct TaskListRow: View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { - Text(task.title.fallback) + Text(IosTaskTextResolver.resolve(task.title)) .font(.headline) if let detail = task.detail, !detail.fallback.isEmpty { - Text(detail.fallback) + Text(IosTaskTextResolver.resolve(detail)) .font(.subheadline) .foregroundStyle(.secondary) } } Spacer() - Text(task.badge.fallback) + Text(IosTaskTextResolver.resolve(task.badge)) .font(.caption) .padding(.horizontal, 8) .padding(.vertical, 4) @@ -632,13 +640,13 @@ private struct TaskListRow: View { Image(systemName: "xmark") } .buttonStyle(.borderless) - .accessibilityLabel("Hide automatic transcript export") + .accessibilityLabel(L10n.text("catalog.hideExport", fallback: "Hide automatic transcript export")) .accessibilityIdentifier("task-dismiss-\(task.stableId)") } } if let error = task.errorMessage, !error.fallback.isEmpty { - Text(error.fallback) + Text(IosTaskTextResolver.resolve(error)) .font(.footnote) .foregroundStyle(.red) } @@ -661,7 +669,7 @@ private struct TaskListRow: View { if !visibleActions.isEmpty { HStack { ForEach(Array(visibleActions.enumerated()), id: \.offset) { _, action in - Button(action.text.fallback) { onAction(action) } + Button(IosTaskTextResolver.resolve(action.text)) { onAction(action) } .disabled(!action.enabled) .accessibilityIdentifier("task-action-\(task.stableId)-\(action.kind.name.lowercased())") } @@ -674,17 +682,18 @@ private struct TaskListRow: View { } private func progressLabel(_ progress: TaskProgressPresentation) -> String { - var parts = [progress.phase.fallback] + var parts = [IosTaskTextResolver.resolve(progress.phase)] if let completed = progress.completedFiles?.int32Value, let total = progress.totalFiles?.int32Value, total > 0 { - parts.append("\(completed) / \(total) files") + parts.append(L10n.format("task.progressFiles", fallback: "%d / %d files", completed, total)) } if let failed = progress.failedFiles?.int32Value, failed > 0 { - parts.append("\(failed) failed") + parts.append(L10n.format("task.progressFailed", fallback: "%d failed", failed)) } return parts.joined(separator: " • ") } + } private struct IosGlobalMessage: Identifiable { @@ -709,7 +718,7 @@ private struct StartupProcessingPromptView: View { NavigationStack { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 8) { - Text("Found files to process. Process now?") + Text(L10n.text("startup.prompt", fallback: "Found files to process. Process now?")) .font(.title3) .fontWeight(.semibold) Text(summary) @@ -721,15 +730,15 @@ private struct StartupProcessingPromptView: View { Spacer() } .padding() - .navigationTitle("Process Files") + .navigationTitle(L10n.text("startup.processFiles", fallback: "Process Files")) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("No") { + Button(L10n.text("settings.startup.no", fallback: "No")) { onNo(alwaysAtStartup) } } ToolbarItem(placement: .confirmationAction) { - Button("Yes") { + Button(L10n.text("settings.startup.yes", fallback: "Yes")) { onYes(alwaysAtStartup) } .buttonStyle(.borderedProminent) @@ -743,6 +752,6 @@ private struct StartupProcessingPromptView: View { if pendingCount == 1 { return "1 file is waiting in New." } - return "\(pendingCount) files are waiting in New." + return L10n.plural("startup.filesWaiting", count: pendingCount, fallback: "%d files are waiting in New.") } } diff --git a/iosApp/VoiceInbox/IosAudioDocumentPicker.swift b/iosApp/VoiceInbox/IosAudioDocumentPicker.swift index e61a509..6cac4b2 100644 --- a/iosApp/VoiceInbox/IosAudioDocumentPicker.swift +++ b/iosApp/VoiceInbox/IosAudioDocumentPicker.swift @@ -156,7 +156,8 @@ struct IosOutputDocumentStatus { let ready: Bool var title: String { - displayName.map { "Output: \($0)" } ?? "Output file not selected" + displayName.map { L10n.format("output.selected", fallback: "Output: %@", $0) } + ?? L10n.text("output.noneSelected", fallback: "Output file not selected") } } @@ -164,7 +165,7 @@ struct IosOutputDocumentStatus { final class IosOutputDocumentStore: ObservableObject { @Published private(set) var status = IosOutputDocumentStatus( displayName: nil, - message: "Choose a text or Markdown file for transcripts.", + message: L10n.text("output.choose", fallback: "Choose a text or Markdown file for transcripts."), ready: false ) @@ -193,7 +194,7 @@ final class IosOutputDocumentStore: ObservableObject { guard Self.isSupportedOutput(url) else { status = IosOutputDocumentStatus( displayName: url.lastPathComponent, - message: "Choose a .txt, .md, or .markdown file.", + message: L10n.text("output.unsupported", fallback: "Choose a .txt, .md, or .markdown file."), ready: false ) return @@ -223,14 +224,14 @@ final class IosOutputDocumentStore: ObservableObject { ) status = IosOutputDocumentStatus( displayName: url.lastPathComponent, - message: "Ready to append transcripts.", + message: L10n.text("output.ready", fallback: "Ready to append transcripts."), ready: true ) } catch { selectedDocument = nil status = IosOutputDocumentStatus( displayName: url.lastPathComponent, - message: "Output file is not writable. Choose another file.", + message: L10n.text("output.notWritable", fallback: "Output file is not writable. Choose another file."), ready: false ) } @@ -247,7 +248,7 @@ final class IosOutputDocumentStore: ObservableObject { selectedDocument = nil status = IosOutputDocumentStatus( displayName: nil, - message: "Automatic export is off. Transcripts stay in Voice Inbox.", + message: L10n.text("output.disabled", fallback: "Automatic export is off. Transcripts stay in Voice Inbox."), ready: false ) } @@ -262,7 +263,7 @@ final class IosOutputDocumentStore: ObservableObject { selectedDocument = nil status = IosOutputDocumentStatus( displayName: nil, - message: "Choose a text or Markdown file for transcripts.", + message: L10n.text("output.choose", fallback: "Choose a text or Markdown file for transcripts."), ready: false ) return @@ -281,7 +282,7 @@ final class IosOutputDocumentStore: ObservableObject { selectedDocument = nil status = IosOutputDocumentStatus( displayName: savedName, - message: "Output access needs to be refreshed. Choose the file again.", + message: L10n.text("output.refreshAccess", fallback: "Output access needs to be refreshed. Choose the file again."), ready: false ) return @@ -294,14 +295,14 @@ final class IosOutputDocumentStore: ObservableObject { ) status = IosOutputDocumentStatus( displayName: url.lastPathComponent, - message: "Ready to append transcripts.", + message: L10n.text("output.ready", fallback: "Ready to append transcripts."), ready: true ) } catch { selectedDocument = nil status = IosOutputDocumentStatus( displayName: savedName, - message: "Could not write to the selected output file. Choose it again.", + message: L10n.text("output.writeFailed", fallback: "Could not write to the selected output file. Choose it again."), ready: false ) } @@ -335,12 +336,12 @@ final class IosOutputDocumentStore: ObservableObject { } try handle.seekToEnd() guard let data = text.data(using: .utf8) else { - return "Transcript text could not be encoded as UTF-8." + return L10n.text("output.encodingFailed", fallback: "Transcript text could not be encoded as UTF-8.") } try handle.write(contentsOf: data) return nil } catch { - return "Could not append to the selected output file." + return L10n.text("output.appendFailed", fallback: "Could not append to the selected output file.") } } diff --git a/iosApp/VoiceInbox/IosAudioImportStore.swift b/iosApp/VoiceInbox/IosAudioImportStore.swift index f1d6409..2d82fce 100644 --- a/iosApp/VoiceInbox/IosAudioImportStore.swift +++ b/iosApp/VoiceInbox/IosAudioImportStore.swift @@ -43,13 +43,13 @@ enum IosImportedAudioStatus: String, Codable { var badge: String { switch self { case .pending: - "New" + L10n.text("catalog.new", fallback: "New") case .processing: - "Processing" + L10n.text("task.processing", fallback: "Processing") case .processed: - "Processed" + L10n.text("catalog.processed", fallback: "Processed") case .failed: - "Failed" + L10n.text("task.failed", fallback: "Failed") } } } @@ -130,7 +130,7 @@ struct IosAudioImportSummary { if imported > 0 { parts.append("\(imported) imported") } if skipped > 0 { parts.append("\(skipped) skipped") } if failed > 0 { parts.append("\(failed) failed") } - return parts.isEmpty ? "No files imported" : parts.joined(separator: ", ") + return parts.isEmpty ? L10n.text("import.none", fallback: "No files imported") : parts.joined(separator: ", ") } var alertMessage: String? { @@ -161,7 +161,7 @@ struct IosInboxFolderStatus { } var title: String { - displayName ?? "No audio folder selected" + displayName ?? L10n.text("folder.noneSelected", fallback: "No audio folder selected") } } @@ -170,7 +170,7 @@ final class IosAudioImportStore: ObservableObject { @Published private(set) var files: [IosImportedAudioFile] = [] @Published private(set) var inboxFolderStatus = IosInboxFolderStatus( displayName: nil, - message: "Choose a folder to use it as your audio inbox.", + message: L10n.text("folder.choose", fallback: "Choose a folder to use it as your audio inbox."), needsSelection: true ) @Published private(set) var isScanningFolder = false diff --git a/iosApp/VoiceInbox/IosAudioPreviewPlayer.swift b/iosApp/VoiceInbox/IosAudioPreviewPlayer.swift index 3d5d8ab..3f7878a 100644 --- a/iosApp/VoiceInbox/IosAudioPreviewPlayer.swift +++ b/iosApp/VoiceInbox/IosAudioPreviewPlayer.swift @@ -37,14 +37,14 @@ final class IosAudioPreviewPlayer: NSObject, ObservableObject, AVAudioPlayerDele player.delegate = self player.prepareToPlay() guard player.play() else { - errorMessage = "Could not start audio playback." + errorMessage = L10n.text("playback.couldNotStart", fallback: "Could not start audio playback.") return } self.player = player playingFileId = fileId errorMessage = nil } catch { - errorMessage = "Could not play \(url.lastPathComponent): \(error.localizedDescription)" + errorMessage = L10n.format("playback.couldNotPlay", fallback: "Could not play %@: %@", url.lastPathComponent, error.localizedDescription) } } @@ -73,7 +73,7 @@ final class IosAudioPreviewPlayer: NSObject, ObservableObject, AVAudioPlayerDele self.deactivateAudioSession() } if !flag { - self.errorMessage = "Playback stopped before the file finished." + self.errorMessage = L10n.text("playback.stoppedEarly", fallback: "Playback stopped before the file finished.") } } } @@ -85,7 +85,7 @@ final class IosAudioPreviewPlayer: NSObject, ObservableObject, AVAudioPlayerDele self.playingFileId = nil self.deactivateAudioSession() } - self.errorMessage = error?.localizedDescription ?? "Could not decode audio for playback." + self.errorMessage = error?.localizedDescription ?? L10n.text("playback.decodeFailed", fallback: "Could not decode audio for playback.") } } } diff --git a/iosApp/VoiceInbox/IosInlineOnboarding.swift b/iosApp/VoiceInbox/IosInlineOnboarding.swift index 518009f..bce6cfb 100644 --- a/iosApp/VoiceInbox/IosInlineOnboarding.swift +++ b/iosApp/VoiceInbox/IosInlineOnboarding.swift @@ -66,8 +66,10 @@ struct IosOnboardingChecklistStep: Identifiable { var id: String { kind.rawValue } var accessibilityLabel: String { - let state = complete ? "Completed" : "Not completed" - let optionalSuffix = optional ? ", optional" : "" + let state = complete + ? L10n.text("onboarding.completed", fallback: "Completed") + : L10n.text("onboarding.notCompleted", fallback: "Not completed") + let optionalSuffix = optional ? ", \(L10n.text("onboarding.optional", fallback: "optional"))" : "" return "\(state): \(label)\(optionalSuffix)" } } @@ -91,8 +93,8 @@ struct IosOnboardingHintPresentation { init( visible: Bool = false, - title: String = "Set up Voice Inbox", - explanation: String = "Follow these steps, or use the setup tasks above in any order.", + title: String = L10n.text("onboarding.title", fallback: "Set up Voice Inbox"), + explanation: String = L10n.text("onboarding.explanation", fallback: "Follow these steps, or use the setup tasks above in any order."), downloadDisclosure: String? = nil, steps: [IosOnboardingChecklistStep] = [], action: IosOnboardingHintAction? = nil @@ -127,24 +129,24 @@ enum IosOnboardingHintPresenter { return IosOnboardingHintPresentation( visible: true, downloadDisclosure: networkActions.contains { $0 == action.kind } && action.enabled - ? "This action downloads the speech model to this device." + ? L10n.text("onboarding.downloadDisclosure", fallback: "This action downloads the speech model to this device.") : nil, steps: [ IosOnboardingChecklistStep( kind: .model, - label: "Install speech model", + label: L10n.text("onboarding.installModel", fallback: "Install speech model"), complete: model.state == .ready, optional: false ), IosOnboardingChecklistStep( kind: .output, - label: "Configure automatic transcript export", + label: L10n.text("onboarding.configureExport", fallback: "Configure automatic transcript export"), complete: output.state == .ready, optional: true ), IosOnboardingChecklistStep( kind: .folder, - label: "Select audio folder", + label: L10n.text("onboarding.selectFolder", fallback: "Select audio folder"), complete: folder.state == .ready, optional: true ), @@ -180,7 +182,7 @@ enum IosOnboardingHintPresenter { ) -> IosOnboardingHintAction { if model.state == .installing { return IosOnboardingHintAction( - label: "Installing speech model…", + label: L10n.text("onboarding.installingModel", fallback: "Installing speech model…"), enabled: false, kind: .downloadModel ) @@ -188,27 +190,29 @@ enum IosOnboardingHintPresenter { if model.state != .ready, model.downloadAvailable { let retry = model.state == .invalid return IosOnboardingHintAction( - label: retry ? "Retry setup" : "Start setup", + label: retry + ? L10n.text("onboarding.retrySetup", fallback: "Retry setup") + : L10n.text("onboarding.startSetup", fallback: "Start setup"), enabled: true, kind: retry ? .retryModelDownload : .downloadModel ) } if model.state != .ready { return IosOnboardingHintAction( - label: "Install model from folder", + label: L10n.text("onboarding.installModelFromFolder", fallback: "Install model from folder"), enabled: true, kind: .importModel ) } if output.state != .ready { return IosOnboardingHintAction( - label: "Select Output File", + label: L10n.text("onboarding.selectOutput", fallback: "Select Output File"), enabled: true, kind: .selectOutput ) } return IosOnboardingHintAction( - label: "Select audio folder (optional)", + label: L10n.text("onboarding.selectOptionalFolder", fallback: "Select audio folder (optional)"), enabled: folder.state != .scanning, kind: .selectFolder ) @@ -251,7 +255,7 @@ struct IosInlineOnboardingRow: View { Image(systemName: "xmark") } .buttonStyle(.plain) - .accessibilityLabel("Dismiss setup guide") + .accessibilityLabel(L10n.text("onboarding.dismiss", fallback: "Dismiss setup guide")) .accessibilityIdentifier("onboarding-dismiss") } @@ -271,7 +275,7 @@ struct IosInlineOnboardingRow: View { HStack(alignment: .firstTextBaseline, spacing: 8) { Image(systemName: step.complete ? "checkmark.circle.fill" : "circle") .foregroundStyle(step.complete ? .green : .secondary) - Text(step.optional ? "\(step.label) · Optional" : step.label) + Text(step.optional ? "\(step.label) · \(L10n.text("onboarding.optionalLabel", fallback: "Optional"))" : step.label) } .accessibilityElement(children: .ignore) .accessibilityLabel(step.accessibilityLabel) diff --git a/iosApp/VoiceInbox/IosLocalization.swift b/iosApp/VoiceInbox/IosLocalization.swift new file mode 100644 index 0000000..4754ad4 --- /dev/null +++ b/iosApp/VoiceInbox/IosLocalization.swift @@ -0,0 +1,70 @@ +import Foundation +import Shared + +enum L10n { + static func text(_ key: String, fallback: String) -> String { + NSLocalizedString(key, value: fallback, comment: "") + } + + static func format(_ key: String, fallback: String, _ arguments: CVarArg...) -> String { + String(format: text(key, fallback: fallback), locale: .current, arguments: arguments) + } + + static func plural(_ key: String, count: Int, fallback: String) -> String { + format("\(key).\(pluralForm(for: count, languageCode: Locale.current.language.languageCode?.identifier))", fallback: fallback, count) + } + + static func pluralForm(for count: Int, languageCode: String?) -> String { + if languageCode == "ru" { + let lastTwo = count % 100 + let last = count % 10 + return (last == 1 && lastTwo != 11) ? "one" : + ((2...4).contains(last) && !(12...14).contains(lastTwo) ? "few" : "many") + } + return count == 1 ? "one" : "other" + } +} + +enum IosTaskTextResolver { + static func resolve(_ text: TaskText) -> String { + switch text.key.name { + case "INSTALL_SPEECH_MODEL": return L10n.text("task.installSpeechModel", fallback: text.fallback) + case "AUTOMATIC_TRANSCRIPT_EXPORT": return L10n.text("task.automaticTranscriptExport", fallback: text.fallback) + case "OUTPUT_OPTIONAL_DETAIL": return L10n.text("task.outputOptionalDetail", fallback: text.fallback) + case "REFRESH_AUDIO_FOLDER": return L10n.text("task.refreshAudioFolder", fallback: text.fallback) + case "RESTORE_AUDIO_FOLDER_ACCESS": return L10n.text("task.restoreAudioFolderAccess", fallback: text.fallback) + case "REQUIRED": return L10n.text("task.required", fallback: text.fallback) + case "OPTIONAL": return L10n.text("task.optional", fallback: text.fallback) + case "INSTALLING": return L10n.text("task.installing", fallback: text.fallback) + case "SCANNING": return L10n.text("task.scanning", fallback: text.fallback) + case "NEEDS_ATTENTION": return L10n.text("task.needsAttention", fallback: text.fallback) + case "NEW": return L10n.text("task.new", fallback: text.fallback) + case "PROCESSING": return L10n.text("task.processing", fallback: text.fallback) + case "PROCESSED": return L10n.text("task.processed", fallback: text.fallback) + case "FAILED": return L10n.text("task.failed", fallback: text.fallback) + case "NO_SPEECH": return L10n.text("task.noSpeech", fallback: text.fallback) + case "CANCEL": return L10n.text("task.cancel", fallback: text.fallback) + case "RETRY_DOWNLOAD": return L10n.text("task.retryDownload", fallback: text.fallback) + case "DOWNLOAD": return L10n.text("task.download", fallback: text.fallback) + case "CREATE_NEW": return L10n.text("task.createNew", fallback: text.fallback) + case "CHOOSE_EXISTING": return L10n.text("task.chooseExisting", fallback: text.fallback) + case "HIDE": return L10n.text("task.hide", fallback: text.fallback) + case "SELECT_FOLDER": return L10n.text("task.selectFolder", fallback: text.fallback) + case "RETRY": return L10n.text("task.retry", fallback: text.fallback) + case "TRANSCRIBE": return L10n.text("task.transcribe", fallback: text.fallback) + case "SHOW_TEXT": return L10n.text("task.showText", fallback: text.fallback) + case "STOP": return L10n.text("task.stop", fallback: text.fallback) + case "PLAY": return L10n.text("task.play", fallback: text.fallback) + case "IMPORT_AUDIO_FILES": return L10n.text("task.importAudioFiles", fallback: text.fallback) + case "SELECT_AUDIO_FOLDER": return L10n.text("task.selectAudioFolder", fallback: text.fallback) + case "NO_NEW_TASKS": return L10n.text("task.noNewTasks", fallback: text.fallback) + case "NO_PROCESSED_AUDIO": return L10n.text("task.noProcessedAudio", fallback: text.fallback) + case "NO_TASKS": return L10n.text("task.noTasks", fallback: text.fallback) + case "INSTALLING_MODEL": return L10n.text("task.installingModel", fallback: text.fallback) + case "SCANNING_AUDIO_FOLDER": return L10n.text("task.scanningAudioFolder", fallback: text.fallback) + case "PREPARING_SPEECH_MODEL": return L10n.text("task.preparingSpeechModel", fallback: text.fallback) + case "CHOOSE_MODEL": return L10n.text("task.chooseModel", fallback: text.fallback) + default: return text.fallback + } + } +} diff --git a/iosApp/VoiceInbox/IosMainScreenShellState.swift b/iosApp/VoiceInbox/IosMainScreenShellState.swift index 900c1ea..08c5e5e 100644 --- a/iosApp/VoiceInbox/IosMainScreenShellState.swift +++ b/iosApp/VoiceInbox/IosMainScreenShellState.swift @@ -10,9 +10,9 @@ enum IosShellCatalogSelection: String, CaseIterable, Identifiable { var title: String { switch self { - case .new: "New" - case .processed: "Processed" - case .all: "All" + case .new: L10n.text("catalog.new", fallback: "New") + case .processed: L10n.text("catalog.processed", fallback: "Processed") + case .all: L10n.text("catalog.all", fallback: "All") } } diff --git a/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift b/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift index 3192427..34cd573 100644 --- a/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift +++ b/iosApp/VoiceInbox/IosSingleFileTranscriptionController.swift @@ -87,7 +87,7 @@ final class IosSingleFileTranscriptionController: ObservableObject { @Published var message: String? private var task: Task? - static let backendUnavailableMessage = "iOS transcription backend is not configured yet." + static let backendUnavailableMessage = L10n.text("transcription.backendUnavailable", fallback: "iOS transcription backend is not configured yet.") var activeFileId: Int64? { state.active ? state.fileId : nil @@ -117,7 +117,7 @@ final class IosSingleFileTranscriptionController: ObservableObject { active: true, fileId: file.id, fileName: file.displayName, - phase: "Preparing speech model", + phase: L10n.text("transcription.preparingModel", fallback: "Preparing speech model"), processedUs: 0, durationUs: 0, completedFiles: 0, @@ -134,7 +134,7 @@ final class IosSingleFileTranscriptionController: ObservableObject { claim: { store.markProcessing(fileId: file.id) } ) guard prepared else { - let error = modelStore.message ?? "Speech model preparation failed." + let error = modelStore.message ?? L10n.text("transcription.modelPreparationFailed", fallback: "Speech model preparation failed.") prerequisiteError = error message = error state = .idle @@ -183,10 +183,10 @@ final class IosSingleFileTranscriptionController: ObservableObject { transcriptText: result.transcriptText, durationUs: result.durationUs?.int64Value ) - message = "Transcribed \(file.displayName)" + message = L10n.format("transcription.transcribed", fallback: "Transcribed %@", file.displayName) onSuccess?(result.transcriptText) } else { - let error = outcome.errorMessage ?? "iOS transcription failed." + let error = outcome.errorMessage ?? L10n.text("transcription.failed", fallback: "iOS transcription failed.") store.markFailed(fileId: file.id, error: error) message = error } @@ -217,7 +217,7 @@ final class IosSingleFileTranscriptionController: ObservableObject { active: true, fileId: nil, fileName: nil, - phase: "Preparing speech model", + phase: L10n.text("transcription.preparingModel", fallback: "Preparing speech model"), processedUs: 0, durationUs: 0, completedFiles: 0, @@ -230,7 +230,7 @@ final class IosSingleFileTranscriptionController: ObservableObject { task = Task { guard await modelStore.prepareForTranscription() != nil else { - let error = modelStore.message ?? "Speech model preparation failed." + let error = modelStore.message ?? L10n.text("transcription.modelPreparationFailed", fallback: "Speech model preparation failed.") prerequisiteError = error message = error state = .idle diff --git a/iosApp/VoiceInbox/IosSpeechModelStore.swift b/iosApp/VoiceInbox/IosSpeechModelStore.swift index cadedf3..b677a82 100644 --- a/iosApp/VoiceInbox/IosSpeechModelStore.swift +++ b/iosApp/VoiceInbox/IosSpeechModelStore.swift @@ -111,9 +111,9 @@ struct IosSpeechModelStatus { var summary: String { if isReady { - return "Speech model installed" + return L10n.text("model.installed", fallback: "Speech model installed") } - return "Speech model is not installed" + return L10n.text("model.notInstalled", fallback: "Speech model is not installed") } var detail: String? { @@ -123,7 +123,7 @@ struct IosSpeechModelStatus { guard !missingFiles.isEmpty else { return nil } - return "Missing: \(missingFiles.joined(separator: ", "))" + return L10n.format("model.missing", fallback: "Missing: %@", missingFiles.joined(separator: ", ")) } } @@ -321,7 +321,7 @@ final class IosSpeechModelStore: ObservableObject { replacementAllowed: Bool = true ) { guard replacementAllowed else { - message = "Wait for the current transcription to finish before replacing the speech model." + message = L10n.text("model.waitForCurrentTranscription", fallback: "Wait for the current transcription to finish before replacing the speech model.") return } guard let candidate = confirmedCandidate ?? pendingCandidate, !isBusy else { return } @@ -333,7 +333,7 @@ final class IosSpeechModelStore: ObservableObject { isInstalling = true installationError = nil downloadProgress = nil - message = "Installing \(candidate.descriptor.displayName)..." + message = L10n.format("model.installing", fallback: "Installing %@...", candidate.descriptor.displayName) Task { let result = await Task.detached(priority: .userInitiated) { @@ -378,18 +378,18 @@ final class IosSpeechModelStore: ObservableObject { catalogId: descriptor.catalogId, modelVersion: descriptor.modelVersion )?.networkDownloadAvailable == true else { - message = "Selected speech model is not available for download." + message = L10n.text("model.notAvailableForDownload", fallback: "Selected speech model is not available for download.") return } guard descriptor.networkDownloadAvailable else { - message = "Network download is not available for this model." + message = L10n.text("model.networkUnavailable", fallback: "Network download is not available for this model.") return } isInstalling = true - message = "Preparing speech model download..." + message = L10n.text("model.preparingDownload", fallback: "Preparing speech model download...") downloadProgress = IosSpeechModelDownloadProgress( - message: "Preparing speech model download...", + message: L10n.text("model.preparingDownload", fallback: "Preparing speech model download..."), bytesDownloaded: 0, totalBytes: descriptor.totalSizeBytes ) @@ -422,7 +422,7 @@ final class IosSpeechModelStore: ObservableObject { downloadTask = nil isInstalling = false downloadProgress = nil - message = "Speech model download cancelled." + message = L10n.text("model.downloadCancelled", fallback: "Speech model download cancelled.") } func prepareForTranscription() async -> URL? { @@ -432,7 +432,7 @@ final class IosSpeechModelStore: ObservableObject { task = existing } else { runtimeState = .loading - message = "Preparing speech model..." + message = L10n.text("model.preparing", fallback: "Preparing speech model...") let directory = modelDirectory let validateInstallation = validateInstallation let prepareNative = prepareNative @@ -455,7 +455,7 @@ final class IosSpeechModelStore: ObservableObject { directory: nil, invalidInstallation: false, message: nativeError() - ?? "Speech model failed to load." + ?? L10n.text("model.loadFailed", fallback: "Speech model failed to load.") ) } recordVerified() diff --git a/iosApp/VoiceInbox/IosTranscriptReview.swift b/iosApp/VoiceInbox/IosTranscriptReview.swift index 4226ad5..e83b11e 100644 --- a/iosApp/VoiceInbox/IosTranscriptReview.swift +++ b/iosApp/VoiceInbox/IosTranscriptReview.swift @@ -43,15 +43,15 @@ struct IosTranscriptViewer: View { NavigationStack { IosSelectableTranscriptTextView( text: transcript.text, - accessibilityLabel: "Transcript text for \(transcript.filename)" + accessibilityLabel: L10n.format("transcript.textAccessibility", fallback: "Transcript text for %@", transcript.filename) ) .accessibilityIdentifier("transcript-text") .navigationTitle(transcript.filename) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Done", action: onDismiss) - .accessibilityLabel("Close transcript") + Button(L10n.text("transcript.done", fallback: "Done"), action: onDismiss) + .accessibilityLabel(L10n.text("transcript.close", fallback: "Close transcript")) .accessibilityIdentifier("transcript-close") } ToolbarItem(placement: .primaryAction) { @@ -59,9 +59,9 @@ struct IosTranscriptViewer: View { item: transcript.shareText, subject: Text(transcript.shareSubject) ) { - Label("Share", systemImage: "square.and.arrow.up") + Label(L10n.text("transcript.share", fallback: "Share"), systemImage: "square.and.arrow.up") } - .accessibilityLabel("Share transcript") + .accessibilityLabel(L10n.text("transcript.shareAccessibility", fallback: "Share transcript")) .accessibilityIdentifier("transcript-share") } } diff --git a/iosApp/VoiceInbox/Localizable.xcstrings b/iosApp/VoiceInbox/Localizable.xcstrings new file mode 100644 index 0000000..727cb49 --- /dev/null +++ b/iosApp/VoiceInbox/Localizable.xcstrings @@ -0,0 +1,2500 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "%@ · %@" : { + "comment" : "A description of a speech model, including the language and maturity level.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ · %2$@" + } + } + } + }, + "%@ · %@ download · %@ free" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ · %2$@ download · %3$@ free" + } + } + } + }, + "Always do this at startup" : { + "comment" : "A toggle that allows the user to choose whether to always process files at startup.", + "isCommentAutoGenerated" : true + }, + "app.title" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voice Inbox" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voice Inbox" + } + } + } + }, + "Available" : { + "comment" : "A label describing an action that is currently available.", + "isCommentAutoGenerated" : true + }, + "Catalog" : { + + }, + "catalog.all" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Все" + } + } + } + }, + "catalog.hideExport" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hide automatic transcript export" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть автоматический экспорт расшифровок" + } + } + } + }, + "catalog.importAudio" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Import Audio Files" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Импортировать аудиофайлы" + } + } + } + }, + "catalog.new" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Новые" + } + } + } + }, + "catalog.processed" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Processed" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обработанные" + } + } + } + }, + "catalog.refreshFolder" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refresh Audio Folder" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновить папку с аудио" + } + } + } + }, + "catalog.transcribeAll.few" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transcribe All (%d)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расшифровать (%d файла)" + } + } + } + }, + "catalog.transcribeAll.many" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transcribe All (%d)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расшифровать (%d файлов)" + } + } + } + }, + "catalog.transcribeAll.one" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transcribe All (%d)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расшифровать (%d файл)" + } + } + } + }, + "catalog.transcribeAll.other" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transcribe All (%d)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расшифровать (%d файлов)" + } + } + } + }, + "common.cancel" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + } + } + }, + "common.download" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скачать" + } + } + } + }, + "common.install" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Install" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установить" + } + } + } + }, + "common.ok" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ОК" + } + } + } + }, + "Detected Speech Model" : { + + }, + "Download Speech Model" : { + + }, + "folder.choose" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a folder to use it as your audio inbox." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите папку, которая будет использоваться для входящих аудио." + } + } + } + }, + "folder.noneSelected" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No audio folder selected" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Папка с аудио не выбрана" + } + } + } + }, + "import.none" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No files imported" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Файлы не импортированы" + } + } + } + }, + "Model" : { + + }, + "model.downloadCancelled" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speech model download cancelled." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка речевой модели отменена." + } + } + } + }, + "model.downloadReplacementWarning" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloading this model replaces the currently installed model." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка этой модели заменит текущую модель." + } + } + } + }, + "model.installed" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speech model installed" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Речевая модель установлена" + } + } + } + }, + "model.installing" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installing %@..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установка %@..." + } + } + } + }, + "model.loadFailed" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speech model failed to load." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось загрузить речевую модель." + } + } + } + }, + "model.missing" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Missing: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отсутствует: %@" + } + } + } + }, + "model.networkUnavailable" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Network download is not available for this model." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сетевая загрузка недоступна для этой модели." + } + } + } + }, + "model.notAvailableForDownload" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selected speech model is not available for download." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбранная речевая модель недоступна для загрузки." + } + } + } + }, + "model.notInstalled" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speech model is not installed" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Речевая модель не установлена" + } + } + } + }, + "model.preparing" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preparing speech model..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подготовка речевой модели..." + } + } + } + }, + "model.preparingDownload" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preparing speech model download..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подготовка загрузки речевой модели..." + } + } + } + }, + "model.replacementWarning" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installing this model replaces the currently installed model." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установка этой модели заменит текущую модель." + } + } + } + }, + "model.waitForCurrentTranscription" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wait for the current transcription to finish before replacing the speech model." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дождитесь окончания текущей расшифровки перед заменой речевой модели." + } + } + } + }, + "model.waitForTranscription" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wait for transcription to finish before replacing the speech model." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дождитесь окончания расшифровки перед заменой речевой модели." + } + } + } + }, + "onboarding.completed" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completed" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выполнено" + } + } + } + }, + "onboarding.configureExport" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configure automatic transcript export" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройте автоматический экспорт расшифровок" + } + } + } + }, + "onboarding.dismiss" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dismiss setup guide" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть подсказку по настройке" + } + } + } + }, + "onboarding.downloadDisclosure" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This action downloads the speech model to this device." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это действие загрузит речевую модель на устройство." + } + } + } + }, + "onboarding.explanation" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Follow these steps, or use the setup tasks above in any order." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пройдите эти шаги или выполните задачи настройки выше в любом порядке." + } + } + } + }, + "onboarding.installingModel" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installing speech model…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установка речевой модели…" + } + } + } + }, + "onboarding.installModel" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Install speech model" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установите речевую модель" + } + } + } + }, + "onboarding.installModelFromFolder" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Install model from folder" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установить модель из папки" + } + } + } + }, + "onboarding.notCompleted" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not completed" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не выполнено" + } + } + } + }, + "onboarding.optional" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "optional" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "необязательно" + } + } + } + }, + "onboarding.optionalLabel" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optional" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Необязательно" + } + } + } + }, + "onboarding.retrySetup" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retry setup" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторить настройку" + } + } + } + }, + "onboarding.selectFolder" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select audio folder" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите папку с аудио" + } + } + } + }, + "onboarding.selectOptionalFolder" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select audio folder (optional)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать папку с аудио (необязательно)" + } + } + } + }, + "onboarding.selectOutput" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select Output File" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать файл вывода" + } + } + } + }, + "onboarding.startSetup" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start setup" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Начать настройку" + } + } + } + }, + "onboarding.title" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set up Voice Inbox" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройте Voice Inbox" + } + } + } + }, + "output.appendFailed" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not append to the selected output file." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось добавить текст в выбранный файл вывода." + } + } + } + }, + "output.choose" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a text or Markdown file for transcripts." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите текстовый или Markdown-файл для расшифровок." + } + } + } + }, + "output.disabled" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatic export is off. Transcripts stay in Voice Inbox." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматический экспорт выключен. Расшифровки остаются в Voice Inbox." + } + } + } + }, + "output.encodingFailed" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transcript text could not be encoded as UTF-8." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось закодировать текст расшифровки в UTF-8." + } + } + } + }, + "output.noneSelected" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Output file not selected" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Файл вывода не выбран" + } + } + } + }, + "output.notWritable" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Output file is not writable. Choose another file." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В файл вывода нельзя записывать. Выберите другой файл." + } + } + } + }, + "output.ready" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ready to append transcripts." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Готово к добавлению расшифровок." + } + } + } + }, + "output.refreshAccess" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Output access needs to be refreshed. Choose the file again." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступ к файлу вывода нужно обновить. Выберите файл снова." + } + } + } + }, + "output.selected" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Output: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вывод: %@" + } + } + } + }, + "output.unsupported" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a .txt, .md, or .markdown file." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите файл .txt, .md или .markdown." + } + } + } + }, + "output.writeFailed" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not write to the selected output file. Choose it again." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось записать в выбранный файл вывода. Выберите его снова." + } + } + } + }, + "playback.couldNotPlay" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not play %@: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось воспроизвести %@: %@" + } + } + } + }, + "playback.couldNotStart" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not start audio playback." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось начать воспроизведение аудио." + } + } + } + }, + "playback.decodeFailed" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not decode audio for playback." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось декодировать аудио для воспроизведения." + } + } + } + }, + "playback.stoppedEarly" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Playback stopped before the file finished." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Воспроизведение остановилось до окончания файла." + } + } + } + }, + "settings.about" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "About" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "О приложении" + } + } + } + }, + "settings.audioInbox" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Audio inbox folder" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Папка входящих аудио" + } + } + } + }, + "settings.changeAudioFolder" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change Audio Folder" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменить папку с аудио" + } + } + } + }, + "settings.changeOutput" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change Output File" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменить файл вывода" + } + } + } + }, + "settings.documentation" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Documentation" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Документация" + } + } + } + }, + "settings.doNotExport" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Do not export" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не экспортировать" + } + } + } + }, + "settings.installModel" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Install model package from folder" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установить пакет модели из папки" + } + } + } + }, + "settings.legal" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legal information" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Правовая информация" + } + } + } + }, + "settings.noModel" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No speech model installed" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Речевая модель не установлена" + } + } + } + }, + "settings.outputFile" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transcript output file" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Файл вывода расшифровок" + } + } + } + }, + "settings.selectAudioFolder" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select Audio Folder" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать папку с аудио" + } + } + } + }, + "settings.selectOutput" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select Output File" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать файл вывода" + } + } + } + }, + "settings.speechModel" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speech Model" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Речевая модель" + } + } + } + }, + "settings.startup" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Startup Processing" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обработка при запуске" + } + } + } + }, + "settings.startup.ask" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ask" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Спрашивать" + } + } + } + }, + "settings.startup.no" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет" + } + } + } + }, + "settings.startup.yes" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Да" + } + } + } + }, + "settings.storage" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Storage" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хранилище" + } + } + } + }, + "settings.title" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки" + } + } + } + }, + "settings.unknown" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неизвестно" + } + } + } + }, + "settings.version" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Версия" + } + } + } + }, + "settings.website" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Website" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сайт" + } + } + } + }, + "settings.whenQueued" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "When queued files are found" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Когда найдены файлы в очереди" + } + } + } + }, + "startup.alreadyRunning" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Found files to process, but transcription is already running." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Найдены файлы для обработки, но расшифровка уже выполняется." + } + } + } + }, + "startup.backendUnavailable" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Found files to process, but the iOS transcription backend is not configured." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Найдены файлы для обработки, но механизм расшифровки iOS не настроен." + } + } + } + }, + "startup.filesWaiting.few" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d files are waiting in New." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В разделе «Новые» ожидают %d файла." + } + } + } + }, + "startup.filesWaiting.many" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d files are waiting in New." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В разделе «Новые» ожидают %d файлов." + } + } + } + }, + "startup.filesWaiting.one" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d file is waiting in New." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В разделе «Новые» ожидает %d файл." + } + } + } + }, + "startup.filesWaiting.other" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d files are waiting in New." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В разделе «Новые» ожидают %d файлов." + } + } + } + }, + "startup.modelNotReady" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Found files to process, but the speech model is not ready." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Найдены файлы для обработки, но речевая модель не готова." + } + } + } + }, + "startup.processFiles" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Process Files" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обработать файлы" + } + } + } + }, + "startup.prompt" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Found files to process. Process now?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Найдены файлы для обработки. Обработать сейчас?" + } + } + } + }, + "task.automaticTranscriptExport" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatic Transcript Export" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматический экспорт расшифровок" + } + } + } + }, + "task.cancel" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + } + } + }, + "task.chooseExisting" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose Existing" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать существующий" + } + } + } + }, + "task.chooseModel" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose Model" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать модель" + } + } + } + }, + "task.createNew" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create New" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать новый" + } + } + } + }, + "task.download" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скачать" + } + } + } + }, + "task.failed" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка" + } + } + } + }, + "task.hide" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hide" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть" + } + } + } + }, + "task.importAudioFiles" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Import Audio Files" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Импортировать аудиофайлы" + } + } + } + }, + "task.installing" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installing" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установка" + } + } + } + }, + "task.installingModel" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installing model" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установка модели" + } + } + } + }, + "task.installSpeechModel" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Install Speech Model" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установить речевую модель" + } + } + } + }, + "task.needsAttention" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Needs attention" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требуется внимание" + } + } + } + }, + "task.new" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Новые" + } + } + } + }, + "task.noNewTasks" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No new tasks" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет новых задач" + } + } + } + }, + "task.noProcessedAudio" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No processed audio files" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет обработанных аудиофайлов" + } + } + } + }, + "task.noSpeech" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No speech" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Речь не обнаружена" + } + } + } + }, + "task.noTasks" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No tasks" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет задач" + } + } + } + }, + "task.optional" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optional" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Необязательно" + } + } + } + }, + "task.outputOptionalDetail" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optional. Transcripts are stored in Voice Inbox." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Необязательно. Расшифровки хранятся в Voice Inbox." + } + } + } + }, + "task.play" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Play" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Воспроизвести" + } + } + } + }, + "task.preparingSpeechModel" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preparing speech model" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подготовка речевой модели" + } + } + } + }, + "task.processed" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Processed" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Готовые" + } + } + } + }, + "task.processing" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Processing" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обработка" + } + } + } + }, + "task.progressFailed" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d failed" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибок: %d" + } + } + } + }, + "task.progressFiles" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d / %d files" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d из %d файлов" + } + } + } + }, + "task.refreshAudioFolder" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refresh Audio Folder" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновить папку с аудио" + } + } + } + }, + "task.required" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Required" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обязательно" + } + } + } + }, + "task.restoreAudioFolderAccess" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restore Audio Folder Access" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Восстановить доступ к папке с аудио" + } + } + } + }, + "task.retry" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retry" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторить" + } + } + } + }, + "task.retryDownload" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retry download" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторить загрузку" + } + } + } + }, + "task.scanning" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scanning" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сканирование" + } + } + } + }, + "task.scanningAudioFolder" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scanning audio folder" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сканирование папки с аудио" + } + } + } + }, + "task.selectAudioFolder" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select Audio Folder" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать папку с аудио" + } + } + } + }, + "task.selectFolder" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select folder" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать папку" + } + } + } + }, + "task.showText" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Text" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать текст" + } + } + } + }, + "task.stop" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stop" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Остановить" + } + } + } + }, + "task.transcribe" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transcribe" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расшифровать" + } + } + } + }, + "transcript.close" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close transcript" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть расшифровку" + } + } + } + }, + "transcript.done" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Готово" + } + } + } + }, + "transcript.share" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поделиться" + } + } + } + }, + "transcript.shareAccessibility" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share transcript" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поделиться расшифровкой" + } + } + } + }, + "transcript.textAccessibility" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transcript text for %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текст расшифровки для %@" + } + } + } + }, + "transcription.backendUnavailable" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "iOS transcription backend is not configured yet." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Механизм расшифровки iOS еще не настроен." + } + } + } + }, + "transcription.failed" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "iOS transcription failed." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбой расшифровки iOS." + } + } + } + }, + "transcription.modelPreparationFailed" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speech model preparation failed." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось подготовить речевую модель." + } + } + } + }, + "transcription.preparingModel" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preparing speech model" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подготовка речевой модели" + } + } + } + }, + "transcription.transcribed" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transcribed %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расшифрован файл %@" + } + } + } + }, + "Unavailable" : { + "comment" : "A label describing an action that is currently unavailable.", + "isCommentAutoGenerated" : true + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/iosApp/VoiceInbox/SettingsView.swift b/iosApp/VoiceInbox/SettingsView.swift index 43e72e6..b972278 100644 --- a/iosApp/VoiceInbox/SettingsView.swift +++ b/iosApp/VoiceInbox/SettingsView.swift @@ -11,22 +11,22 @@ enum IosStartupProcessingPolicy: String, CaseIterable, Identifiable { var title: String { switch self { case .ask: - "Ask" + L10n.text("settings.startup.ask", fallback: "Ask") case .yes: - "Yes" + L10n.text("settings.startup.yes", fallback: "Yes") case .no: - "No" + L10n.text("settings.startup.no", fallback: "No") } } var detail: String { switch self { case .ask: - "Ask before processing queued files when Voice Inbox starts." + L10n.text("settings.startup.askDetail", fallback: "Ask before processing queued files when Voice Inbox starts.") case .yes: - "Automatically process queued files when Voice Inbox starts." + L10n.text("settings.startup.yesDetail", fallback: "Automatically process queued files when Voice Inbox starts.") case .no: - "Do not process queued files automatically at startup." + L10n.text("settings.startup.noDetail", fallback: "Do not process queued files automatically at startup.") } } } @@ -70,9 +70,9 @@ struct SettingsView: View { var body: some View { Form { - Section("Storage") { + Section(L10n.text("settings.storage", fallback: "Storage")) { VStack(alignment: .leading, spacing: 6) { - Text("Audio inbox folder") + Text(L10n.text("settings.audioInbox", fallback: "Audio inbox folder")) .font(.headline) Text(importStore.inboxFolderStatus.title) if let message = importStore.inboxFolderStatus.message { @@ -89,14 +89,16 @@ struct SettingsView: View { selectInboxFolder() } label: { Label( - importStore.inboxFolderStatus.needsSelection ? "Select Audio Folder" : "Change Audio Folder", + importStore.inboxFolderStatus.needsSelection + ? L10n.text("settings.selectAudioFolder", fallback: "Select Audio Folder") + : L10n.text("settings.changeAudioFolder", fallback: "Change Audio Folder"), systemImage: "folder" ) } .disabled(importStore.isScanningFolder) VStack(alignment: .leading, spacing: 6) { - Text("Transcript output file") + Text(L10n.text("settings.outputFile", fallback: "Transcript output file")) .font(.headline) Text(outputStore.status.title) Text(outputStore.status.message) @@ -108,18 +110,20 @@ struct SettingsView: View { selectOutputFile() } label: { Label( - outputStore.isReady ? "Change Output File" : "Select Output File", + outputStore.isReady + ? L10n.text("settings.changeOutput", fallback: "Change Output File") + : L10n.text("settings.selectOutput", fallback: "Select Output File"), systemImage: "doc.badge.plus" ) } - Button("Do not export", role: .destructive) { + Button(L10n.text("settings.doNotExport", fallback: "Do not export"), role: .destructive) { disableExport() } } - Section("Startup Processing") { - Picker("When queued files are found", selection: $startupPolicyStore.policy) { + Section(L10n.text("settings.startup", fallback: "Startup Processing")) { + Picker(L10n.text("settings.whenQueued", fallback: "When queued files are found"), selection: $startupPolicyStore.policy) { ForEach(IosStartupProcessingPolicy.allCases) { policy in Text(policy.title).tag(policy) } @@ -130,7 +134,7 @@ struct SettingsView: View { .foregroundStyle(.secondary) } - Section("Speech Model") { + Section(L10n.text("settings.speechModel", fallback: "Speech Model")) { if let active = speechModelStore.activeDescriptor { VStack(alignment: .leading, spacing: 4) { Text(active.displayName).font(.headline) @@ -139,24 +143,24 @@ struct SettingsView: View { .foregroundStyle(.secondary) } } else { - Text("No speech model installed") + Text(L10n.text("settings.noModel", fallback: "No speech model installed")) } Button { installModelPackage() } label: { - Label("Install model package from folder", systemImage: "folder.badge.plus") + Label(L10n.text("settings.installModel", fallback: "Install model package from folder"), systemImage: "folder.badge.plus") } .disabled(speechModelStore.isBusy) } - Section("About") { - LabeledContent("Version", value: appVersion) - Link("Website", destination: websiteURL) - Link("Documentation", destination: documentationURL) - Link("Legal information", destination: legalURL) + Section(L10n.text("settings.about", fallback: "About")) { + LabeledContent(L10n.text("settings.version", fallback: "Version"), value: appVersion) + Link(L10n.text("settings.website", fallback: "Website"), destination: websiteURL) + Link(L10n.text("settings.documentation", fallback: "Documentation"), destination: documentationURL) + Link(L10n.text("settings.legal", fallback: "Legal information"), destination: legalURL) } } - .navigationTitle("Settings") + .navigationTitle(L10n.text("settings.title", fallback: "Settings")) } private var appVersion: String { @@ -168,6 +172,6 @@ struct SettingsView: View { if let build, !build.isEmpty { return build } - return "Unknown" + return L10n.text("settings.unknown", fallback: "Unknown") } } diff --git a/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift b/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift index 02b24c7..87a5128 100644 --- a/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift +++ b/iosApp/VoiceInboxTests/DeferredSpeechModelLoadingTests.swift @@ -369,7 +369,7 @@ final class DeferredSpeechModelLoadingTests: XCTestCase { return XCTFail("Expected an audio task") } XCTAssertEqual(task.state, .pending) - XCTAssertEqual(task.errorMessage, "Model could not be loaded") + XCTAssertEqual(task.errorMessage?.fallback, "Model could not be loaded") } func testTypedActionsHaveExplicitIosRoutes() { diff --git a/iosApp/VoiceInboxTests/IosInlineOnboardingTests.swift b/iosApp/VoiceInboxTests/IosInlineOnboardingTests.swift index 0f46a05..5afbe94 100644 --- a/iosApp/VoiceInboxTests/IosInlineOnboardingTests.swift +++ b/iosApp/VoiceInboxTests/IosInlineOnboardingTests.swift @@ -4,6 +4,15 @@ import XCTest @testable import VoiceInbox final class IosInlineOnboardingTests: XCTestCase { + func testRussianPluralSelectionCoversOneFewAndMany() { + XCTAssertEqual(L10n.pluralForm(for: 1, languageCode: "ru"), "one") + XCTAssertEqual(L10n.pluralForm(for: 2, languageCode: "ru"), "few") + XCTAssertEqual(L10n.pluralForm(for: 5, languageCode: "ru"), "many") + XCTAssertEqual(L10n.pluralForm(for: 11, languageCode: "ru"), "many") + XCTAssertEqual(L10n.pluralForm(for: 21, languageCode: "ru"), "one") + XCTAssertEqual(L10n.pluralForm(for: 2, languageCode: "en"), "other") + } + func testLifecycleStoreDefaultsUnknownValuesToActiveAndPersistsTerminalStates() throws { let suiteName = "IosInlineOnboardingTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) diff --git a/iosApp/VoiceInboxTests/IosTranscriptReviewTests.swift b/iosApp/VoiceInboxTests/IosTranscriptReviewTests.swift index d4059d6..1de136f 100644 --- a/iosApp/VoiceInboxTests/IosTranscriptReviewTests.swift +++ b/iosApp/VoiceInboxTests/IosTranscriptReviewTests.swift @@ -91,9 +91,9 @@ final class IosTranscriptReviewTests: XCTestCase { ) let allScreen = screen(selection: .all, files: [pending, processed]) - XCTAssertEqual(newDetail, audioTask(in: allScreen, id: 3).detail) - XCTAssertEqual(processedDetail, audioTask(in: allScreen, id: 4).detail) - XCTAssertTrue(newDetail.contains(" • ")) + XCTAssertEqual(newDetail.fallback, audioTask(in: allScreen, id: 3).detail?.fallback) + XCTAssertEqual(processedDetail.fallback, audioTask(in: allScreen, id: 4).detail?.fallback) + XCTAssertTrue(newDetail.fallback.contains(" • ")) } private func file( From ed142e1052fbac879997e0c53aebcdc5f315a0c2 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Sat, 19 Sep 2026 05:38:27 +0200 Subject: [PATCH 03/11] add extension localization --- iosApp/VoiceInbox.xcodeproj/project.pbxproj | 10 + .../Localizable.xcstrings | 261 ++++++++++++++++++ .../ShareExtensionLocalization.swift | 31 +++ .../ShareViewController.swift | 32 ++- .../IosInlineOnboardingTests.swift | 9 + 5 files changed, 337 insertions(+), 6 deletions(-) create mode 100644 iosApp/VoiceInboxShareExtension/Localizable.xcstrings create mode 100644 iosApp/VoiceInboxShareExtension/ShareExtensionLocalization.swift diff --git a/iosApp/VoiceInbox.xcodeproj/project.pbxproj b/iosApp/VoiceInbox.xcodeproj/project.pbxproj index 9dc32be..413e0e0 100644 --- a/iosApp/VoiceInbox.xcodeproj/project.pbxproj +++ b/iosApp/VoiceInbox.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + E60000000000000000000001 /* ShareExtensionLocalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60000000000000000000002 /* ShareExtensionLocalization.swift */; }; + E60000000000000000000005 /* ShareExtensionLocalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60000000000000000000002 /* ShareExtensionLocalization.swift */; }; + E60000000000000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = E60000000000000000000004 /* Localizable.xcstrings */; }; E50000000000000000000001 /* IosLocalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50000000000000000000002 /* IosLocalization.swift */; }; E50000000000000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = E50000000000000000000004 /* Localizable.xcstrings */; }; 101010101010101010101001 /* VoiceInboxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101010101010101010101011 /* VoiceInboxApp.swift */; }; @@ -66,6 +69,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + E60000000000000000000002 /* ShareExtensionLocalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareExtensionLocalization.swift; sourceTree = ""; }; + E60000000000000000000004 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; E50000000000000000000002 /* IosLocalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosLocalization.swift; sourceTree = ""; }; E50000000000000000000004 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 101010101010101010101003 /* VoiceInbox.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoiceInbox.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -173,6 +178,8 @@ isa = PBXGroup; children = ( A2A88E9CFF2023E878C6EC22 /* ShareViewController.swift */, + E60000000000000000000002 /* ShareExtensionLocalization.swift */, + E60000000000000000000004 /* Localizable.xcstrings */, CD030301C0E14C9932DCBFE6 /* IosSharedImportStaging.swift */, F63411837D138D0E90F5BA1D /* Info.plist */, A3B22C5C663FBCBBFAB0A81F /* VoiceInboxShareExtension.entitlements */, @@ -303,6 +310,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + E60000000000000000000003 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -368,6 +376,7 @@ 101010101010101010101001 /* VoiceInboxApp.swift in Sources */, 101010101010101010101002 /* ContentView.swift in Sources */, E50000000000000000000001 /* IosLocalization.swift in Sources */, + E60000000000000000000005 /* ShareExtensionLocalization.swift in Sources */, 101010101010101010101028 /* IosAudioDocumentPicker.swift in Sources */, 101010101010101010101027 /* IosAudioImportStore.swift in Sources */, 101010101010101010101031 /* IosAudioPreviewPlayer.swift in Sources */, @@ -389,6 +398,7 @@ buildActionMask = 2147483647; files = ( 361EE03677F4524C001986C0 /* ShareViewController.swift in Sources */, + E60000000000000000000001 /* ShareExtensionLocalization.swift in Sources */, 9FAC76B26E6BD4116D968475 /* IosSharedImportStaging.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/iosApp/VoiceInboxShareExtension/Localizable.xcstrings b/iosApp/VoiceInboxShareExtension/Localizable.xcstrings new file mode 100644 index 0000000..239a7de --- /dev/null +++ b/iosApp/VoiceInboxShareExtension/Localizable.xcstrings @@ -0,0 +1,261 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "share.done" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Готово" + } + } + } + }, + "share.failed.few" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d files failed" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка при добавлении %d файлов" + } + } + } + }, + "share.failed.many" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d files failed" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка при добавлении %d файлов" + } + } + } + }, + "share.failed.one" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d file failed" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка при добавлении %d файла" + } + } + } + }, + "share.failed.other" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d files failed" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка при добавлении %d файлов" + } + } + } + }, + "share.imported.few" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d files added to Voice Inbox" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d файла добавлено в Voice Inbox" + } + } + } + }, + "share.imported.many" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d files added to Voice Inbox" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d файлов добавлено в Voice Inbox" + } + } + } + }, + "share.imported.one" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d file added to Voice Inbox" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d файл добавлен в Voice Inbox" + } + } + } + }, + "share.imported.other" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d files added to Voice Inbox" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d файлов добавлено в Voice Inbox" + } + } + } + }, + "share.importing" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importing audio..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Импорт аудио..." + } + } + } + }, + "share.noSupportedAudio" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No supported audio files found." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поддерживаемые аудиофайлы не найдены." + } + } + } + }, + "share.skipped.few" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d files skipped" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d файла пропущено" + } + } + } + }, + "share.skipped.many" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d files skipped" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d файлов пропущено" + } + } + } + }, + "share.skipped.one" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d file skipped" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d файл пропущен" + } + } + } + }, + "share.skipped.other" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d files skipped" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d файлов пропущено" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/iosApp/VoiceInboxShareExtension/ShareExtensionLocalization.swift b/iosApp/VoiceInboxShareExtension/ShareExtensionLocalization.swift new file mode 100644 index 0000000..9f71c20 --- /dev/null +++ b/iosApp/VoiceInboxShareExtension/ShareExtensionLocalization.swift @@ -0,0 +1,31 @@ +import Foundation + +enum ShareExtensionL10n { + static func text(_ key: String, fallback: String) -> String { + NSLocalizedString(key, bundle: .main, value: fallback, comment: "") + } + + static func format(_ key: String, fallback: String, _ arguments: CVarArg...) -> String { + String(format: text(key, fallback: fallback), locale: .current, arguments: arguments) + } + + static func plural(_ key: String, count: Int, fallback: String) -> String { + format("\(key).\(pluralForm(for: count, languageCode: Locale.current.language.languageCode?.identifier))", fallback: fallback, count) + } + + static func pluralForm(for count: Int, languageCode: String?) -> String { + guard languageCode == "ru" else { + return count == 1 ? "one" : "other" + } + + let lastTwo = count % 100 + let last = count % 10 + if last == 1 && lastTwo != 11 { + return "one" + } + if (2...4).contains(last) && !(12...14).contains(lastTwo) { + return "few" + } + return "many" + } +} diff --git a/iosApp/VoiceInboxShareExtension/ShareViewController.swift b/iosApp/VoiceInboxShareExtension/ShareViewController.swift index 8e93de4..8024c04 100644 --- a/iosApp/VoiceInboxShareExtension/ShareViewController.swift +++ b/iosApp/VoiceInboxShareExtension/ShareViewController.swift @@ -17,10 +17,10 @@ final class ShareViewController: UIViewController { statusLabel.translatesAutoresizingMaskIntoConstraints = false statusLabel.numberOfLines = 0 statusLabel.textAlignment = .center - statusLabel.text = "Importing audio..." + statusLabel.text = ShareExtensionL10n.text("share.importing", fallback: "Importing audio...") doneButton.translatesAutoresizingMaskIntoConstraints = false - doneButton.setTitle("Done", for: .normal) + doneButton.setTitle(ShareExtensionL10n.text("share.done", fallback: "Done"), for: .normal) doneButton.addTarget(self, action: #selector(closeExtension), for: .touchUpInside) doneButton.isHidden = true @@ -164,9 +164,29 @@ private struct ShareImportResult { 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: ", ") + if imported > 0 { + parts.append(ShareExtensionL10n.plural( + "share.imported", + count: imported, + fallback: "%d file added to Voice Inbox" + )) + } + if skipped > 0 { + parts.append(ShareExtensionL10n.plural( + "share.skipped", + count: skipped, + fallback: "%d file skipped" + )) + } + if failed > 0 { + parts.append(ShareExtensionL10n.plural( + "share.failed", + count: failed, + fallback: "%d file failed" + )) + } + return parts.isEmpty + ? ShareExtensionL10n.text("share.noSupportedAudio", fallback: "No supported audio files found.") + : parts.joined(separator: ", ") } } diff --git a/iosApp/VoiceInboxTests/IosInlineOnboardingTests.swift b/iosApp/VoiceInboxTests/IosInlineOnboardingTests.swift index 5afbe94..c22b7b5 100644 --- a/iosApp/VoiceInboxTests/IosInlineOnboardingTests.swift +++ b/iosApp/VoiceInboxTests/IosInlineOnboardingTests.swift @@ -4,6 +4,15 @@ import XCTest @testable import VoiceInbox final class IosInlineOnboardingTests: XCTestCase { + func testShareExtensionRussianPluralSelectionCoversOneFewAndMany() { + XCTAssertEqual(ShareExtensionL10n.pluralForm(for: 1, languageCode: "ru"), "one") + XCTAssertEqual(ShareExtensionL10n.pluralForm(for: 2, languageCode: "ru"), "few") + XCTAssertEqual(ShareExtensionL10n.pluralForm(for: 5, languageCode: "ru"), "many") + XCTAssertEqual(ShareExtensionL10n.pluralForm(for: 11, languageCode: "ru"), "many") + XCTAssertEqual(ShareExtensionL10n.pluralForm(for: 21, languageCode: "ru"), "one") + XCTAssertEqual(ShareExtensionL10n.pluralForm(for: 2, languageCode: "en"), "other") + } + func testRussianPluralSelectionCoversOneFewAndMany() { XCTAssertEqual(L10n.pluralForm(for: 1, languageCode: "ru"), "one") XCTAssertEqual(L10n.pluralForm(for: 2, languageCode: "ru"), "few") From 501bcf8d69069e9f5353b9746042329aaa1cbd64 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Sat, 19 Sep 2026 12:35:01 +0200 Subject: [PATCH 04/11] agg german translation --- .../AndroidLocalizationInstrumentedTest.kt | 8 + .../MainActivityInstrumentedTest.kt | 6 +- .../voiceinbox/AudioImportCoordinator.kt | 9 +- .../me/maxistar/voiceinbox/MainActivity.kt | 4 +- .../voiceinbox/SpeechModelDownloadWorker.kt | 2 +- .../voiceinbox/SpeechModelImportWorker.kt | 2 +- .../voiceinbox/SpeechModelInstallationWork.kt | 13 +- app/src/main/res/layout/activity_settings.xml | 4 +- app/src/main/res/values-de/strings.xml | 230 ++++++++++++++++++ app/src/main/res/values-ru/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + .../voiceinbox/GermanResourceParityTest.kt | 56 +++++ 12 files changed, 326 insertions(+), 16 deletions(-) create mode 100644 app/src/main/res/values-de/strings.xml create mode 100644 app/src/test/java/me/maxistar/voiceinbox/GermanResourceParityTest.kt diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/AndroidLocalizationInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/AndroidLocalizationInstrumentedTest.kt index e76dfad..80f52e1 100644 --- a/app/src/androidTest/java/me/maxistar/voiceinbox/AndroidLocalizationInstrumentedTest.kt +++ b/app/src/androidTest/java/me/maxistar/voiceinbox/AndroidLocalizationInstrumentedTest.kt @@ -17,13 +17,21 @@ class AndroidLocalizationInstrumentedTest { fun taskTextResolverAndPluralResourcesFollowTheRequestedLocale() { val english = localizedContext(Locale.US) val russian = localizedContext(Locale("ru", "RU")) + val german = localizedContext(Locale("de", "DE")) val taskTitle = TaskText(TaskTextKey.INSTALL_SPEECH_MODEL, "Install Speech Model") assertEquals("Install Speech Model", AndroidTaskTextResolver.resolve(english.resources, taskTitle)) assertEquals("Установить речевую модель", AndroidTaskTextResolver.resolve(russian.resources, taskTitle)) + assertEquals("Sprachmodell installieren", AndroidTaskTextResolver.resolve(german.resources, taskTitle)) + assertEquals("Клавиатура Voice Inbox", russian.getString(R.string.voice_keyboard_name)) + assertEquals("Voice-Inbox-Tastatur", german.getString(R.string.voice_keyboard_name)) + assertEquals("Sprachmodell aus Ordner installieren", german.getString(R.string.settings_model_import_title)) assertEquals("Расшифровать все (1 файл)", russian.resources.getQuantityString(R.plurals.transcribe_all_count, 1, 1)) assertEquals("Расшифровать все (2 файла)", russian.resources.getQuantityString(R.plurals.transcribe_all_count, 2, 2)) assertEquals("Расшифровать все (5 файлов)", russian.resources.getQuantityString(R.plurals.transcribe_all_count, 5, 5)) + assertEquals("Alle transkribieren (1 Datei)", german.resources.getQuantityString(R.plurals.transcribe_all_count, 1, 1)) + assertEquals("Alle transkribieren (2 Dateien)", german.resources.getQuantityString(R.plurals.transcribe_all_count, 2, 2)) + assertEquals("Alle transkribieren (5 Dateien)", german.resources.getQuantityString(R.plurals.transcribe_all_count, 5, 5)) } private fun localizedContext(locale: Locale): Context { diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt index e825155..dc321a4 100644 --- a/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt +++ b/app/src/androidTest/java/me/maxistar/voiceinbox/MainActivityInstrumentedTest.kt @@ -269,7 +269,7 @@ class MainActivityInstrumentedTest { assertEquals(android.view.View.VISIBLE, action.visibility) assertTrue(action.isEnabled) assertEquals( - AndroidFolderSyncPresentation.ACCESSIBILITY_REFRESH, + activity.getString(R.string.refresh_state_idle), action.contentDescription, ) } @@ -282,7 +282,7 @@ class MainActivityInstrumentedTest { awaitActivity(scenario) { activity -> !refreshAction(activity).isEnabled } scenario.onActivity { activity -> assertEquals( - AndroidFolderSyncPresentation.ACCESSIBILITY_REFRESH, + activity.getString(R.string.refresh_state_idle), refreshAction(activity).contentDescription, ) } @@ -320,7 +320,7 @@ class MainActivityInstrumentedTest { val action = refreshAction(activity) assertFalse(action.isEnabled) assertEquals( - AndroidFolderSyncPresentation.ACCESSIBILITY_REFRESHING, + activity.getString(R.string.refresh_state_refreshing), action.contentDescription, ) val before = stateHost(activity).currentInput.folderSync diff --git a/app/src/main/java/me/maxistar/voiceinbox/AudioImportCoordinator.kt b/app/src/main/java/me/maxistar/voiceinbox/AudioImportCoordinator.kt index 74ad2ef..8a431f4 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/AudioImportCoordinator.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/AudioImportCoordinator.kt @@ -68,12 +68,19 @@ data class AudioImportSummary( if (duplicateCount > 0) append(" • ${resources.getString(R.string.audio_import_duplicates, duplicateCount)}") if (rejectedCount == 1) { val rejection = outcomes.filterIsInstance().single() - append(" • ${resources.getString(R.string.audio_import_rejected_reason, rejection.reason)}") + append(" • ${resources.getString(R.string.audio_import_rejected_reason, localizedRejectionReason(resources, rejection.reason))}") } else if (rejectedCount > 1) { append(" • ${resources.getString(R.string.audio_import_rejected_count, rejectedCount)}") } if (outcomes.isEmpty()) append(" • ${resources.getString(R.string.audio_import_none_received)}") } + + private fun localizedRejectionReason(resources: Resources, reason: String): String = when (reason) { + "Audio file is empty" -> resources.getString(R.string.audio_import_empty_file) + "Unsupported audio format" -> resources.getString(R.string.audio_import_unsupported_format) + "Audio import failed" -> resources.getString(R.string.audio_import_failed) + else -> reason + } } interface AudioImportStorage { diff --git a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt index 87514af..0c4b00a 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt @@ -1651,7 +1651,9 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen val view = refreshActionView ?: return view.visibility = if (sync.visible) View.VISIBLE else View.GONE view.isEnabled = sync.enabled - view.contentDescription = sync.accessibilityLabel + view.contentDescription = getString( + if (sync.active) R.string.refresh_state_refreshing else R.string.refresh_state_idle, + ) ViewCompat.setStateDescription( view, if (sync.active) getString(R.string.refresh_state_refreshing) else getString(R.string.refresh_state_idle), diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt index 7a6dfb3..587e0b0 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelDownloadWorker.kt @@ -47,7 +47,7 @@ class SpeechModelDownloadWorker( } catch (error: IllegalArgumentException) { failure(error.message ?: applicationContext.getString(R.string.error_model_unavailable_download)) } catch (error: ForegroundPromotionException) { - failure(error.userMessage) + failure(applicationContext.getString(R.string.error_foreground_work)) } } diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt index 5c3f2e1..27ec2e6 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelImportWorker.kt @@ -47,7 +47,7 @@ class SpeechModelImportWorker( workDataOf(SpeechModelInstallationWork.KEY_MODEL_PATH to installed.absolutePath), ) } catch (error: ForegroundPromotionException) { - failure(error.userMessage) + failure(applicationContext.getString(R.string.error_foreground_work)) } finally { SpeechModelImportPermission.releaseOwnedIfUnused(applicationContext) } diff --git a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt index 74dc61f..8a078a3 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/SpeechModelInstallationWork.kt @@ -28,11 +28,10 @@ object SpeechModelInstallationWork { enum class Source( val diagnosticName: String, - val userFacingName: String, ) { - NETWORK_DOWNLOAD("network-download", "model download"), - LOCAL_IMPORT("local-import", "local model import"), - TRANSCRIPTION("transcription", "transcription"), + NETWORK_DOWNLOAD("network-download"), + LOCAL_IMPORT("local-import"), + TRANSCRIPTION("transcription"), } fun foregroundInfo(context: Context, progress: Int, message: String): ForegroundInfo { @@ -146,13 +145,13 @@ object SpeechModelInstallationWork { error, ) return ForegroundPromotionException( - userMessage = "Could not keep ${source.userFacingName} running in the foreground. Please try again.", + source = source, cause = error, ) } } class ForegroundPromotionException( - val userMessage: String, + val source: SpeechModelInstallationWork.Source, cause: Throwable, -) : RuntimeException(userMessage, cause) +) : RuntimeException(cause) diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index a8cf443..330f2c7 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -95,14 +95,14 @@ android:id="@+id/settingsModelDetail" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="Install speech model from folder" + android:text="@string/settings_model_import_title" android:textStyle="bold" /> + android:text="@string/settings_model_select" /> + Voice Inbox + Voice-Inbox-Tastatur + Offline-Diktat + Zum Aufnehmen tippen oder während des Sprechens gedrückt halten + Sprachmodell wird vorbereitet… + Höre zu… Zum Transkribieren loslassen. + Höre zu… Nach dem Ende auf Stopp tippen. + Höre zu… Zum Transkribieren loslassen. Noch %1$s. + Höre zu… Nach dem Ende auf Stopp tippen. Noch %1$s. + Lokale Transkription… + Der Text ist bereit. Zum Einfügen ein Textfeld auswählen oder verwerfen. + Keine Sprache erkannt. Bitte erneut versuchen. + Mikrofonzugriff erforderlich. Voice Inbox öffnen, um ihn zu erlauben. + Mikrofon ist nicht verfügbar. Bitte erneut versuchen. + Ein verfügbares Sprachmodell ist erforderlich. Voice Inbox öffnen, um es zu installieren oder zu reparieren. + Transkription fehlgeschlagen. Bitte erneut versuchen. + Aufnehmen + Stopp + Abbrechen + Schließen + Voice-Inbox-Einrichtung öffnen + Löschen + Leerzeichen + Eingabe + Weiter + Zur vorherigen Tastatur zurückkehren + Mikrofonzugriff gewährt. Zur Voice-Inbox-Tastatur zurückkehren. + Mikrofonzugriff wurde nicht gewährt. + Tastaturvorschlag schließen + Die Android-Tastatureinstellungen sind auf diesem Gerät nicht verfügbar. Die Tastaturanleitung enthält manuelle Schritte. + Ausgabedatei auswählen + Ausgabedatei ändern + Audioordner auswählen + Audioordner ändern + Aktualisieren + Audioordner wird nicht aktualisiert + Audioordner wird aktualisiert + Einstellungen + Dokumentation + Einstellungen + Speicher + Audioordner + Automatischer Transkript-Export + Automatischen Transkript-Export konfigurieren + Neu erstellen + Vorhandene auswählen + Nicht exportieren + Über + Version: %1$s + Webseite + Rechtliche Hinweise + Keine App kann diesen Link öffnen. + Aktiv: %1$s. Ein unterstütztes lokales Modellpaket zum Ersetzen auswählen. + Sprachmodell aus Ordner installieren + Ein unterstütztes lokales Modellpaket auswählen. + Ausgabedatei ist nicht beschreibbar + Audioordner ist nicht lesbar + Sprachmodell wird geprüft + Sprachmodell ist bereit + Sprachmodell ist nicht installiert + Ungültiges Sprachmodell: %1$s + Sprachmodell wird installiert + Installation des Sprachmodells fehlgeschlagen + Installation des Sprachmodells + Audiotranskription + Modelldownload wird vorbereitet + Sprachmodell wird heruntergeladen + Sprachmodell wird aktiviert + Lokales Sprachmodell wird vorbereitet + Transkription wird vorbereitet + Sprachmodell wird vorbereitet + Transkription fehlgeschlagen + Vorbereitung des Sprachmodells fehlgeschlagen + %1$s wird heruntergeladen + %1$s verifiziert + Das ausgewählte Sprachmodell steht nicht zum Download bereit + Vorprüfung der Modellinstallation fehlgeschlagen + Download von %1$s fehlgeschlagen: %2$s + Aktivierung des Sprachmodells fehlgeschlagen + Kein Modellordner ausgewählt + Kein Sprachmodell ausgewählt + Keine Version des Sprachmodells ausgewählt + Das ausgewählte Sprachmodell wird nicht mehr unterstützt + Sprachmodell konnte nicht importiert werden + unbekannter Fehler + Voice-Inbox-Tastatur + Kompaktes Android-Diktat für jedes bearbeitbare Feld. Die Erkennung läuft nach der Einrichtung des Modells lokal; dies ist keine vollständige Tastatur. + Nicht aktiviert + Aktiviert · In der Android-Tastaturauswahl auswählen + Bereit · Derzeit ausgewählt + Aktivieren + Tastatur auswählen + Tastatur ändern + Tastaturanleitung + Nächtliche Transkription + Neue Notizen zur ausgewählten Zeit automatisch suchen und transkribieren. + Geplante Zeit: %1$02d:%2$02d + Geplante Zeit ändern + Android kann Hintergrundarbeit zur Akkuschonung verzögern. Diese Funktion ist standardmäßig deaktiviert. + Verarbeitung beim Start + Auswählen, was beim Öffnen von Voice Inbox mit wartenden Notizen geschieht. + Jedes Mal fragen + Automatisch transkribieren + Dateien in der Warteschlange lassen + Die Startverarbeitung läuft nur beim Öffnen der App. Die nächtliche Transkription ist eine separate Hintergrundoption. + Wartende Notizen verarbeiten? + + %1$d Datei ist zum Transkribieren bereit. + %1$d Dateien sind zum Transkribieren bereit. + + Beim Start von Voice Inbox immer so verfahren + Jetzt verarbeiten + Dateien in der Warteschlange lassen + Ausgabe: nicht ausgewählt + Ausgabe: %1$s + Ausgabe: Auswahl wird wiederhergestellt… + Ausgabe: vorübergehend nicht verfügbar + Ausgabedatei auswählen + Audioordner: nicht ausgewählt + Audioordner: %1$s + Audioordner: Auswahl wird wiederhergestellt… + Audioordner: vorübergehend nicht verfügbar + Audioordner auswählen + Audio importieren + Audio wird importiert… + Es wurde kein lesbares Audio geteilt. + Teilen + Schließen + Auswählbarer Transkripttext + Sprachmodell wird geprüft + Sprachmodell herunterladen + Aus Ordner installieren + Neu + Verarbeitet + Alle + Keine neuen Audiodateien + Alle transkribieren + Sprachmodell installieren + Automatischer Transkript-Export + Optional. Transkripte werden in Voice Inbox gespeichert. + %1$s · %2$s · %3$s · %4$s Download · %5$s frei + Modell auswählen + Audioordner aktualisieren + Zugriff auf Audioordner wiederherstellen + Erforderlich + Optional + Wird installiert + Wird durchsucht + Aufmerksamkeit erforderlich + Wird verarbeitet + Fehlgeschlagen + Keine Sprache + Download wiederholen + Herunterladen + Ausblenden + Ordner auswählen + Erneut versuchen + Transkribieren + Text anzeigen + Abspielen + Audiodateien importieren + Keine neuen Aufgaben + Keine verarbeiteten Audiodateien + Keine Aufgaben + Modell wird installiert + Audioordner wird durchsucht + Sprachmodell wird vorbereitet + Einrichtungsanleitung schließen + Voice Inbox einrichten + Diesen Schritten folgen oder die Einrichtungsaufgaben oben in beliebiger Reihenfolge verwenden. + Beim Start der Einrichtung wird das Sprachmodell auf dieses Gerät heruntergeladen. + Sprachmodell installieren + Automatischen Transkript-Export konfigurieren · Optional + Audioordner auswählen · Optional + Voice-Inbox-Tastatur aktivieren · Optional + Sprachmodell wird installiert… + Einrichtung wiederholen + Einrichtung starten + Modell aus Ordner installieren + Exportdokument auswählen + Audioordner auswählen + Voice-Inbox-Tastatur aktivieren + Bereit zum Transkribieren + In jeder App diktieren + Voice Inbox als kompakte Sprachtastatur verwenden. Die Erkennung läuft nach der Einrichtung lokal. + Tastatur einrichten + Mehr erfahren + Automatischen Transkript-Export ausblenden + Ausgabedatei ist vorübergehend nicht verfügbar + Zugriff auf Ausgabedatei wurde entzogen; erneut auswählen + Audioordner ist vorübergehend nicht verfügbar + Zugriff auf Audioordner wurde entzogen; erneut auswählen + Auf den ausgewählten Modellordner kann nach dem Schließen der Auswahl nicht zugegriffen werden + Der ausgewählte Modellordner ist nicht lesbar + %1$s installieren? + %1$s-Modell · %2$s · ungefähr %3$s + %1$s wird installiert + Ordner wird durchsucht + Suche abgeschlossen: %1$d Audiodateien + Ordnersuche fehlgeschlagen + Aktualisierung des Audiokatalogs fehlgeschlagen + Hintergrundarbeit konnte nicht fortgesetzt werden. Bitte erneut versuchen. + Abgeschlossen + Transkribieren + %1$d von %2$d abgeschlossen + %1$d von %2$d abgeschlossen, %3$d fehlgeschlagen + %1$s kann nicht abgespielt werden + geteiltes Audio + Audioimport fehlgeschlagen + Audiodatei ist leer + Nicht unterstütztes Audioformat + %1$d importiert + %1$d bereits hinzugefügt + abgelehnt: %1$s + %1$d abgelehnt + kein Audio empfangen + Installieren + Unbekannt + Dokument + Audioordner + ✓ %1$s + ○ %1$s + Abgeschlossen: %1$s + Nicht abgeschlossen: %1$s + + Alle transkribieren (%1$d Datei) + Alle transkribieren (%1$d Dateien) + + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 1775391..55fe930 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -52,6 +52,7 @@ Правовая информация Нет приложения для открытия этой ссылки. Активна: %1$s. Выберите поддерживаемый локальный пакет модели, чтобы заменить её. + Установить речевую модель из папки Выберите поддерживаемый локальный пакет модели. В файл вывода нельзя записать Папка с аудио недоступна для чтения @@ -201,6 +202,7 @@ Сканирование завершено: аудиофайлов %1$d Не удалось просканировать папку Не удалось обновить каталог аудио + Не удалось продолжить фоновую работу. Попробуйте ещё раз. Готово Расшифровка Готово: %1$d из %2$d @@ -208,6 +210,8 @@ Не удалось воспроизвести %1$s переданное аудио Не удалось импортировать аудио + Аудиофайл пуст + Неподдерживаемый формат аудио Импортировано: %1$d Уже добавлено: %1$d Отклонено: %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ab23797..72c590d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -52,6 +52,7 @@ Legal information No app can open this link. Active: %1$s. Select a supported local model package to replace it. + Install speech model from folder Select a supported local model package. Output file is not writable Audio folder is not readable @@ -199,6 +200,7 @@ Scan complete: %1$d audio files Folder scan failed Audio catalog refresh failed + Could not continue background work. Please try again. Completed Transcribing Completed %1$d of %2$d @@ -206,6 +208,8 @@ Cannot play %1$s shared audio Audio import failed + Audio file is empty + Unsupported audio format Imported %1$d %1$d already added rejected: %1$s diff --git a/app/src/test/java/me/maxistar/voiceinbox/GermanResourceParityTest.kt b/app/src/test/java/me/maxistar/voiceinbox/GermanResourceParityTest.kt new file mode 100644 index 0000000..e9e596e --- /dev/null +++ b/app/src/test/java/me/maxistar/voiceinbox/GermanResourceParityTest.kt @@ -0,0 +1,56 @@ +package me.maxistar.voiceinbox + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.w3c.dom.Element +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory + +class GermanResourceParityTest { + @Test + fun germanResourcesMatchDefaultNamesAndFormatArguments() { + val defaultDocument = parse("src/main/res/values/strings.xml") + val germanDocument = parse("src/main/res/values-de/strings.xml") + val defaultEntries = entries(defaultDocument) + val germanEntries = entries(germanDocument) + + assertEquals(defaultEntries.keys, germanEntries.keys) + defaultEntries.forEach { (name, value) -> + assertEquals("format arguments for $name", placeholders(value), placeholders(germanEntries.getValue(name))) + } + } + + @Test + fun germanLocaleContainsTranslatedVoiceInboxCopy() { + val document = parse("src/main/res/values-de/strings.xml") + val entries = entries(document) + assertEquals("Sprachmodell installieren", entries.getValue("task_install_speech_model")) + assertEquals("Voice-Inbox-Tastatur", entries.getValue("voice_keyboard_name")) + assertTrue(entries.getValue("onboarding_title").contains("einrichten", ignoreCase = true)) + } + + private fun parse(path: String) = DocumentBuilderFactory.newInstance() + .newDocumentBuilder() + .parse(File(path)) + .documentElement + + private fun entries(root: Element): Map = buildMap { + val nodes = root.childNodes + for (index in 0 until nodes.length) { + val node = nodes.item(index) + if (node is Element && node.tagName == "string") { + put(node.getAttribute("name"), node.textContent) + } else if (node is Element && node.tagName == "plurals") { + for (childIndex in 0 until node.childNodes.length) { + val child = node.childNodes.item(childIndex) + if (child is Element && child.tagName == "item") { + put("${node.getAttribute("name")}:${child.getAttribute("quantity")}", child.textContent) + } + } + } + } + } + + private fun placeholders(value: String): List = "%\\d+\\$[sd]".toRegex().findAll(value).map { it.value }.toList() +} From e758c69725a5bcab19a3e3d81c2249532b403279 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Sat, 19 Sep 2026 13:03:59 +0200 Subject: [PATCH 05/11] minor changes --- .../AndroidBackupPolicyInstrumentedTest.kt | 18 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 4 +--- app/src/main/res/xml/backup_rules.xml | 13 ------------- .../main/res/xml/data_extraction_rules.xml | 19 ------------------- website/src/pages/legal.astro | 3 +-- website/src/pages/privacy.astro | 8 ++++++++ 6 files changed, 28 insertions(+), 37 deletions(-) create mode 100644 app/src/androidTest/java/me/maxistar/voiceinbox/AndroidBackupPolicyInstrumentedTest.kt delete mode 100644 app/src/main/res/xml/backup_rules.xml delete mode 100644 app/src/main/res/xml/data_extraction_rules.xml diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/AndroidBackupPolicyInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/AndroidBackupPolicyInstrumentedTest.kt new file mode 100644 index 0000000..7d1c6a1 --- /dev/null +++ b/app/src/androidTest/java/me/maxistar/voiceinbox/AndroidBackupPolicyInstrumentedTest.kt @@ -0,0 +1,18 @@ +package me.maxistar.voiceinbox + +import android.content.pm.ApplicationInfo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AndroidBackupPolicyInstrumentedTest { + @Test + fun installedApplicationDisablesAndroidSystemBackup() { + val appInfo = InstrumentationRegistry.getInstrumentation().targetContext.applicationInfo + + assertEquals(0, appInfo.flags and ApplicationInfo.FLAG_ALLOW_BACKUP) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3ec6f3e..eb92350 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,9 +9,7 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml deleted file mode 100644 index 9ee9997..0000000 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/website/src/pages/legal.astro b/website/src/pages/legal.astro index 7e03a78..29446c7 100644 --- a/website/src/pages/legal.astro +++ b/website/src/pages/legal.astro @@ -48,7 +48,6 @@ const base = import.meta.env.BASE_URL.replace(/\/$/, '');

App and publication details

This page collects information useful for users and app store publication preparation. - Fields marked as placeholders must be reviewed and replaced by the maintainer before store submission.

@@ -57,7 +56,7 @@ const base = import.meta.env.BASE_URL.replace(/\/$/, '');
App nameVoice Inbox
Android packageme.maxistar.voiceinbox
iOS bundle statusiOS MVP in development; final bundle identifier and store listing details must be confirmed before release.
-
Developer / publisherTODO: maintainer legal or publisher name
+
Project maintainerMaxim Starikov
Privacy policyPrivacy page
Source code{githubUrl}
diff --git a/website/src/pages/privacy.astro b/website/src/pages/privacy.astro index 96d3fd1..c1e0657 100644 --- a/website/src/pages/privacy.astro +++ b/website/src/pages/privacy.astro @@ -55,6 +55,14 @@ import Layout from '../layouts/Layout.astro';

+
+

Android backup

+

+ Voice Inbox disables Android system backup for app-private data, including its local catalog, imported audio, settings, and installed model files. + This does not restrict files you explicitly share, export, or save to a document or folder you select outside the app. +

+
+

External providers and platform services

From 86d77db3a7448a9d1c456cc937149c8cd8e9e8ba Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Thu, 1 Oct 2026 17:42:31 +0200 Subject: [PATCH 06/11] copy all button --- .../TranscriptReviewInstrumentedTest.kt | 14 +++++ .../voiceinbox/AndroidTranscriptReview.kt | 11 ++++ .../me/maxistar/voiceinbox/MainActivity.kt | 8 +++ app/src/main/res/values-de/strings.xml | 3 ++ app/src/main/res/values-ru/strings.xml | 3 ++ app/src/main/res/values/strings.xml | 3 ++ iosApp/VoiceInbox/IosTranscriptReview.swift | 32 ++++++++++- iosApp/VoiceInbox/Localizable.xcstrings | 53 ++++++++++++++++++- .../IosTranscriptReviewTests.swift | 15 +++++- 9 files changed, 139 insertions(+), 3 deletions(-) diff --git a/app/src/androidTest/java/me/maxistar/voiceinbox/TranscriptReviewInstrumentedTest.kt b/app/src/androidTest/java/me/maxistar/voiceinbox/TranscriptReviewInstrumentedTest.kt index 3750080..d6eef90 100644 --- a/app/src/androidTest/java/me/maxistar/voiceinbox/TranscriptReviewInstrumentedTest.kt +++ b/app/src/androidTest/java/me/maxistar/voiceinbox/TranscriptReviewInstrumentedTest.kt @@ -41,4 +41,18 @@ class TranscriptReviewInstrumentedTest { transcript.contentDescription, ) } + + @Test + fun copyActionPreparesExactTranscriptAndHasLocalizedAccessibilityText() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val review = AndroidTranscriptReview(9, "voice.ogg", " exact transcript\n") + + val copiedText = transcriptClipboardClip(review) + .getItemAt(0) + ?.coerceToText(context) + ?.toString() + assertEquals(review.text, copiedText) + assertTrue(context.getString(R.string.transcript_copy_accessibility).isNotBlank()) + assertTrue(context.getString(R.string.transcript_copied).isNotBlank()) + } } diff --git a/app/src/main/java/me/maxistar/voiceinbox/AndroidTranscriptReview.kt b/app/src/main/java/me/maxistar/voiceinbox/AndroidTranscriptReview.kt index c3ea3e7..860f39d 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/AndroidTranscriptReview.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/AndroidTranscriptReview.kt @@ -1,6 +1,9 @@ package me.maxistar.voiceinbox import android.content.Intent +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context import me.maxistar.voiceinbox.core.AudioCatalogEntry internal data class AndroidTranscriptReview( @@ -32,3 +35,11 @@ internal object AndroidTranscriptShareIntent { fun chooser(review: AndroidTranscriptReview): Intent = Intent.createChooser(create(review), review.filename) } + +internal fun copyTranscriptToClipboard(context: Context, review: AndroidTranscriptReview) { + val clipboard = context.getSystemService(ClipboardManager::class.java) + clipboard.setPrimaryClip(transcriptClipboardClip(review)) +} + +internal fun transcriptClipboardClip(review: AndroidTranscriptReview): ClipData = + ClipData.newPlainText(review.filename, review.text) diff --git a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt index 0c4b00a..8cc946b 100644 --- a/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt +++ b/app/src/main/java/me/maxistar/voiceinbox/MainActivity.kt @@ -950,9 +950,17 @@ class MainActivity : AppCompatActivity(), StartupProcessingDialogFragment.Listen .setTitle(review.filename) .setView(content) .setNegativeButton(R.string.transcript_close, null) + .setNeutralButton(R.string.transcript_copy_all, null) .setPositiveButton(R.string.transcript_share, null) .create() dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).apply { + contentDescription = getString(R.string.transcript_copy_accessibility) + setOnClickListener { + copyTranscriptToClipboard(this@MainActivity, review) + Toast.makeText(this@MainActivity, R.string.transcript_copied, Toast.LENGTH_SHORT).show() + } + } dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { startActivity(AndroidTranscriptShareIntent.chooser(review)) } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 71c750a..a6b546c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -125,6 +125,9 @@ Audio importieren Audio wird importiert… Es wurde kein lesbares Audio geteilt. + Alles kopieren + Vollständiges Transkript kopieren + Transkript kopiert Teilen Schließen Auswählbarer Transkripttext diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 55fe930..569f8e4 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -127,6 +127,9 @@ Импортировать аудио Импорт аудио… Не удалось прочитать переданное аудио. + Скопировать всё + Скопировать всю расшифровку + Расшифровка скопирована Поделиться Закрыть Текст расшифровки с возможностью выделения diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 72c590d..c5c2a35 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -125,6 +125,9 @@ Import audio Importing audio… No readable audio was shared. + Copy all + Copy full transcript + Transcript copied Share Close Selectable transcript text diff --git a/iosApp/VoiceInbox/IosTranscriptReview.swift b/iosApp/VoiceInbox/IosTranscriptReview.swift index e83b11e..57d7a9f 100644 --- a/iosApp/VoiceInbox/IosTranscriptReview.swift +++ b/iosApp/VoiceInbox/IosTranscriptReview.swift @@ -7,6 +7,7 @@ struct IosDisplayedTranscript: Identifiable, Equatable { let text: String var id: Int64 { entryId } + var clipboardText: String { text } var shareText: String { text } var shareSubject: String { filename } @@ -38,6 +39,7 @@ enum IosTranscriptReview { struct IosTranscriptViewer: View { let transcript: IosDisplayedTranscript let onDismiss: () -> Void + @State private var copyConfirmationVisible = false var body: some View { NavigationStack { @@ -48,13 +50,35 @@ struct IosTranscriptViewer: View { .accessibilityIdentifier("transcript-text") .navigationTitle(transcript.filename) .navigationBarTitleDisplayMode(.inline) + .overlay(alignment: .bottom) { + if copyConfirmationVisible { + Text(L10n.text("transcript.copied", fallback: "Transcript copied")) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.regularMaterial, in: Capsule()) + .padding(.bottom, 20) + .accessibilityIdentifier("transcript-copy-confirmation") + } + } .toolbar { ToolbarItem(placement: .cancellationAction) { Button(L10n.text("transcript.done", fallback: "Done"), action: onDismiss) .accessibilityLabel(L10n.text("transcript.close", fallback: "Close transcript")) .accessibilityIdentifier("transcript-close") } - ToolbarItem(placement: .primaryAction) { + ToolbarItemGroup(placement: .primaryAction) { + Button { + IosTranscriptClipboard.copy(transcript.clipboardText) + copyConfirmationVisible = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + copyConfirmationVisible = false + } + } label: { + Label(L10n.text("transcript.copyAll", fallback: "Copy All"), systemImage: "doc.on.doc") + } + .accessibilityLabel(L10n.text("transcript.copyAccessibility", fallback: "Copy full transcript")) + .accessibilityIdentifier("transcript-copy") + ShareLink( item: transcript.shareText, subject: Text(transcript.shareSubject) @@ -69,6 +93,12 @@ struct IosTranscriptViewer: View { } } +enum IosTranscriptClipboard { + static func copy(_ text: String, to pasteboard: UIPasteboard = .general) { + pasteboard.string = text + } +} + struct IosSelectableTranscriptTextView: UIViewRepresentable { let text: String let accessibilityLabel: String diff --git a/iosApp/VoiceInbox/Localizable.xcstrings b/iosApp/VoiceInbox/Localizable.xcstrings index 727cb49..4cdafe7 100644 --- a/iosApp/VoiceInbox/Localizable.xcstrings +++ b/iosApp/VoiceInbox/Localizable.xcstrings @@ -2338,6 +2338,57 @@ } } }, + "transcript.copyAccessibility" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy full transcript" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скопировать всю расшифровку" + } + } + } + }, + "transcript.copyAll" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy All" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скопировать всё" + } + } + } + }, + "transcript.copied" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transcript copied" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расшифровка скопирована" + } + } + } + }, "transcript.done" : { "extractionState" : "stale", "localizations" : { @@ -2497,4 +2548,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/iosApp/VoiceInboxTests/IosTranscriptReviewTests.swift b/iosApp/VoiceInboxTests/IosTranscriptReviewTests.swift index 1de136f..ec5d198 100644 --- a/iosApp/VoiceInboxTests/IosTranscriptReviewTests.swift +++ b/iosApp/VoiceInboxTests/IosTranscriptReviewTests.swift @@ -1,5 +1,6 @@ import Foundation import Shared +import UIKit import XCTest @testable import VoiceInbox @@ -51,7 +52,7 @@ final class IosTranscriptReviewTests: XCTestCase { ) } - func testTranscriptPresentationPreservesExactShareTextAndFilenameContext() throws { + func testTranscriptPresentationPreservesExactShareAndClipboardTextAndFilenameContext() throws { let text = " recognized text\n" let presentation = try XCTUnwrap(IosDisplayedTranscript( entryId: 7, @@ -60,12 +61,24 @@ final class IosTranscriptReviewTests: XCTestCase { )) XCTAssertEqual(presentation.id, 7) + XCTAssertEqual(presentation.clipboardText, text) XCTAssertEqual(presentation.shareText, text) XCTAssertEqual(presentation.shareSubject, "voice.m4a") XCTAssertFalse(presentation.shareText.contains(presentation.filename)) XCTAssertNil(IosDisplayedTranscript(entryId: 8, filename: "empty.m4a", text: " \n ")) } + func testTranscriptClipboardWritesExactText() throws { + let pasteboardName = UIPasteboard.Name("VoiceInboxTests.TranscriptClipboard.\(UUID().uuidString)") + let pasteboard = try XCTUnwrap(UIPasteboard(name: pasteboardName, create: true)) + defer { UIPasteboard.remove(withName: pasteboardName) } + let text = " exact transcript\n" + + IosTranscriptClipboard.copy(text, to: pasteboard) + + XCTAssertEqual(pasteboard.string, text) + } + func testTranscriptReviewRejectsMissingOrStaleCatalogText() throws { let files = [ file(id: 1, status: .processed, transcript: "stored transcript"), From 9c0db0bfe3acde6d53e786ea215e1be03568d4d4 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Sun, 4 Oct 2026 19:17:44 +0200 Subject: [PATCH 07/11] add files --- .github/workflows/android-release.yml | 171 ++++--- .github/workflows/create-release.yml | 112 +++++ .github/workflows/release-pr.yml | 77 +++ .github/workflows/sync-master-to-develop.yml | 38 ++ Gemfile | 4 + Gemfile.lock | 344 ++++++++++++++ fastlane/Fastfile | 62 +++ .../android/en-US/changelogs/default.txt | 7 +- .../android/ru-RU/changelogs/default.txt | 7 +- release-config.json | 15 + scripts/release_tool.py | 448 ++++++++++++++++++ scripts/test_release_tool.py | 117 +++++ 12 files changed, 1320 insertions(+), 82 deletions(-) create mode 100644 .github/workflows/create-release.yml create mode 100644 .github/workflows/release-pr.yml create mode 100644 .github/workflows/sync-master-to-develop.yml create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 fastlane/Fastfile create mode 100644 release-config.json create mode 100755 scripts/release_tool.py create mode 100644 scripts/test_release_tool.py diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index edfe91b..b7c57b5 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -1,36 +1,73 @@ -name: Android Release +name: Publish Tagged Android Release on: + push: + tags: ["v*"] workflow_dispatch: inputs: - tag_name: - description: "Release tag. Defaults to v." - required: false + tag: + description: Existing immutable release tag to publish + required: true + type: string + workflow_call: + inputs: + tag: + required: true type: string - prerelease: - description: "Mark GitHub release as a prerelease." - required: false - default: false - type: boolean permissions: contents: write jobs: - signed-release: - name: Build signed release artifacts + publish: runs-on: ubuntu-latest - env: ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} - steps: - - name: Check out repository - uses: actions/checkout@v4 + - name: Resolve requested tag + id: requested + env: + INPUT_TAG: ${{ inputs.tag }} + REF_TAG: ${{ github.ref_name }} + run: | + if [ -n "$INPUT_TAG" ]; then TAG="$INPUT_TAG"; else TAG="$REF_TAG"; fi + case "$TAG" in v[0-9]*.[0-9]*.[0-9]*) ;; *) echo "::error::Invalid release tag: $TAG"; exit 2 ;; esac + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v4 + with: + ref: ${{ steps.requested.outputs.tag }} + fetch-depth: 0 + + - name: Verify tag and release identity + env: + TAG: ${{ steps.requested.outputs.tag }} + run: | + git fetch origin --tags + test "$(git rev-parse HEAD)" = "$(git rev-list -n 1 "$TAG")" + test "$(git cat-file -t "$TAG")" = "tag" + test "$(python3 scripts/release_tool.py metadata | python3 -c 'import json,sys; print(json.load(sys.stdin)["tag"])')" = "$TAG" + python3 scripts/release_tool.py validate --skip-branch --allow-existing-tag + + - name: Detect an existing complete GitHub Release + id: existing + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.requested.outputs.tag }} + run: | + mkdir -p release-artifacts + if gh release view "$TAG" >/dev/null 2>&1; then + gh release download "$TAG" --dir release-artifacts + (cd release-artifacts && sha256sum --check SHA256SUMS) + echo "complete=true" >> "$GITHUB_OUTPUT" + else + echo "complete=false" >> "$GITHUB_OUTPUT" + fi - name: Validate signing secrets + if: steps.existing.outputs.complete != 'true' env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} run: | @@ -46,23 +83,28 @@ jobs: fi - name: Set up JDK + if: steps.existing.outputs.complete != 'true' uses: actions/setup-java@v4 with: distribution: temurin java-version: "17" - name: Set up Android SDK + if: steps.existing.outputs.complete != 'true' uses: android-actions/setup-android@v3 - name: Install Android SDK packages + if: steps.existing.outputs.complete != 'true' run: sdkmanager "platforms;android-36" "build-tools;36.0.0" "ndk;30.0.14904198" - name: Set up Rust + if: steps.existing.outputs.complete != 'true' uses: dtolnay/rust-toolchain@stable with: targets: aarch64-linux-android - name: Cache Rust artifacts + if: steps.existing.outputs.complete != 'true' uses: actions/cache@v4 with: path: | @@ -74,12 +116,20 @@ jobs: rust-${{ runner.os }}- - name: Install cargo-ndk + if: steps.existing.outputs.complete != 'true' run: cargo install cargo-ndk --locked - name: Set up Gradle + if: steps.existing.outputs.complete != 'true' uses: gradle/actions/setup-gradle@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + - name: Decode release keystore + if: steps.existing.outputs.complete != 'true' env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} run: | @@ -87,64 +137,45 @@ jobs: printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$keystore" echo "ANDROID_KEYSTORE_FILE=$keystore" >> "$GITHUB_ENV" - - name: Build signed release APK - run: ./gradlew :app:assembleRelease - - - name: Build signed release AAB - run: ./gradlew :app:bundleRelease - - - name: Stage release files - id: release_files + - name: Build signed APK and AAB once + if: steps.existing.outputs.complete != 'true' run: | - version=$(grep 'versionName =' app/build.gradle.kts | head -n1 | cut -d '"' -f2) - if [ -z "$version" ]; then - echo "::error::Unable to read versionName from app/build.gradle.kts" - exit 1 - fi - tag="${{ inputs.tag_name }}" - if [ -z "$tag" ]; then - tag="v$version" - fi - - mkdir -p release-dist - cp app/build/outputs/apk/release/app-release.apk "release-dist/voice-inbox-$version.apk" - cp app/build/outputs/bundle/release/app-release.aab "release-dist/voice-inbox-$version.aab" - - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "tag=$tag" >> "$GITHUB_OUTPUT" - echo "apk=release-dist/voice-inbox-$version.apk" >> "$GITHUB_OUTPUT" - echo "aab=release-dist/voice-inbox-$version.aab" >> "$GITHUB_OUTPUT" - - - name: Upload signed release APK - uses: actions/upload-artifact@v4 - with: - name: voice-inbox-release-apk - path: ${{ steps.release_files.outputs.apk }} - if-no-files-found: error - - - name: Upload signed release AAB - uses: actions/upload-artifact@v4 - with: - name: voice-inbox-release-aab - path: ${{ steps.release_files.outputs.aab }} - if-no-files-found: error + ./gradlew :app:assembleRelease :app:bundleRelease --no-daemon + version=$(python3 scripts/release_tool.py metadata | python3 -c 'import json,sys; print(json.load(sys.stdin)["versionName"])') + cp app/build/outputs/apk/release/app-release.apk "release-artifacts/voice-inbox-$version.apk" + cp app/build/outputs/bundle/release/app-release.aab "release-artifacts/voice-inbox-$version.aab" + (cd release-artifacts && sha256sum "voice-inbox-$version.apk" "voice-inbox-$version.aab" > SHA256SUMS) + (cd release-artifacts && sha256sum --check SHA256SUMS) + + - name: Publish GitHub Release and immutable assets + if: steps.existing.outputs.complete != 'true' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.requested.outputs.tag }} + run: | + CODE=$(python3 scripts/release_tool.py metadata | python3 -c 'import json,sys; print(json.load(sys.stdin)["versionCode"])') + NOTES="fastlane/metadata/android/en-US/changelogs/$CODE.txt" + gh release create "$TAG" release-artifacts/* --verify-tag --title "Voice Inbox $TAG" --notes-file "$NOTES" - - name: Create GitHub release + - name: Decode Google Play service account env: - GITHUB_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ steps.release_files.outputs.tag }} - RELEASE_VERSION: ${{ steps.release_files.outputs.version }} - PRERELEASE: ${{ inputs.prerelease }} + SERVICE_ACCOUNT_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }} run: | - prerelease_args="" - if [ "$PRERELEASE" = "true" ]; then - prerelease_args="--prerelease" + if [ -z "$SERVICE_ACCOUNT_BASE64" ]; then + echo "::error::GOOGLE_PLAY_SERVICE_ACCOUNT is required for Google Play upload" + exit 1 fi + printf '%s' "$SERVICE_ACCOUNT_BASE64" | base64 --decode > service-account.json - gh release create "$RELEASE_TAG" \ - "${{ steps.release_files.outputs.apk }}" \ - "${{ steps.release_files.outputs.aab }}" \ - --target "$GITHUB_SHA" \ - --title "Voice Inbox $RELEASE_VERSION" \ - --notes "Signed Android release artifacts for Voice Inbox $RELEASE_VERSION." \ - $prerelease_args + - name: Upload the published AAB to Play Internal testing + run: | + TAG="${{ steps.requested.outputs.tag }}" + CODE=$(python3 scripts/release_tool.py metadata | python3 -c 'import json,sys; print(json.load(sys.stdin)["versionCode"])') + TRACK=$(python3 scripts/release_tool.py metadata | python3 -c 'import json,sys; print(json.load(sys.stdin)["playTrack"])') + AAB=$(find release-artifacts -maxdepth 1 -name 'voice-inbox-*.aab' | head -n1) + bundle exec fastlane android upload_release \ + aab:"$AAB" \ + version_code:"$CODE" \ + metadata_path:"${{ github.workspace }}/fastlane/metadata/android" \ + json_key:"${{ github.workspace }}/service-account.json" \ + track:"$TRACK" diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 0000000..7460856 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,112 @@ +name: Create Android Release + +on: + pull_request_target: + branches: [master] + types: [closed] + workflow_dispatch: + inputs: + head_branch: + description: Merged release/* or hotfix/* branch for recovery + required: true + type: string + commit_sha: + description: Exact merge commit contained in master + required: true + type: string + +permissions: + contents: write + checks: read + pull-requests: read + +concurrency: + group: android-release-${{ github.event.pull_request.merge_commit_sha || inputs.commit_sha }} + cancel-in-progress: false + +jobs: + create-tag: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && + (startsWith(github.event.pull_request.head.ref, 'release/') || + startsWith(github.event.pull_request.head.ref, 'hotfix/'))) + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.identity.outputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.merge_commit_sha || inputs.commit_sha }} + fetch-depth: 0 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + + - name: Fetch stable history and tags + run: git fetch origin master --tags + + - name: Require successful protected pull-request checks + if: github.event_name == 'pull_request_target' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: gh pr checks "$PR_NUMBER" --required + + - name: Resolve merged release identity + id: identity + env: + HEAD_BRANCH: ${{ github.event.pull_request.head.ref || inputs.head_branch }} + BASE_BRANCH: ${{ github.event.pull_request.base.ref || 'master' }} + MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha || inputs.commit_sha }} + MERGED: ${{ github.event_name == 'workflow_dispatch' || github.event.pull_request.merged }} + run: | + python3 scripts/release_tool.py candidate \ + --head-branch "$HEAD_BRANCH" \ + --base-branch "$BASE_BRANCH" \ + --merge-commit "$MERGE_COMMIT" \ + --merged "$MERGED" > release-candidate.json + tag=$(python3 -c 'import json; print(json.load(open("release-candidate.json"))["tag"])') + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Decode Google Play service account + env: + SERVICE_ACCOUNT_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }} + run: | + if [ -z "$SERVICE_ACCOUNT_BASE64" ]; then + echo "::error::GOOGLE_PLAY_SERVICE_ACCOUNT is required for release publishing" + exit 1 + fi + printf '%s' "$SERVICE_ACCOUNT_BASE64" | base64 --decode > service-account.json + + - name: Repeat repository and Google Play validation + run: >- + python3 scripts/release_tool.py validate --skip-branch --check-play + --service-account service-account.json + + - name: Create immutable annotated tag + env: + TAG: ${{ steps.identity.outputs.tag }} + MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha || inputs.commit_sha }} + run: | + test -z "$(git tag --list "$TAG")" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" "$MERGE_COMMIT" -m "Release $TAG" + git push origin "refs/tags/$TAG" + + publish: + needs: create-tag + uses: ./.github/workflows/android-release.yml + with: + tag: ${{ needs.create-tag.outputs.tag }} + secrets: inherit + + sync-development: + needs: [create-tag, publish] + uses: ./.github/workflows/sync-master-to-develop.yml + with: + tag: ${{ needs.create-tag.outputs.tag }} + secrets: inherit diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml new file mode 100644 index 0000000..6724021 --- /dev/null +++ b/.github/workflows/release-pr.yml @@ -0,0 +1,77 @@ +name: Validate Android Release PR + +on: + pull_request: + branches: [master] + +permissions: + contents: read + +jobs: + validate: + if: startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Install Android SDK packages + run: sdkmanager "platforms;android-36" "build-tools;36.0.0" "ndk;30.0.14904198" + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android + + - name: Cache Rust artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: rust-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'transcribe-rs/**/Cargo.toml') }} + restore-keys: | + rust-${{ runner.os }}- + + - name: Install cargo-ndk + run: cargo install cargo-ndk --locked + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Validate release identity and localized notes + run: python3 scripts/release_tool.py validate + + - name: Test release tooling + run: python3 -m unittest scripts/test_release_tool.py + + - name: Run Android release validation build + run: ./gradlew :shared:jvmTest :app:testDebugUnitTest :app:assembleRelease :app:bundleRelease --no-daemon + + - name: Verify assembled release identity + run: >- + python3 scripts/release_tool.py verify-build + --manifest app/build/intermediates/merged_manifests/release/processReleaseManifest/AndroidManifest.xml + --metadata app/build/outputs/apk/release/output-metadata.json + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: release-pr-validation + path: | + app/build/reports/ + app/build/outputs/apk/release/ + app/build/outputs/bundle/release/ + retention-days: 14 diff --git a/.github/workflows/sync-master-to-develop.yml b/.github/workflows/sync-master-to-develop.yml new file mode 100644 index 0000000..e411227 --- /dev/null +++ b/.github/workflows/sync-master-to-develop.yml @@ -0,0 +1,38 @@ +name: Sync Released Master to Develop + +on: + workflow_dispatch: + inputs: + tag: + description: Released v tag on master + required: true + type: string + workflow_call: + inputs: + tag: + required: true + type: string + +permissions: + contents: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: develop + fetch-depth: 0 + + - name: Merge released master into develop + env: + TAG: ${{ inputs.tag }} + run: | + git fetch origin master develop --tags + test "$(git cat-file -t "$TAG")" = "tag" + git merge-base --is-ancestor "$TAG^{}" origin/master + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git merge --no-ff origin/master -m "Merge released $TAG back into develop" + git push origin HEAD:develop diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..b42dd3c --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem "fastlane", "2.235.0" +gem "multi_json", "~> 1.15" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..001cd65 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,344 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1292.0) + aws-sdk-core (3.257.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + rexml (~> 3.4, >= 3.4.2) + aws-sdk-kms (1.132.0) + aws-sdk-core (~> 3, >= 3.256.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.233.1) + aws-sdk-core (~> 3, >= 3.256.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.3) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + csv (3.3.6) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20260921) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.112.0) + faraday (1.10.6) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.1) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.4) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.1) + fastlane (2.235.0) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.197) + babosa (>= 1.0.3, < 2.0.0) + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 2.4.0, < 5.0.0) + colored (~> 1.2) + commander (~> 4.6) + csv (~> 3.3) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.1.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.3.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 4) + logger (>= 1.6, < 2.0) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3) + naturally (~> 2.2) + nkf (~> 0.2) + optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.1.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.109.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) + addressable (~> 2.5, >= 2.5.1) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) + mini_mime (~> 1.0) + mutex_m + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + google-apis-iamcredentials_v1 (0.28.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.18.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.68.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.9.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (2.2.2) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.7.0) + google-cloud-storage (1.62.1) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) + google-cloud-core (~> 1.6) + googleauth (~> 1.9) + mini_mime (~> 1.0) + google-logging-utils (0.2.0) + googleauth (1.17.4) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) + os (>= 0.9, < 2.0) + pstore (~> 0.1) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.9.0) + mutex_m + jmespath (1.6.2) + json (2.21.2) + jwt (3.3.0) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.21.2) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + nkf (0.3.0) + optparse (0.8.1) + os (1.1.4) + ostruct (0.6.3) + plist (3.7.2) + pstore (0.2.1) + public_suffix (7.0.5) + rake (13.4.2) + representable (3.3.1) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.8.0) + rexml (3.4.4) + rouge (3.28.0) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + security (0.1.5) + signet (0.22.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + simctl (1.6.10) + CFPropertyList + naturally + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.28.1) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + base64 + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + nkf + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + arm64-darwin-25 + ruby + +DEPENDENCIES + fastlane (= 2.235.0) + multi_json (~> 1.15) + +CHECKSUMS + CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261 + abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242 + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263 + atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f + aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b + aws-partitions (1.1292.0) sha256=c7fbe8b878640f12e24c975fd14c30401f4dd112416a2d21f834d3a823c8c4b8 + aws-sdk-core (3.257.0) sha256=a92ad19c7ff668cec6688ee3200ee562da80647a3bbeed9fda02e8b0ca6492c6 + aws-sdk-kms (1.132.0) sha256=094b0097bb3be9d5c1ac87e971ca7f5aebb746801dfb5f0f576480bf98dca69c + aws-sdk-s3 (1.233.1) sha256=cdfb5051266354ae7e93a1443ae3878cceca531d81a890cf8185458ec62919e9 + aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00 + babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c + bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66 + claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e + colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c + colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a + commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9 + csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 + declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9 + digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07 + domain_name (0.6.20260921) sha256=b335949f69fae603fed0ae88c4e09a51e9d8e3ceef4c73a6cfde44eb5c8dee6d + dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8 + emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b + excon (0.112.0) sha256=daf9ac3a4c2fc9aa48383a33da77ecb44fa395111e973084d5c52f6f214ae0f0 + faraday (1.10.6) sha256=7ff4802a6b312876a2241b3e641ce0d5045e168dd871b422c35b505e5261ad4d + faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb + faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689 + faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750 + faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940 + faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b + faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 + faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682 + faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335 + faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7 + faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0 + faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa + faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9 + fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20 + fastlane (2.235.0) sha256=7de12cf4c4e242b68836aef859ba8e6b25bb1db7f6c8e2d705b024fb16a2ed65 + fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847 + gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939 + google-apis-androidpublisher_v3 (0.109.0) sha256=b4fdbb8af4a0d235e86bd173bd51eccd10effdc5de067ab10c1c0d22048a2a56 + google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee + google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106 + google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254 + google-apis-storage_v1 (0.68.0) sha256=43387d0466b4fc569fca05fdef5098ecf135ce173626b5c5f45bfe5e8db959fc + google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4 + google-cloud-env (2.2.2) sha256=94bed40e05a67e9468ce1cb38389fba9a90aa8fc62fc9e173204c1dca59e21e7 + google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3 + google-cloud-storage (1.62.1) sha256=10dbf2ebda3af1b4b841335364cb889827c6cb17bf6c9f8bc5313b6bef7a4d3c + google-logging-utils (0.2.0) sha256=675462b4ea5affa825a3442694ca2d75d0069455a1d0956127207498fca3df7b + googleauth (1.17.4) sha256=acc2cac2a6011048f149c922cbc6c9675719e165aa5000c7fa3876c480fe9ae3 + highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479 + http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6 + httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 + jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + jwt (3.3.0) sha256=44cc34fbc341c148233cf66641522b4bce0dfb1ce55e8073f05c56f44f4ee546 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + multi_json (1.21.2) sha256=245531eaf54bdba57aba515ff08b4503505c5e09911daa89c5a561e2f85bea8e + multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8 + mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 + nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723 + naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01 + nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895 + optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a + os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42 + pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + representable (3.3.1) sha256=8a5fc8eef99f598147eee0390f606386268b429a840419fcfd3f7a8ccc40f566 + retriable (3.8.0) sha256=9f2f1b0207594c7817f17f671587b8ec7587387ac6cebda6c941a802bb98a8e5 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20 + ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef + rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615 + security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7 + signet (0.22.0) sha256=b76d495ccb07ad35dbc89f3e920665a9d8ed717141955034005d7843dcfe4780 + simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b + terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea + terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91 + trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3 + tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48 + tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50 + tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542 + uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc + unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a + word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7 + xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc + xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892 + xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93 + +BUNDLED WITH + 4.0.11 diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..9187d5f --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,62 @@ +default_platform(:android) + +platform :android do + desc "Fail when a version code already exists on a Google Play track" + lane :validate_play_version_code do |options| + version_code = Integer(options.fetch(:version_code)) + json_key = File.expand_path(options.fetch(:json_key)) + tracks = %w[internal alpha beta production] + existing = tracks.to_h do |track| + codes = google_play_track_version_codes( + package_name: "me.maxistar.voiceinbox", + json_key: json_key, + track: track + ).map(&:to_i) + [track, codes] + end + duplicates = existing.select { |_track, codes| codes.include?(version_code) }.keys + UI.user_error!("versionCode #{version_code} already exists on: #{duplicates.join(', ')}") unless duplicates.empty? + end + + desc "Require a version code to exist on one explicit Google Play track" + lane :assert_track_version_code do |options| + version_code = Integer(options.fetch(:version_code)) + track = options.fetch(:track) + codes = google_play_track_version_codes( + package_name: "me.maxistar.voiceinbox", + json_key: File.expand_path(options.fetch(:json_key)), + track: track + ).map(&:to_i) + UI.user_error!("versionCode #{version_code} does not exist on #{track}") unless codes.include?(version_code) + end + + desc "Upload one already-built AAB and localized metadata to an explicit Play track" + lane :upload_release do |options| + aab = File.expand_path(options.fetch(:aab)) + version_code = Integer(options.fetch(:version_code)) + json_key = File.expand_path(options.fetch(:json_key)) + metadata_path = File.expand_path(options.fetch(:metadata_path)) + track = options.fetch(:track) + UI.user_error!("AAB does not exist: #{aab}") unless File.file?(aab) + existing = google_play_track_version_codes( + package_name: "me.maxistar.voiceinbox", + json_key: json_key, + track: track + ).map(&:to_i) + if existing.include?(version_code) + UI.message("versionCode #{version_code} already exists on #{track}; upload is complete") + next + end + upload_to_play_store( + package_name: "me.maxistar.voiceinbox", + aab: aab, + json_key: json_key, + metadata_path: metadata_path, + track: track, + release_status: "completed", + skip_upload_apk: true, + skip_upload_images: true, + skip_upload_screenshots: true + ) + end +end diff --git a/fastlane/metadata/android/en-US/changelogs/default.txt b/fastlane/metadata/android/en-US/changelogs/default.txt index 53b4f2b..43057e9 100644 --- a/fastlane/metadata/android/en-US/changelogs/default.txt +++ b/fastlane/metadata/android/en-US/changelogs/default.txt @@ -1,6 +1 @@ -Initial Google Play release. - -• Import individual audio files or select a recording folder -• Transcribe recordings locally after installing the speech model -• Append results to a Markdown or plain-text document -• Review, play, retry, and process recordings from the task list +Bug fixes and improvements. diff --git a/fastlane/metadata/android/ru-RU/changelogs/default.txt b/fastlane/metadata/android/ru-RU/changelogs/default.txt index dd32797..62cca67 100644 --- a/fastlane/metadata/android/ru-RU/changelogs/default.txt +++ b/fastlane/metadata/android/ru-RU/changelogs/default.txt @@ -1,6 +1 @@ -Первый выпуск в Google Play. - -• Импорт отдельных аудиофайлов или выбор папки с записями -• Локальная расшифровка после установки речевой модели -• Добавление результатов в документ Markdown или обычного текста -• Просмотр, прослушивание, повторная и пакетная обработка записей +Исправления ошибок и улучшения. diff --git a/release-config.json b/release-config.json new file mode 100644 index 0000000..9f71244 --- /dev/null +++ b/release-config.json @@ -0,0 +1,15 @@ +{ + "branches": { + "development": "develop", + "stable": "master", + "releasePrefix": "release/", + "hotfixPrefix": "hotfix/" + }, + "tagPrefix": "v", + "metadataRoot": "fastlane/metadata/android", + "requiredLocales": ["en-US", "ru-RU"], + "ignoredLocales": [], + "englishLocale": "en-US", + "placeholderMarkers": ["TODO: RELEASE NOTES", "TODO_RELEASE_NOTES"], + "playTrack": "internal" +} diff --git a/scripts/release_tool.py b/scripts/release_tool.py new file mode 100755 index 0000000..edbd82e --- /dev/null +++ b/scripts/release_tool.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python3 +"""Repository-owned Android release preparation and validation tool.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path +from typing import Sequence + + +ROOT = Path(__file__).resolve().parents[1] +CONFIG_PATH = ROOT / "release-config.json" +GRADLE_PATH = ROOT / "app" / "build.gradle.kts" +MANIFEST_PATH = ROOT / "app" / "src" / "main" / "AndroidManifest.xml" +SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") +VERSION_CODE_RE = re.compile(r"(?m)^([ \t]*)versionCode[ \t]*=[ \t]*(\d+)[ \t]*$") +VERSION_NAME_RE = re.compile(r'(?m)^([ \t]*)versionName[ \t]*=[ \t]*["\']([^"\']+)["\'][ \t]*$') + + +class ReleaseError(RuntimeError): + pass + + +@dataclass(frozen=True) +class Version: + name: str + code: int + + @property + def semver(self) -> tuple[int, int, int]: + return parse_semver(self.name) + + +@dataclass(frozen=True) +class ReleaseCandidate: + mode: str + head_branch: str + base_branch: str + merge_commit: str + version: Version + tag: str + + +def load_config(path: Path = CONFIG_PATH) -> dict: + with path.open(encoding="utf-8") as stream: + config = json.load(stream) + required = {"branches", "tagPrefix", "metadataRoot", "requiredLocales", "placeholderMarkers", "playTrack"} + missing = required.difference(config) + if missing: + raise ReleaseError(f"release-config.json is missing: {', '.join(sorted(missing))}") + return config + + +def parse_semver(value: str) -> tuple[int, int, int]: + match = SEMVER_RE.fullmatch(value) + if not match: + raise ReleaseError(f"Invalid release SemVer: {value}") + return tuple(int(part) for part in match.groups()) + + +def parse_version_text(text: str) -> Version: + codes = VERSION_CODE_RE.findall(text) + names = VERSION_NAME_RE.findall(text) + if len(codes) != 1 or len(names) != 1: + raise ReleaseError("Gradle must contain exactly one versionCode and versionName") + parse_semver(names[0][1]) + return Version(names[0][1], int(codes[0][1])) + + +def read_version(path: Path = GRADLE_PATH) -> Version: + return parse_version_text(path.read_text(encoding="utf-8")) + + +def read_version_from_ref(ref: str) -> Version: + gradle = run(["git", "show", f"{ref}:app/build.gradle.kts"], check=False) + if not gradle: + raise ReleaseError(f"Candidate {ref} does not contain app/build.gradle.kts") + return parse_version_text(gradle) + + +def write_version(version: Version, path: Path = GRADLE_PATH) -> None: + text = path.read_text(encoding="utf-8") + + def code_repl(match: re.Match[str]) -> str: + return f"{match.group(1)}versionCode = {version.code}" + + def name_repl(match: re.Match[str]) -> str: + return f'{match.group(1)}versionName = "{version.name}"' + + text, code_count = VERSION_CODE_RE.subn(code_repl, text) + text, name_count = VERSION_NAME_RE.subn(name_repl, text) + if code_count != 1 or name_count != 1: + raise ReleaseError("Refusing to update ambiguous Gradle version declarations") + path.write_text(text, encoding="utf-8") + + +def next_version(current: Version, bump: str | None, explicit: str | None) -> Version: + if bool(bump) == bool(explicit): + raise ReleaseError("Specify exactly one of --bump or --version") + major, minor, patch = current.semver + if explicit: + candidate = parse_semver(explicit) + if candidate <= current.semver: + raise ReleaseError("Explicit version must be greater than the current version") + name = explicit + elif bump == "major": + name = f"{major + 1}.0.0" + elif bump == "minor": + name = f"{major}.{minor + 1}.0" + else: + name = f"{major}.{minor}.{patch + 1}" + return Version(name, current.code + 1) + + +def run(command: Sequence[str], *, check: bool = True) -> str: + result = subprocess.run(command, cwd=ROOT, check=False, text=True, capture_output=True) + if check and result.returncode: + message = result.stderr.strip() or result.stdout.strip() or "command failed" + raise ReleaseError(f"{' '.join(command)}: {message}") + return result.stdout.strip() + + +def git_ref_exists(ref: str) -> bool: + return subprocess.run(["git", "show-ref", "--verify", "--quiet", ref], cwd=ROOT, check=False).returncode == 0 + + +def git_is_ancestor(ancestor: str, descendant: str) -> bool: + return subprocess.run(["git", "merge-base", "--is-ancestor", ancestor, descendant], cwd=ROOT, check=False).returncode == 0 + + +def current_branch() -> str: + branch = run(["git", "branch", "--show-current"]) + if not branch: + raise ReleaseError("A branch checkout is required") + return branch + + +def discover_locales(config: dict, root: Path = ROOT) -> list[str]: + metadata_root = root / config["metadataRoot"] + ignored = set(config.get("ignoredLocales", [])) + if not metadata_root.is_dir(): + raise ReleaseError(f"Metadata root does not exist: {metadata_root.relative_to(root)}") + locales = sorted( + item.name for item in metadata_root.iterdir() + if item.is_dir() and item.name not in ignored and (item / "title.txt").is_file() + ) + missing = sorted(set(config["requiredLocales"]) - set(locales)) + if missing: + raise ReleaseError(f"Required Fastlane locales are missing: {', '.join(missing)}") + if not locales: + raise ReleaseError("No Fastlane metadata locales discovered") + return locales + + +def changelog_paths(version: Version, config: dict, root: Path = ROOT) -> list[Path]: + return [ + root / config["metadataRoot"] / locale / "changelogs" / f"{version.code}.txt" + for locale in discover_locales(config, root) + ] + + +def validate_changelogs(version: Version, config: dict, root: Path = ROOT) -> None: + markers = tuple(config["placeholderMarkers"]) + errors = [] + for path in changelog_paths(version, config, root): + if not path.is_file(): + errors.append(f"missing {path.relative_to(root)}") + continue + content = path.read_text(encoding="utf-8").strip() + if not content: + errors.append(f"empty {path.relative_to(root)}") + elif any(marker in content for marker in markers): + errors.append(f"placeholder remains in {path.relative_to(root)}") + if errors: + raise ReleaseError("Invalid localized release notes:\n- " + "\n- ".join(errors)) + + +def create_placeholders(version: Version, config: dict) -> None: + marker = config["placeholderMarkers"][0] + for path in changelog_paths(version, config): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"{marker} ({version.name})\n", encoding="utf-8") + + +def version_from_tag(tag: str, config: dict) -> Version | None: + gradle = run(["git", "show", f"{tag}:app/build.gradle.kts"], check=False) + if not gradle: + return None + try: + return parse_version_text(gradle) + except ReleaseError: + return None + + +def validate_history(version: Version, config: dict, *, require_absent_tag: bool = True) -> None: + expected_tag = config["tagPrefix"] + version.name + if require_absent_tag and git_ref_exists(f"refs/tags/{expected_tag}"): + raise ReleaseError(f"Tag already exists: {expected_tag}") + historical = [ + parsed for tag in run(["git", "tag", "--list", f'{config["tagPrefix"]}*']).splitlines() + if require_absent_tag or tag != expected_tag + if (parsed := version_from_tag(tag, config)) is not None + ] + if historical and version.code <= max(item.code for item in historical): + raise ReleaseError("versionCode must be greater than all tagged releases") + same_code = [item.name for item in historical if item.code == version.code] + if same_code: + raise ReleaseError(f"versionCode {version.code} already belongs to {same_code[0]}") + + +def validate_branch(version: Version, config: dict, *, mode: str | None = None) -> str: + branch = current_branch() + branches = config["branches"] + inferred = mode + if inferred is None: + if branch == f'{branches["releasePrefix"]}{version.name}': + inferred = "release" + elif branch == f'{branches["hotfixPrefix"]}{version.name}': + inferred = "hotfix" + else: + raise ReleaseError(f"Unexpected release branch: {branch}") + base = branches["development"] if inferred == "release" else branches["stable"] + expected = f'{branches[inferred + "Prefix"]}{version.name}' + if branch != expected: + raise ReleaseError(f"Expected branch {expected}, found {branch}") + base_ref = base if git_ref_exists(f"refs/heads/{base}") else f"origin/{base}" + if subprocess.run(["git", "merge-base", "--is-ancestor", base_ref, "HEAD"], cwd=ROOT).returncode: + raise ReleaseError(f"{branch} is not based on {base}") + return inferred + + +def resolve_release_candidate(*, head_branch: str, base_branch: str, merge_commit: str, merged: bool, config: dict, stable_ref: str | None = None) -> ReleaseCandidate: + branches = config["branches"] + stable = branches["stable"] + if not merged: + raise ReleaseError("Release pull request was closed without being merged") + if base_branch != stable: + raise ReleaseError(f"Release pull request must target {stable}, found {base_branch}") + version = read_version_from_ref(merge_commit) + expected_release = f'{branches["releasePrefix"]}{version.name}' + expected_hotfix = f'{branches["hotfixPrefix"]}{version.name}' + if head_branch == expected_release: + mode = "release" + elif head_branch == expected_hotfix: + mode = "hotfix" + else: + raise ReleaseError(f"Candidate branch {head_branch} does not match version {version.name}") + commit_type = run(["git", "cat-file", "-t", f"{merge_commit}^{{commit}}"], check=False) + if commit_type != "commit": + raise ReleaseError(f"Candidate is not a commit: {merge_commit}") + parents = run(["git", "rev-list", "--parents", "-n", "1", merge_commit], check=False).split() + if len(parents) < 3 or parents[0] != merge_commit: + raise ReleaseError(f"Candidate is not a merge commit: {merge_commit}") + if stable_ref is None: + remote_ref = f"origin/{stable}" + stable_ref = remote_ref if git_ref_exists(f"refs/remotes/{remote_ref}") else stable + if not git_is_ancestor(merge_commit, stable_ref): + raise ReleaseError(f"Candidate {merge_commit} is not contained in {stable_ref}") + tag = config["tagPrefix"] + version.name + if git_ref_exists(f"refs/tags/{tag}"): + raise ReleaseError(f"Tag already exists: {tag}") + return ReleaseCandidate(mode, head_branch, base_branch, merge_commit, version, tag) + + +def candidate_conflicts(version: Version, config: dict, mode: str) -> list[str]: + branches = config["branches"] + branch = f'{branches[mode + "Prefix"]}{version.name}' + tag = config["tagPrefix"] + version.name + conflicts = [] + for ref in (f"refs/heads/{branch}", f"refs/remotes/origin/{branch}", f"refs/tags/{tag}"): + if git_ref_exists(ref): + conflicts.append(ref) + if shutil.which("gh"): + gh = subprocess.run(["gh", "pr", "list", "--state", "open", "--head", branch, "--json", "number"], cwd=ROOT, text=True, capture_output=True, check=False) + if gh.returncode == 0 and json.loads(gh.stdout or "[]"): + conflicts.append(f"open pull request for {branch}") + return conflicts + + +def ensure_clean_current_base(config: dict, mode: str) -> str: + if run(["git", "status", "--porcelain"]): + raise ReleaseError("Working tree must be clean") + base = config["branches"]["development" if mode == "release" else "stable"] + if current_branch() != base: + raise ReleaseError(f"Preparation must start on {base}") + run(["git", "fetch", "origin", "--prune", "--tags"]) + local = run(["git", "rev-parse", base]) + remote = run(["git", "rev-parse", f"origin/{base}"]) + if local != remote: + raise ReleaseError(f"{base} must exactly match origin/{base}") + return base + + +def verify_build_identity(version: Version, manifest_path: Path, metadata_path: Path) -> None: + android = "{http://schemas.android.com/apk/res/android}" + root = ET.parse(manifest_path).getroot() + manifest_code = int(root.attrib[android + "versionCode"]) + manifest_name = root.attrib[android + "versionName"] + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + element = metadata["elements"][0] + identities = {(version.code, version.name), (manifest_code, manifest_name), (int(element["versionCode"]), element["versionName"])} + if len(identities) != 1: + raise ReleaseError(f"Build output identity does not match Gradle: {sorted(identities)}") + + +def prepare(args: argparse.Namespace) -> None: + config = load_config() + current = read_version() + candidate = next_version(current, args.bump, args.version) + mode = "hotfix" if args.hotfix else "release" + branches = config["branches"] + branch = f'{branches[mode + "Prefix"]}{candidate.name}' + base = branches["stable" if mode == "hotfix" else "development"] + summary = {"base": base, "branch": branch, "versionName": candidate.name, "versionCode": candidate.code} + if args.dry_run: + conflicts = candidate_conflicts(candidate, config, mode) + validate_history(candidate, config) + if conflicts: + raise ReleaseError("Release candidate already exists: " + ", ".join(conflicts)) + print(json.dumps(summary, indent=2)) + return + if not shutil.which("gh"): + raise ReleaseError("GitHub CLI (gh) is required before preparation can mutate the repository") + ensure_clean_current_base(config, mode) + conflicts = candidate_conflicts(candidate, config, mode) + validate_history(candidate, config) + if conflicts: + raise ReleaseError("Release candidate already exists: " + ", ".join(conflicts)) + run(["git", "switch", "-c", branch]) + write_version(candidate) + create_placeholders(candidate, config) + run(["git", "add", "app/build.gradle.kts", config["metadataRoot"]]) + run(["git", "commit", "-m", f"Prepare release {candidate.name}"]) + run(["git", "push", "-u", "origin", branch]) + checklist = "\n".join(["## Release checklist", "", "- [ ] Localized release notes completed", "- [ ] Release validation passes", "- [ ] Release APK and AAB assemble successfully"]) + run(["gh", "pr", "create", "--base", branches["stable"], "--head", branch, "--title", f"Release {candidate.name}", "--body", checklist]) + print(json.dumps(summary, indent=2)) + + +def metadata(args: argparse.Namespace) -> None: + config = load_config() + version = read_version() + print(json.dumps({ + "versionName": version.name, + "versionCode": version.code, + "tag": config["tagPrefix"] + version.name, + "playTrack": config["playTrack"], + "locales": discover_locales(config), + "changelogs": [str(path.relative_to(ROOT)) for path in changelog_paths(version, config)], + }, indent=2)) + + +def candidate(args: argparse.Namespace) -> None: + resolved = resolve_release_candidate( + head_branch=args.head_branch, + base_branch=args.base_branch, + merge_commit=args.merge_commit, + merged=args.merged == "true", + config=load_config(), + ) + print(json.dumps({ + "mode": resolved.mode, + "headBranch": resolved.head_branch, + "baseBranch": resolved.base_branch, + "mergeCommit": resolved.merge_commit, + "versionName": resolved.version.name, + "versionCode": resolved.version.code, + "tag": resolved.tag, + }, indent=2)) + + +def validate(args: argparse.Namespace) -> None: + config = load_config() + version = read_version() + manifest = MANIFEST_PATH.read_text(encoding="utf-8") + if "android:versionCode" in manifest or "android:versionName" in manifest: + raise ReleaseError("Manifest contains competing Android version declarations") + validate_history(version, config, require_absent_tag=not args.allow_existing_tag) + validate_changelogs(version, config) + if not args.skip_branch: + validate_branch(version, config, mode=args.mode) + if args.check_play: + if not args.service_account: + raise ReleaseError("--check-play requires --service-account") + run([ + "bundle", "exec", "fastlane", "android", "validate_play_version_code", + f"version_code:{version.code}", f"json_key:{Path(args.service_account).resolve()}", + ]) + print(json.dumps({"valid": True, "versionName": version.name, "versionCode": version.code})) + + +def verify_build(args: argparse.Namespace) -> None: + version = read_version() + verify_build_identity(version, Path(args.manifest), Path(args.metadata)) + print(json.dumps({"valid": True, "versionName": version.name, "versionCode": version.code})) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + metadata_parser = commands.add_parser("metadata") + metadata_parser.set_defaults(handler=metadata) + candidate_parser = commands.add_parser("candidate") + candidate_parser.add_argument("--head-branch", required=True) + candidate_parser.add_argument("--base-branch", required=True) + candidate_parser.add_argument("--merge-commit", required=True) + candidate_parser.add_argument("--merged", required=True, choices=("true", "false")) + candidate_parser.set_defaults(handler=candidate) + validate_parser = commands.add_parser("validate") + validate_parser.add_argument("--mode", choices=("release", "hotfix")) + validate_parser.add_argument("--skip-branch", action="store_true") + validate_parser.add_argument("--allow-existing-tag", action="store_true") + validate_parser.add_argument("--check-play", action="store_true") + validate_parser.add_argument("--service-account") + validate_parser.set_defaults(handler=validate) + verify_parser = commands.add_parser("verify-build") + verify_parser.add_argument("--manifest", required=True) + verify_parser.add_argument("--metadata", required=True) + verify_parser.set_defaults(handler=verify_build) + prepare_parser = commands.add_parser("prepare") + group = prepare_parser.add_mutually_exclusive_group(required=True) + group.add_argument("--bump", choices=("patch", "minor", "major")) + group.add_argument("--version") + prepare_parser.add_argument("--hotfix", action="store_true") + prepare_parser.add_argument("--dry-run", action="store_true") + prepare_parser.set_defaults(handler=prepare) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + try: + args = build_parser().parse_args(argv) + args.handler(args) + return 0 + except (ReleaseError, OSError, json.JSONDecodeError, ET.ParseError) as error: + print(f"release-tool: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_release_tool.py b/scripts/test_release_tool.py new file mode 100644 index 0000000..b8af6af --- /dev/null +++ b/scripts/test_release_tool.py @@ -0,0 +1,117 @@ +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import release_tool + + +class ReleaseToolTests(unittest.TestCase): + def test_parse_kotlin_gradle_version(self): + version = release_tool.parse_version_text(''' + defaultConfig { + versionCode = 7 + versionName = "0.4.0" + } + ''') + self.assertEqual(version.code, 7) + self.assertEqual(version.name, "0.4.0") + + def test_write_kotlin_gradle_version_preserves_indentation(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "build.gradle.kts" + path.write_text(' versionCode = 7\n versionName = "0.4.0"\n', encoding="utf-8") + release_tool.write_version(release_tool.Version("0.5.0", 8), path) + self.assertEqual(path.read_text(encoding="utf-8"), ' versionCode = 8\n versionName = "0.5.0"\n') + + def test_next_version_requires_greater_explicit_version(self): + with self.assertRaises(release_tool.ReleaseError): + release_tool.next_version(release_tool.Version("0.4.0", 7), None, "0.4.0") + self.assertEqual(release_tool.next_version(release_tool.Version("0.4.0", 7), "minor", None), release_tool.Version("0.5.0", 8)) + + def test_discover_locales_and_changelog_paths(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for locale in ("en-US", "ru-RU"): + locale_dir = root / "fastlane" / "metadata" / "android" / locale + locale_dir.mkdir(parents=True) + (locale_dir / "title.txt").write_text("Voice Inbox", encoding="utf-8") + config = { + "metadataRoot": "fastlane/metadata/android", + "requiredLocales": ["en-US", "ru-RU"], + "ignoredLocales": [], + } + self.assertEqual(release_tool.discover_locales(config, root), ["en-US", "ru-RU"]) + paths = release_tool.changelog_paths(release_tool.Version("0.5.0", 8), config, root) + self.assertEqual([p.name for p in paths], ["8.txt", "8.txt"]) + + def test_validate_changelogs_rejects_placeholders(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for locale in ("en-US", "ru-RU"): + locale_dir = root / "fastlane" / "metadata" / "android" / locale + changelog_dir = locale_dir / "changelogs" + changelog_dir.mkdir(parents=True) + (locale_dir / "title.txt").write_text("Voice Inbox", encoding="utf-8") + (changelog_dir / "8.txt").write_text("TODO: RELEASE NOTES", encoding="utf-8") + config = { + "metadataRoot": "fastlane/metadata/android", + "requiredLocales": ["en-US", "ru-RU"], + "ignoredLocales": [], + "placeholderMarkers": ["TODO: RELEASE NOTES"], + } + with self.assertRaises(release_tool.ReleaseError): + release_tool.validate_changelogs(release_tool.Version("0.5.0", 8), config, root) + + def test_validate_branch_requires_release_branch_name(self): + config = { + "branches": { + "development": "develop", + "stable": "master", + "releasePrefix": "release/", + "hotfixPrefix": "hotfix/", + } + } + with mock.patch.object(release_tool, "current_branch", return_value="release/0.5.0"), \ + mock.patch.object(release_tool, "git_ref_exists", return_value=True), \ + mock.patch("subprocess.run") as subprocess_run: + subprocess_run.return_value.returncode = 0 + self.assertEqual(release_tool.validate_branch(release_tool.Version("0.5.0", 8), config), "release") + + def test_resolve_candidate_rejects_non_matching_branch(self): + config = { + "branches": { + "development": "develop", + "stable": "master", + "releasePrefix": "release/", + "hotfixPrefix": "hotfix/", + }, + "tagPrefix": "v", + } + with mock.patch.object(release_tool, "read_version_from_ref", return_value=release_tool.Version("0.5.0", 8)): + with self.assertRaises(release_tool.ReleaseError): + release_tool.resolve_release_candidate( + head_branch="feature/not-release", + base_branch="master", + merge_commit="abc123", + merged=True, + config=config, + stable_ref="master", + ) + + def test_verify_build_identity_detects_mismatch(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + manifest = root / "AndroidManifest.xml" + metadata = root / "output-metadata.json" + manifest.write_text('', encoding="utf-8") + metadata.write_text(json.dumps({"elements": [{"versionCode": 9, "versionName": "0.5.0"}]}), encoding="utf-8") + with self.assertRaises(release_tool.ReleaseError): + release_tool.verify_build_identity(release_tool.Version("0.5.0", 8), manifest, metadata) + + +if __name__ == "__main__": + unittest.main() From daf05b14fe8968f6786792676d87eca449c4f841 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Sun, 4 Oct 2026 19:21:32 +0200 Subject: [PATCH 08/11] Prepare release 0.5.0 --- app/build.gradle.kts | 4 ++-- fastlane/metadata/android/en-US/changelogs/8.txt | 1 + fastlane/metadata/android/ru-RU/changelogs/8.txt | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/8.txt create mode 100644 fastlane/metadata/android/ru-RU/changelogs/8.txt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bac4ee9..740899c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,8 +27,8 @@ android { applicationId = "me.maxistar.voiceinbox" minSdk = 24 targetSdk = 36 - versionCode = 7 - versionName = "0.4.0" + versionCode = 8 + versionName = "0.5.0" ndk { abiFilters += "arm64-v8a" } diff --git a/fastlane/metadata/android/en-US/changelogs/8.txt b/fastlane/metadata/android/en-US/changelogs/8.txt new file mode 100644 index 0000000..d3edb7c --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/8.txt @@ -0,0 +1 @@ +TODO: RELEASE NOTES (0.5.0) diff --git a/fastlane/metadata/android/ru-RU/changelogs/8.txt b/fastlane/metadata/android/ru-RU/changelogs/8.txt new file mode 100644 index 0000000..d3edb7c --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/8.txt @@ -0,0 +1 @@ +TODO: RELEASE NOTES (0.5.0) From 584e7bda9d0302ce9e035b24bb506777b27ae435 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Sun, 4 Oct 2026 19:25:01 +0200 Subject: [PATCH 09/11] update release notes --- fastlane/metadata/android/en-US/changelogs/8.txt | 4 +++- fastlane/metadata/android/ru-RU/changelogs/8.txt | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/fastlane/metadata/android/en-US/changelogs/8.txt b/fastlane/metadata/android/en-US/changelogs/8.txt index d3edb7c..1845d8f 100644 --- a/fastlane/metadata/android/en-US/changelogs/8.txt +++ b/fastlane/metadata/android/en-US/changelogs/8.txt @@ -1 +1,3 @@ -TODO: RELEASE NOTES (0.5.0) +RELEASE NOTES (0.5.0) + +Improved voice keyboard experience, added Copy All action, expanded localization, and updated onboarding. \ No newline at end of file diff --git a/fastlane/metadata/android/ru-RU/changelogs/8.txt b/fastlane/metadata/android/ru-RU/changelogs/8.txt index d3edb7c..aea4248 100644 --- a/fastlane/metadata/android/ru-RU/changelogs/8.txt +++ b/fastlane/metadata/android/ru-RU/changelogs/8.txt @@ -1 +1,3 @@ -TODO: RELEASE NOTES (0.5.0) +RELEASE NOTES (0.5.0) + +Улучшена работа голосовой клавиатуры, добавлена кнопка «Копировать всё», расширены переводы и обновлён онбординг. \ No newline at end of file From af31a49ccf12099298ec369a4cbdbdf408bfeb73 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Sun, 4 Oct 2026 19:28:08 +0200 Subject: [PATCH 10/11] update versions --- .github/workflows/android-ci.yml | 7 +++++-- .github/workflows/android-release.yml | 7 +++++-- .github/workflows/release-pr.yml | 7 +++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 9ffc4f0..2392c69 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -24,8 +24,11 @@ jobs: distribution: temurin java-version: "17" - - name: Set up Android SDK - uses: android-actions/setup-android@v3 + - name: Configure Android SDK + run: | + echo "$ANDROID_HOME/cmdline-tools/latest/bin" >> "$GITHUB_PATH" + echo "$ANDROID_HOME/platform-tools" >> "$GITHUB_PATH" + yes | sdkmanager --licenses >/dev/null - name: Install Android SDK packages run: sdkmanager "platforms;android-36" "build-tools;36.0.0" "ndk;30.0.14904198" diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index b7c57b5..35fbd5a 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -89,9 +89,12 @@ jobs: distribution: temurin java-version: "17" - - name: Set up Android SDK + - name: Configure Android SDK if: steps.existing.outputs.complete != 'true' - uses: android-actions/setup-android@v3 + run: | + echo "$ANDROID_HOME/cmdline-tools/latest/bin" >> "$GITHUB_PATH" + echo "$ANDROID_HOME/platform-tools" >> "$GITHUB_PATH" + yes | sdkmanager --licenses >/dev/null - name: Install Android SDK packages if: steps.existing.outputs.complete != 'true' diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 6724021..8e45af8 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -23,8 +23,11 @@ jobs: distribution: temurin java-version: "17" - - name: Set up Android SDK - uses: android-actions/setup-android@v3 + - name: Configure Android SDK + run: | + echo "$ANDROID_HOME/cmdline-tools/latest/bin" >> "$GITHUB_PATH" + echo "$ANDROID_HOME/platform-tools" >> "$GITHUB_PATH" + yes | sdkmanager --licenses >/dev/null - name: Install Android SDK packages run: sdkmanager "platforms;android-36" "build-tools;36.0.0" "ndk;30.0.14904198" From b3a65a8857a6ba60ef8f7ac9f5c6a30632c7bfea Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Sun, 4 Oct 2026 19:30:45 +0200 Subject: [PATCH 11/11] update versions --- .github/workflows/android-ci.yml | 12 +++++++++++- .github/workflows/android-release.yml | 12 +++++++++++- .github/workflows/release-pr.yml | 12 +++++++++++- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 2392c69..2b7f7c5 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -26,9 +26,19 @@ jobs: - name: Configure Android SDK run: | + ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}" + echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV" + echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> "$GITHUB_ENV" + if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then + mkdir -p "$ANDROID_HOME/cmdline-tools" + curl -fsSL https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -o "$RUNNER_TEMP/cmdline-tools.zip" + unzip -q "$RUNNER_TEMP/cmdline-tools.zip" -d "$RUNNER_TEMP/cmdline-tools" + rm -rf "$ANDROID_HOME/cmdline-tools/latest" + mv "$RUNNER_TEMP/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" + fi echo "$ANDROID_HOME/cmdline-tools/latest/bin" >> "$GITHUB_PATH" echo "$ANDROID_HOME/platform-tools" >> "$GITHUB_PATH" - yes | sdkmanager --licenses >/dev/null + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null - name: Install Android SDK packages run: sdkmanager "platforms;android-36" "build-tools;36.0.0" "ndk;30.0.14904198" diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index 35fbd5a..5278084 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -92,9 +92,19 @@ jobs: - name: Configure Android SDK if: steps.existing.outputs.complete != 'true' run: | + ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}" + echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV" + echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> "$GITHUB_ENV" + if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then + mkdir -p "$ANDROID_HOME/cmdline-tools" + curl -fsSL https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -o "$RUNNER_TEMP/cmdline-tools.zip" + unzip -q "$RUNNER_TEMP/cmdline-tools.zip" -d "$RUNNER_TEMP/cmdline-tools" + rm -rf "$ANDROID_HOME/cmdline-tools/latest" + mv "$RUNNER_TEMP/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" + fi echo "$ANDROID_HOME/cmdline-tools/latest/bin" >> "$GITHUB_PATH" echo "$ANDROID_HOME/platform-tools" >> "$GITHUB_PATH" - yes | sdkmanager --licenses >/dev/null + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null - name: Install Android SDK packages if: steps.existing.outputs.complete != 'true' diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 8e45af8..c1e1b01 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -25,9 +25,19 @@ jobs: - name: Configure Android SDK run: | + ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}" + echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV" + echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> "$GITHUB_ENV" + if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then + mkdir -p "$ANDROID_HOME/cmdline-tools" + curl -fsSL https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -o "$RUNNER_TEMP/cmdline-tools.zip" + unzip -q "$RUNNER_TEMP/cmdline-tools.zip" -d "$RUNNER_TEMP/cmdline-tools" + rm -rf "$ANDROID_HOME/cmdline-tools/latest" + mv "$RUNNER_TEMP/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" + fi echo "$ANDROID_HOME/cmdline-tools/latest/bin" >> "$GITHUB_PATH" echo "$ANDROID_HOME/platform-tools" >> "$GITHUB_PATH" - yes | sdkmanager --licenses >/dev/null + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null - name: Install Android SDK packages run: sdkmanager "platforms;android-36" "build-tools;36.0.0" "ndk;30.0.14904198"