diff --git a/CHANGELOG.md b/CHANGELOG.md index 786f9e75..0d900ed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Added Bluetooth audio recording support + +### Changed +- Improved Android 12+ compatibility ## [1.7.1] - 2026-02-14 ### Changed diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 238ea798..f3800476 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,12 +1,15 @@ + import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import java.io.FileInputStream import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.konan.properties.Properties +import java.io.FileInputStream plugins { - alias(libs.plugins.android) + alias(libs.plugins.androidApplication) alias(libs.plugins.ksp) alias(libs.plugins.detekt) + + } val keystorePropertiesFile: File = rootProject.file("keystore.properties") @@ -147,4 +150,7 @@ dependencies { implementation(libs.tandroidlame) implementation(libs.autofittextview) detektPlugins(libs.compose.detekt) + + implementation(project(":store")) + implementation(project(":transcribe")) } diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index f4dda697..823cd42a 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -27,6 +27,8 @@ TooManyFunctions:Activity.kt$org.fossify.voicerecorder.extensions.Activity.kt TooManyFunctions:Context.kt$org.fossify.voicerecorder.extensions.Context.kt TooManyFunctions:MainActivity.kt$MainActivity : SimpleActivity + TooManyFunctions:MediaRecorderWrapper.kt$MediaRecorderWrapper : Recorder + TooManyFunctions:Mp3Recorder.kt$Mp3Recorder : Recorder TooManyFunctions:PlayerFragment.kt$PlayerFragment : MyViewPagerFragmentRefreshRecordingsListener TooManyFunctions:RecorderFragment.kt$RecorderFragment : MyViewPagerFragment TooManyFunctions:RecorderService.kt$RecorderService : Service diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fdb2017a..31b324a5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,10 +7,18 @@ + + + + + + + android:maxSdkVersion="28" /> + + + + , grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + getPagerAdapter()?.onPermissionResult(requestCode, grantResults) + } + override fun onPause() { super.onPause() config.lastUsedViewPagerPage = binding.viewPager.currentItem @@ -110,7 +110,7 @@ class MainActivity : SimpleActivity() { action = STOP_AMPLITUDE_UPDATE try { startService(this) - } catch (ignored: Exception) { + } catch (_: Exception) { } } } @@ -120,7 +120,7 @@ class MainActivity : SimpleActivity() { binding.mainMenu.closeSearch() true } else if (isThirdPartyIntent()) { - setResult(Activity.RESULT_CANCELED, null) + setResult(RESULT_CANCELED, null) false } else { false @@ -150,15 +150,16 @@ class MainActivity : SimpleActivity() { getPagerAdapter()?.searchTextChanged(text) } - binding.mainMenu.requireToolbar().setOnMenuItemClickListener { menuItem -> - when (menuItem.itemId) { - R.id.more_apps_from_us -> launchMoreAppsFromUsIntent() - R.id.settings -> launchSettings() - R.id.about -> launchAbout() - else -> return@setOnMenuItemClickListener false + binding.mainMenu.requireToolbar() + .setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.more_apps_from_us -> launchMoreAppsFromUsIntent() + R.id.settings -> launchSettings() + R.id.about -> launchAbout() + else -> return@setOnMenuItemClickListener false + } + return@setOnMenuItemClickListener true } - return@setOnMenuItemClickListener true - } } private fun updateMenuColors() { @@ -166,25 +167,7 @@ class MainActivity : SimpleActivity() { } private fun tryInitVoiceRecorder() { - if (isRPlus()) { - ensureStoragePermission { granted -> - if (granted) { - setupViewPager() - } else { - toast(org.fossify.commons.R.string.no_storage_permissions) - finish() - } - } - } else { - handlePermission(PERMISSION_WRITE_STORAGE) { - if (it) { - setupViewPager() - } else { - toast(org.fossify.commons.R.string.no_storage_permissions) - finish() - } - } - } + setupViewPager() } private fun setupViewPager() { @@ -201,18 +184,16 @@ class MainActivity : SimpleActivity() { tabDrawables.forEachIndexed { i, drawableId -> binding.mainTabsHolder.newTab() - .setCustomView(org.fossify.commons.R.layout.bottom_tablayout_item).apply { - customView - ?.findViewById(org.fossify.commons.R.id.tab_item_icon) + .setCustomView(org.fossify.commons.R.layout.bottom_tablayout_item) + .apply { + customView?.findViewById(org.fossify.commons.R.id.tab_item_icon) ?.setImageDrawable( AppCompatResources.getDrawable( - this@MainActivity, - drawableId + this@MainActivity, drawableId ) ) - customView - ?.findViewById(org.fossify.commons.R.id.tab_item_label) + customView?.findViewById(org.fossify.commons.R.id.tab_item_label) ?.setText(tabLabels[i]) AutofitHelper.create( @@ -223,18 +204,15 @@ class MainActivity : SimpleActivity() { } } - binding.mainTabsHolder.onTabSelectionChanged( - tabUnselectedAction = { - updateBottomTabItemColors(it.customView, false) - if (it.position == 1 || it.position == 2) { - binding.mainMenu.closeSearch() - } - }, - tabSelectedAction = { - binding.viewPager.currentItem = it.position - updateBottomTabItemColors(it.customView, true) + binding.mainTabsHolder.onTabSelectionChanged(tabUnselectedAction = { + updateBottomTabItemColors(it.customView, false) + if (it.position == 1 || it.position == 2) { + binding.mainMenu.closeSearch() } - ) + }, tabSelectedAction = { + binding.viewPager.currentItem = it.position + updateBottomTabItemColors(it.customView, true) + }) binding.viewPager.adapter = ViewPagerAdapter(this, config.useRecycleBin) binding.viewPager.offscreenPageLimit = 2 @@ -247,16 +225,19 @@ class MainActivity : SimpleActivity() { binding.viewPager.currentItem = 0 } else { binding.viewPager.currentItem = config.lastUsedViewPagerPage - binding.mainTabsHolder.getTabAt(config.lastUsedViewPagerPage)?.select() + binding.mainTabsHolder.getTabAt(config.lastUsedViewPagerPage) + ?.select() } } private fun setupTabColors() { - val activeView = binding.mainTabsHolder.getTabAt(binding.viewPager.currentItem)?.customView + val activeView = + binding.mainTabsHolder.getTabAt(binding.viewPager.currentItem)?.customView updateBottomTabItemColors(activeView, true) for (i in 0 until binding.mainTabsHolder.tabCount) { if (i != binding.viewPager.currentItem) { - val inactiveView = binding.mainTabsHolder.getTabAt(i)?.customView + val inactiveView = + binding.mainTabsHolder.getTabAt(i)?.customView updateBottomTabItemColors(inactiveView, false) } } @@ -266,7 +247,8 @@ class MainActivity : SimpleActivity() { binding.mainTabsHolder.setBackgroundColor(bottomBarColor) } - private fun getPagerAdapter() = (binding.viewPager.adapter as? ViewPagerAdapter) + private fun getPagerAdapter() = + (binding.viewPager.adapter as? ViewPagerAdapter) private fun launchSettings() { hideKeyboard() @@ -274,17 +256,13 @@ class MainActivity : SimpleActivity() { } private fun launchAbout() { - val licenses = LICENSE_EVENT_BUS or - LICENSE_AUDIO_RECORD_VIEW or - LICENSE_ANDROID_LAME or - LICENSE_AUTOFITTEXTVIEW + val licenses = + LICENSE_EVENT_BUS or LICENSE_AUDIO_RECORD_VIEW or LICENSE_ANDROID_LAME or LICENSE_AUTOFITTEXTVIEW val faqItems = arrayListOf( FAQItem( - title = R.string.faq_1_title, - text = R.string.faq_1_text - ), - FAQItem( + title = R.string.faq_1_title, text = R.string.faq_1_text + ), FAQItem( title = org.fossify.commons.R.string.faq_9_title_commons, text = org.fossify.commons.R.string.faq_9_text_commons ) @@ -314,18 +292,25 @@ class MainActivity : SimpleActivity() { ) } - private fun isThirdPartyIntent() = intent?.action == MediaStore.Audio.Media.RECORD_SOUND_ACTION + private fun isThirdPartyIntent() = + intent?.action == MediaStore.Audio.Media.RECORD_SOUND_ACTION @Suppress("unused") @Subscribe(threadMode = ThreadMode.MAIN) fun recordingSaved(event: Events.RecordingSaved) { if (isThirdPartyIntent()) { Intent().apply { - data = event.uri!! + data = event.uri flags = Intent.FLAG_GRANT_READ_URI_PERMISSION - setResult(Activity.RESULT_OK, this) + setResult(RESULT_OK, this) } finish() } } + + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + fun recordingFailed(event: Events.RecordingFailed) { + handleRecordingStoreError(event.exception) + } } diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt b/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt index b4ebb40a..630d3752 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt @@ -2,7 +2,10 @@ package org.fossify.voicerecorder.activities import android.content.Intent import android.media.MediaRecorder +import android.net.Uri import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts import org.fossify.commons.dialogs.ChangeDateTimeFormatDialog import org.fossify.commons.dialogs.ConfirmationDialog import org.fossify.commons.dialogs.RadioGroupDialog @@ -12,7 +15,6 @@ import org.fossify.commons.extensions.beVisible import org.fossify.commons.extensions.beVisibleIf import org.fossify.commons.extensions.formatSize import org.fossify.commons.extensions.getProperPrimaryColor -import org.fossify.commons.extensions.humanizePath import org.fossify.commons.extensions.toast import org.fossify.commons.extensions.updateTextColors import org.fossify.commons.helpers.IS_CUSTOMIZING_COLORS @@ -20,42 +22,102 @@ import org.fossify.commons.helpers.NavigationIcon import org.fossify.commons.helpers.ensureBackgroundThread import org.fossify.commons.helpers.isQPlus import org.fossify.commons.helpers.isTiramisuPlus -import org.fossify.commons.helpers.sumByInt import org.fossify.commons.models.RadioItem import org.fossify.voicerecorder.R import org.fossify.voicerecorder.databinding.ActivitySettingsBinding import org.fossify.voicerecorder.dialogs.FilenamePatternDialog import org.fossify.voicerecorder.dialogs.MoveRecordingsDialog +import org.fossify.voicerecorder.dialogs.TranscriptionModelsDialog import org.fossify.voicerecorder.extensions.config -import org.fossify.voicerecorder.extensions.deleteTrashedRecordings -import org.fossify.voicerecorder.extensions.getAllRecordings -import org.fossify.voicerecorder.extensions.hasRecordings -import org.fossify.voicerecorder.extensions.launchFolderPicker +import org.fossify.voicerecorder.extensions.recordingStore +import org.fossify.voicerecorder.extensions.recordingStoreFor import org.fossify.voicerecorder.helpers.BITRATES import org.fossify.voicerecorder.helpers.DEFAULT_BITRATE import org.fossify.voicerecorder.helpers.DEFAULT_SAMPLING_RATE -import org.fossify.voicerecorder.helpers.EXTENSION_M4A -import org.fossify.voicerecorder.helpers.EXTENSION_MP3 -import org.fossify.voicerecorder.helpers.EXTENSION_OGG import org.fossify.voicerecorder.helpers.SAMPLING_RATES import org.fossify.voicerecorder.helpers.SAMPLING_RATE_BITRATE_LIMITS import org.fossify.voicerecorder.models.Events +import org.fossify.voicerecorder.store.RecordingFormat +import org.fossify.voicerecorder.transcribe.model.ModelCatalog import org.greenrobot.eventbus.EventBus import java.util.Locale import kotlin.math.abs import kotlin.system.exitProcess class SettingsActivity : SimpleActivity() { + companion object { + /** + * Set this extra to true in the [Intent] that starts this activity to focus (scroll to view) the save + * recordings folder field. + */ + const val EXTRA_FOCUS_SAVE_RECORDINGS_FOLDER = "org.fossify.voicerecorder.extra.FOCUS_SAVE_RECORDINGS_FOLDER" + + // (Whisper language code, label string-id). Empty code = auto-detect. + private val TRANSCRIBE_LANGUAGES: List> = listOf( + "" to R.string.transcribe_auto_detect, + "en" to R.string.transcribe_lang_english, + "es" to R.string.transcribe_lang_spanish, + "fr" to R.string.transcribe_lang_french, + "de" to R.string.transcribe_lang_german, + "it" to R.string.transcribe_lang_italian, + "pt" to R.string.transcribe_lang_portuguese, + "zh" to R.string.transcribe_lang_chinese, + "ja" to R.string.transcribe_lang_japanese, + ) + } + private var recycleBinContentSize = 0 private lateinit var binding: ActivitySettingsBinding + private val saveRecordingsFolderPicker = registerForActivityResult( + ActivityResultContracts.OpenDocumentTree() + ) { newUri -> + if (newUri != null) { + val oldUri = config.saveRecordingsFolder + + contentResolver.takePersistableUriPermission( + newUri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + + ensureBackgroundThread { + val hasRecordings = try { + !recordingStore.isEmpty() + } catch (_: Exception) { + // Something went wrong accessing the current store. Swallow the exception to allow the user to + // select a different one. + false + } + + runOnUiThread { + if (newUri != oldUri && hasRecordings) { + MoveRecordingsDialog( + activity = this, oldFolder = oldUri, newFolder = newUri + ) { + config.saveRecordingsFolder = newUri + updateSaveRecordingsFolder(newUri) + } + } else { + config.saveRecordingsFolder = newUri + updateSaveRecordingsFolder(newUri) + } + } + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivitySettingsBinding.inflate(layoutInflater) setContentView(binding.root) setupEdgeToEdge(padBottomSystem = listOf(binding.settingsNestedScrollview)) - setupMaterialScrollListener(binding.settingsNestedScrollview, binding.settingsAppbar) + setupMaterialScrollListener( + binding.settingsNestedScrollview, binding.settingsAppbar + ) + + if (intent.getBooleanExtra(EXTRA_FOCUS_SAVE_RECORDINGS_FOLDER, false)) { + focusSaveRecordingsFolder() + } } override fun onResume() { @@ -75,6 +137,8 @@ class SettingsActivity : SimpleActivity() { setupMicrophoneMode() setupRecordAfterLaunch() setupKeepScreenOn() + setupTranscribeModel() + setupTranscribeLanguage() setupUseRecycleBin() setupEmptyRecycleBin() updateTextColors(binding.settingsNestedScrollview) @@ -84,6 +148,7 @@ class SettingsActivity : SimpleActivity() { binding.settingsGeneralSettingsLabel, binding.settingsRecordingSectionLabel, binding.settingsAudioSectionLabel, + binding.settingsTranscribeSectionLabel, binding.settingsRecycleBinLabel ).forEach { it.setTextColor(getProperPrimaryColor()) @@ -98,7 +163,9 @@ class SettingsActivity : SimpleActivity() { private fun setupCustomizeWidgetColors() { binding.settingsWidgetColorCustomizationHolder.setOnClickListener { - Intent(this, WidgetRecordDisplayConfigureActivity::class.java).apply { + Intent( + this, WidgetRecordDisplayConfigureActivity::class.java + ).apply { putExtra(IS_CUSTOMIZING_COLORS, true) startActivity(this) } @@ -107,8 +174,7 @@ class SettingsActivity : SimpleActivity() { private fun setupUseEnglish() { binding.settingsUseEnglishHolder.beVisibleIf( - (config.wasUseEnglishToggled || Locale.getDefault().language != "en") - && !isTiramisuPlus() + (config.wasUseEnglishToggled || Locale.getDefault().language != "en") && !isTiramisuPlus() ) binding.settingsUseEnglish.isChecked = config.useEnglish binding.settingsUseEnglishHolder.setOnClickListener { @@ -137,36 +203,39 @@ class SettingsActivity : SimpleActivity() { } private fun setupSaveRecordingsFolder() { - binding.settingsSaveRecordingsLabel.text = - addLockedLabelIfNeeded(R.string.save_recordings_in) - binding.settingsSaveRecordings.text = humanizePath(config.saveRecordingsFolder) + binding.settingsSaveRecordingsLabel.text = addLockedLabelIfNeeded(R.string.save_recordings_in) binding.settingsSaveRecordingsHolder.setOnClickListener { - val currentFolder = config.saveRecordingsFolder - launchFolderPicker(currentFolder) { newFolder -> - if (!newFolder.isNullOrEmpty()) { - ensureBackgroundThread { - val hasRecordings = hasRecordings() - runOnUiThread { - if (newFolder != currentFolder && hasRecordings) { - MoveRecordingsDialog( - activity = this, - previousFolder = currentFolder, - newFolder = newFolder - ) { - config.saveRecordingsFolder = newFolder - binding.settingsSaveRecordings.text = - humanizePath(config.saveRecordingsFolder) - } - } else { - config.saveRecordingsFolder = newFolder - binding.settingsSaveRecordings.text = - humanizePath(config.saveRecordingsFolder) - } - } - } - } + saveRecordingsFolderPicker.launch(config.saveRecordingsFolder) + } + + updateSaveRecordingsFolder(config.saveRecordingsFolder) + } + + private fun updateSaveRecordingsFolder(uri: Uri) { + val store = recordingStoreFor(uri) + binding.settingsSaveRecordings.text = store.shortName + + val providerInfo = store.providerInfo + + if (providerInfo != null) { + val providerIcon = providerInfo.loadIcon(packageManager) + val providerLabel = providerInfo.loadLabel(packageManager) + + binding.settingsSaveRecordingsProviderIcon.apply { + visibility = View.VISIBLE + contentDescription = providerLabel + setImageDrawable(providerIcon) } + } else { + binding.settingsSaveRecordingsProviderIcon.visibility = View.GONE } + + } + + private fun focusSaveRecordingsFolder() = binding.settingsSaveRecordingsHolder.post { + binding.settingsNestedScrollview.smoothScrollTo( + 0, binding.settingsSaveRecordingsHolder.top + ) } private fun setupFilenamePattern() { @@ -179,20 +248,21 @@ class SettingsActivity : SimpleActivity() { } private fun setupExtension() { - binding.settingsExtension.text = config.getExtensionText() + binding.settingsExtension.text = config.recordingFormat.getDescription(this) binding.settingsExtensionHolder.setOnClickListener { - val items = arrayListOf( - RadioItem(EXTENSION_M4A, getString(R.string.m4a)), - RadioItem(EXTENSION_MP3, getString(R.string.mp3_experimental)) - ) + val items = RecordingFormat.entries.map { + RadioItem( + it.value, it.getDescription(this), it + ) + }.let { ArrayList(it) } - if (isQPlus()) { - items.add(RadioItem(EXTENSION_OGG, getString(R.string.ogg_opus))) - } + RadioGroupDialog( + this@SettingsActivity, items, config.recordingFormat.value + ) { + val checked = it as RecordingFormat - RadioGroupDialog(this@SettingsActivity, items, config.extension) { - config.extension = it as Int - binding.settingsExtension.text = config.getExtensionText() + config.recordingFormat = checked + binding.settingsExtension.text = checked.getDescription(this) adjustBitrate() adjustSamplingRate() } @@ -202,8 +272,11 @@ class SettingsActivity : SimpleActivity() { private fun setupBitrate() { binding.settingsBitrate.text = getBitrateText(config.bitrate) binding.settingsBitrateHolder.setOnClickListener { - val items = BITRATES[config.extension]!! - .map { RadioItem(it, getBitrateText(it)) } as ArrayList + val items = BITRATES[config.recordingFormat]!!.map { + RadioItem( + it, getBitrateText(it) + ) + } as ArrayList RadioGroupDialog(this@SettingsActivity, items, config.bitrate) { config.bitrate = it as Int @@ -218,11 +291,10 @@ class SettingsActivity : SimpleActivity() { } private fun adjustBitrate() { - val availableBitrates = BITRATES[config.extension]!! + val availableBitrates = BITRATES[config.recordingFormat]!! if (!availableBitrates.contains(config.bitrate)) { val currentBitrate = config.bitrate - val closestBitrate = availableBitrates.minByOrNull { abs(it - currentBitrate) } - ?: DEFAULT_BITRATE + val closestBitrate = availableBitrates.minByOrNull { abs(it - currentBitrate) } ?: DEFAULT_BITRATE config.bitrate = closestBitrate binding.settingsBitrate.text = getBitrateText(config.bitrate) @@ -232,10 +304,15 @@ class SettingsActivity : SimpleActivity() { private fun setupSamplingRate() { binding.settingsSamplingRate.text = getSamplingRateText(config.samplingRate) binding.settingsSamplingRateHolder.setOnClickListener { - val items = getSamplingRatesArray() - .map { RadioItem(it, getSamplingRateText(it)) } as ArrayList + val items = getSamplingRatesArray().map { + RadioItem( + it, getSamplingRateText(it) + ) + } as ArrayList - RadioGroupDialog(this@SettingsActivity, items, config.samplingRate) { + RadioGroupDialog( + this@SettingsActivity, items, config.samplingRate + ) { config.samplingRate = it as Int binding.settingsSamplingRate.text = getSamplingRateText(config.samplingRate) } @@ -247,8 +324,8 @@ class SettingsActivity : SimpleActivity() { } private fun getSamplingRatesArray(): ArrayList { - val baseRates = SAMPLING_RATES[config.extension]!! - val limits = SAMPLING_RATE_BITRATE_LIMITS[config.extension]!! + val baseRates = SAMPLING_RATES[config.recordingFormat]!! + val limits = SAMPLING_RATE_BITRATE_LIMITS[config.recordingFormat]!! val filteredRates = baseRates.filter { config.bitrate in limits[it]!![0]..limits[it]!![1] } as ArrayList @@ -300,7 +377,7 @@ class SettingsActivity : SimpleActivity() { private fun setupEmptyRecycleBin() { ensureBackgroundThread { try { - recycleBinContentSize = getAllRecordings(trashed = true).sumByInt { it.size } + recycleBinContentSize = recordingStore.all(trashed = true).map { it.size }.sum() } catch (_: Exception) { } @@ -321,7 +398,7 @@ class SettingsActivity : SimpleActivity() { negative = org.fossify.commons.R.string.no ) { ensureBackgroundThread { - deleteTrashedRecordings() + recordingStore.deleteTrashed() runOnUiThread { recycleBinContentSize = 0 binding.settingsEmptyRecycleBinSize.text = 0.formatSize() @@ -354,24 +431,55 @@ class SettingsActivity : SimpleActivity() { } private fun showMicrophoneModeDialog() { - val items = getMediaRecorderAudioSources() - .map { microphoneMode -> - RadioItem( - id = microphoneMode, - title = config.getMicrophoneModeText(microphoneMode) - ) - } as ArrayList + val items = getMediaRecorderAudioSources().map { microphoneMode -> + RadioItem( + id = microphoneMode, title = config.getMicrophoneModeText(microphoneMode) + ) + } as ArrayList RadioGroupDialog( - activity = this@SettingsActivity, - items = items, - checkedItemId = config.microphoneMode + activity = this@SettingsActivity, items = items, checkedItemId = config.microphoneMode ) { config.microphoneMode = it as Int binding.settingsMicrophoneMode.text = config.getMicrophoneModeText(config.microphoneMode) } } + private fun setupTranscribeModel() { + updateTranscribeModelLabel() + binding.settingsTranscribeModelHolder.setOnClickListener { + TranscriptionModelsDialog(this) { updateTranscribeModelLabel() } + } + } + + private fun updateTranscribeModelLabel() { + val activeId = config.transcribeModelId ?: ModelCatalog.DEFAULT.id + val active = ModelCatalog.byId(activeId) ?: ModelCatalog.DEFAULT + binding.settingsTranscribeModel.text = active.displayName + } + + private fun setupTranscribeLanguage() { + binding.settingsTranscribeLanguage.text = transcribeLanguageLabel(config.transcribeLanguage) + binding.settingsTranscribeLanguageHolder.setOnClickListener { + val items = TRANSCRIBE_LANGUAGES.map { (code, labelRes) -> + RadioItem(id = code.hashCode(), title = getString(labelRes), value = code) + } as ArrayList + + val checkedHash = config.transcribeLanguage.hashCode() + RadioGroupDialog(this@SettingsActivity, items, checkedHash) { + val code = it as String + config.transcribeLanguage = code + binding.settingsTranscribeLanguage.text = transcribeLanguageLabel(code) + } + } + } + + private fun transcribeLanguageLabel(code: String): String { + val labelRes = TRANSCRIBE_LANGUAGES.firstOrNull { it.first == code }?.second + ?: R.string.transcribe_auto_detect + return getString(labelRes) + } + private fun getMediaRecorderAudioSources(): List { return buildList { add(MediaRecorder.AudioSource.DEFAULT) diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/activities/SimpleActivity.kt b/app/src/main/kotlin/org/fossify/voicerecorder/activities/SimpleActivity.kt index e17eea48..bb7aa65a 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/SimpleActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/SimpleActivity.kt @@ -1,10 +1,30 @@ package org.fossify.voicerecorder.activities +import android.Manifest +import android.app.AuthenticationRequiredException +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import org.fossify.commons.activities.BaseSimpleActivity +import org.fossify.commons.extensions.getAlertDialogBuilder import org.fossify.voicerecorder.R import org.fossify.voicerecorder.helpers.REPOSITORY_NAME open class SimpleActivity : BaseSimpleActivity() { + companion object { + private const val PERMISSION_FIRST_REQUEST_CODE = 10000 + } + + private var permissionCallbacks = mutableMapOf Unit>() + private var permissionNextRequestCode: Int = PERMISSION_FIRST_REQUEST_CODE + + private val authLauncher = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) {} + override fun getAppIconIDs() = arrayListOf( R.mipmap.ic_launcher_red, R.mipmap.ic_launcher_pink, @@ -30,4 +50,71 @@ open class SimpleActivity : BaseSimpleActivity() { override fun getAppLauncherName() = getString(R.string.app_launcher_name) override fun getRepositoryName() = REPOSITORY_NAME + + // NOTE: Need this instead of using `BaseSimpleActivity.handlePermission` because it doesn't always work + // correctly (particularly on old SDKs). Possibly because this app invokes the permission request from multiple + // places and `BaseSimpleActivity` doesn't handle it well? + fun handleExternalStoragePermission( + externalStoragePermission: ExternalStoragePermission, callback: (Boolean?) -> Unit + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // External storage permissions to access MediaStore are no longer needed + callback(true) + return + } + + val permission = when (externalStoragePermission) { + ExternalStoragePermission.READ -> Manifest.permission.READ_EXTERNAL_STORAGE + ExternalStoragePermission.WRITE -> Manifest.permission.WRITE_EXTERNAL_STORAGE + } + + if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) { + callback(true) + return + } + + + val requestCode = permissionNextRequestCode++ + permissionCallbacks[requestCode] = callback + + ActivityCompat.requestPermissions( + this, arrayOf(permission), requestCode + ) + } + + override fun onRequestPermissionsResult( + requestCode: Int, permissions: Array, grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + + val callback = permissionCallbacks.remove(requestCode) + val result = grantResults.firstOrNull()?.let { it == PackageManager.PERMISSION_GRANTED } + + callback?.invoke(result) + } + + open fun handleRecordingStoreError(exception: Exception) { + Log.w(this::class.simpleName, "recording store error", exception) + + if (exception is AuthenticationRequiredException) { + authLauncher.launch(IntentSenderRequest.Builder(exception.userAction).build()) + return + } + + runOnUiThread { + getAlertDialogBuilder().setTitle(getString(R.string.recording_store_error_title)) + .setMessage(getString(R.string.recording_store_error_message)) + .setPositiveButton(org.fossify.commons.R.string.go_to_settings) { _, _ -> + startActivity(Intent(applicationContext, SettingsActivity::class.java).apply { + putExtra(SettingsActivity.EXTRA_FOCUS_SAVE_RECORDINGS_FOLDER, true) + }) + }.setNegativeButton(org.fossify.commons.R.string.cancel, null).create().show() + } + } + +} + +enum class ExternalStoragePermission { + READ, WRITE + } diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/activities/TranscriptActivity.kt b/app/src/main/kotlin/org/fossify/voicerecorder/activities/TranscriptActivity.kt new file mode 100644 index 00000000..e114db19 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/TranscriptActivity.kt @@ -0,0 +1,881 @@ +package org.fossify.voicerecorder.activities + +import android.content.Intent +import android.graphics.drawable.Drawable +import android.media.AudioAttributes +import android.media.MediaPlayer +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.PowerManager +import android.graphics.Color +import android.graphics.Typeface +import android.text.SpannableString +import android.text.style.BackgroundColorSpan +import android.text.style.ForegroundColorSpan +import android.view.MenuItem +import android.view.View +import android.widget.SeekBar +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.widget.SearchView +import org.fossify.commons.dialogs.ConfirmationDialog +import org.fossify.commons.extensions.applyColorFilter +import org.fossify.commons.extensions.copyToClipboard +import org.fossify.commons.extensions.getColoredDrawableWithColor +import org.fossify.commons.extensions.getContrastColor +import org.fossify.commons.extensions.getFormattedDuration +import org.fossify.commons.extensions.getProperPrimaryColor +import org.fossify.commons.extensions.getProperTextColor +import org.fossify.commons.extensions.showErrorToast +import org.fossify.commons.extensions.toast +import org.fossify.commons.extensions.updateTextColors +import org.fossify.commons.helpers.NavigationIcon +import org.fossify.commons.helpers.ensureBackgroundThread +import org.fossify.voicerecorder.R +import org.fossify.voicerecorder.databinding.ActivityTranscriptBinding +import org.fossify.voicerecorder.databinding.ItemTranscriptSegmentBinding +import org.fossify.voicerecorder.extensions.buildShareTranscriptJsonIntent +import org.fossify.voicerecorder.extensions.buildShareTranscriptTextIntent +import org.fossify.voicerecorder.extensions.config +import org.fossify.voicerecorder.extensions.recordingStore +import org.fossify.voicerecorder.extensions.toShareableText +import org.fossify.voicerecorder.helpers.ACTION_CANCEL_TRANSCRIPTION +import org.fossify.voicerecorder.helpers.ACTION_START_TRANSCRIPTION +import org.fossify.voicerecorder.helpers.EXTRA_LANGUAGE +import org.fossify.voicerecorder.helpers.EXTRA_MODEL_ID +import org.fossify.voicerecorder.helpers.EXTRA_RECORDING_URI +import org.fossify.voicerecorder.models.Events +import org.fossify.voicerecorder.models.TranscriptionPhase +import org.fossify.voicerecorder.services.TranscriptionService +import org.fossify.voicerecorder.store.Recording +import org.fossify.voicerecorder.store.Transcript +import org.fossify.voicerecorder.store.TranscriptStore +import org.fossify.voicerecorder.transcribe.model.ModelCatalog +import org.fossify.voicerecorder.transcribe.model.ModelManager +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import java.util.Locale +import java.util.Timer +import java.util.TimerTask + +/** + * Full-screen transcript viewer with toolbar (back, search, overflow), in-page search highlighting, + * tap-to-seek segments, and an embedded mini player. Replaces the previous TranscriptDialog. + */ +@Suppress("TooManyFunctions", "LargeClass") +class TranscriptActivity : SimpleActivity() { + + companion object { + const val EXTRA_RECORDING_URI_STRING = "org.fossify.voicerecorder.extra.RECORDING_URI" + private const val PCT_MAX = 100 + private const val MS_PER_SECOND = 1000L + private const val SEC_PER_MIN = 60L + private const val BYTES_PER_MB = 1_000_000L + private const val PASSIVE_HIGHLIGHT_ALPHA = 0x55000000.toInt() + private const val PLAYHEAD_TINT_ALPHA = 0x33000000.toInt() + private const val SELECTED_TINT_ALPHA = 0x66000000.toInt() + private const val RGB_MASK = 0x00FFFFFF + private const val PLAYHEAD_TICK_MS = 200L + private const val ETA_MIN_FRACTION = 0.05f + } + + private lateinit var binding: ActivityTranscriptBinding + private var recording: Recording? = null + private var currentTranscript: Transcript? = null + private val segmentBindings = mutableListOf() + + private var player: MediaPlayer? = null + private var progressTimer = Timer() + private var busyElapsedTimer = Timer() + private var pendingSeekMs: Int = -1 + private var playOnPreparation = false + + private var searchQuery: String = "" + private val matches = mutableListOf() + private var currentMatchIndex = 0 + private var playheadSegmentIndex: Int = -1 + private var latestPhase: TranscriptionPhase? = null + private var latestFraction: Float = 0f + + private var isSelectionMode: Boolean = false + private val selectedSegmentIndices: java.util.TreeSet = java.util.TreeSet() + private val selectionBackCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + exitSelectionMode() + } + } + + private data class Match(val segmentIndex: Int, val start: Int, val end: Int) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityTranscriptBinding.inflate(layoutInflater) + setContentView(binding.root) + setupEdgeToEdge(padBottomSystem = listOf(binding.transcriptContent)) + + EventBus.getDefault().register(this) + onBackPressedDispatcher.addCallback(this, selectionBackCallback) + setupToolbarMenu() + initMediaPlayer() + wireControls() + loadRecordingAndTranscript() + } + + override fun onResume() { + super.onResume() + setupTopAppBar(binding.transcriptAppbar, NavigationIcon.Arrow) + applyColors() + updateTextColors(binding.transcriptContent) + } + + override fun onDestroy() { + super.onDestroy() + EventBus.getDefault().unregister(this) + progressTimer.cancel() + busyElapsedTimer.cancel() + player?.release() + player = null + } + + private fun setupToolbarMenu() { + val toolbar = binding.transcriptToolbar + toolbar.inflateMenu(R.menu.menu_transcript) + tintToolbarMenuIcons() + + val searchItem = toolbar.menu.findItem(R.id.transcript_menu_search) + val searchView = searchItem.actionView as SearchView + searchView.queryHint = getString(R.string.transcript_search_hint) + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?) = false + override fun onQueryTextChange(newText: String?): Boolean { + applySearchQuery(newText.orEmpty()) + return true + } + }) + searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(item: MenuItem) = true + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + applySearchQuery("") + return true + } + }) + toolbar.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.transcript_menu_share_text -> { shareTranscriptAsText(); true } + R.id.transcript_menu_share_json -> { shareTranscriptAsJson(); true } + R.id.transcript_menu_copy -> { copyTranscriptText(); true } + R.id.transcript_menu_re_transcribe -> { startTranscription(); true } + R.id.transcript_menu_delete -> { confirmDeleteTranscript(); true } + else -> false + } + } + } + + private fun tintToolbarMenuIcons() { + val contrast = getProperPrimaryColor().getContrastColor() + val menu = binding.transcriptToolbar.menu + for (i in 0 until menu.size()) { + menu.getItem(i).icon?.setTint(contrast) + } + binding.transcriptToolbar.overflowIcon?.setTint(contrast) + } + + // ---- setup ---- + + private fun applyColors() { + val primary = getProperPrimaryColor() + val contrast = primary.getContrastColor() + binding.transcriptToolbar.setTitleTextColor(contrast) + binding.transcriptToolbar.navigationIcon?.setTint(contrast) + binding.transcriptToolbar.overflowIcon?.setTint(contrast) + binding.transcriptPlayPauseBtn.applyColorFilter(getProperTextColor()) + } + + private fun wireControls() { + binding.transcriptStartBtn.setOnClickListener { startTranscription() } + binding.transcriptCancelBtn.setOnClickListener { cancelTranscription() } + binding.transcriptSearchPrev.setOnClickListener { goToMatch(currentMatchIndex - 1) } + binding.transcriptSearchNext.setOnClickListener { goToMatch(currentMatchIndex + 1) } + + binding.transcriptPlayPauseBtn.setOnClickListener { togglePlayPause() } + binding.transcriptPlayerProgress.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + if (fromUser) { + player?.seekTo(progress * MS_PER_SECOND.toInt()) + binding.transcriptPlayerCurrent.text = progress.getFormattedDuration() + } + } + override fun onStartTrackingTouch(seekBar: SeekBar) = Unit + override fun onStopTrackingTouch(seekBar: SeekBar) = Unit + }) + } + + private fun loadRecordingAndTranscript() { + val uriStr = intent.getStringExtra(EXTRA_RECORDING_URI_STRING) + if (uriStr == null) { + toast(R.string.recording_store_error_message) + finish() + return + } + val uri = Uri.parse(uriStr) + ensureBackgroundThread { + val rec = recordingStore.all().firstOrNull { it.uri == uri } + ?: recordingStore.all(trashed = true).firstOrNull { it.uri == uri } + if (rec == null) { + runOnUiThread { + toast(R.string.recording_store_error_message) + finish() + } + return@ensureBackgroundThread + } + val transcript = TranscriptStore(this, config.saveRecordingsFolder).read(rec) + runOnUiThread { + recording = rec + currentTranscript = transcript + binding.transcriptToolbar.title = rec.title + prepareMediaSource(rec.uri) + renderState(transcript) + } + } + } + + // ---- state rendering ---- + + private fun renderState(transcript: Transcript?) { + when { + TranscriptionService.isRunning -> renderBusy(getString(R.string.transcribing), 0, indeterminate = true) + transcript != null -> renderReady(transcript) + else -> renderIdle() + } + } + + private fun renderIdle() { + stopBusyElapsedTimer() + binding.transcriptIdle.visibility = View.VISIBLE + binding.transcriptBusy.visibility = View.GONE + binding.transcriptReady.visibility = View.GONE + + val spec = ModelCatalog.byId(config.transcribeModelId ?: ModelCatalog.DEFAULT.id) ?: ModelCatalog.DEFAULT + val mgr = ModelManager(this) + binding.transcriptIdleSubtitle.text = if (mgr.isModelInstalled(spec)) { + spec.displayName + } else { + "${spec.displayName} (~${spec.archiveSizeBytes / BYTES_PER_MB} MB will be downloaded)" + } + } + + private fun renderBusy(label: String, progress: Int, indeterminate: Boolean) { + binding.transcriptIdle.visibility = View.GONE + binding.transcriptBusy.visibility = View.VISIBLE + binding.transcriptReady.visibility = View.GONE + binding.transcriptBusyLabel.text = label + binding.transcriptBusyProgress.isIndeterminate = indeterminate + if (!indeterminate) { + binding.transcriptBusyProgress.setProgressCompat(progress, true) + } + startBusyElapsedTimer() + } + + private fun renderReady(transcript: Transcript) { + stopBusyElapsedTimer() + renderReadyInner(transcript) + } + + private fun renderReadyInner(transcript: Transcript) { + if (isSelectionMode) exitSelectionMode() + binding.transcriptIdle.visibility = View.GONE + binding.transcriptBusy.visibility = View.GONE + binding.transcriptReady.visibility = View.VISIBLE + + val langLabel = transcript.language.ifBlank { "?" } + val processingMs = transcript.processingMs + val processedSuffix = if (processingMs != null && processingMs > 0L) { + " · ${getString(R.string.transcript_processing_time, formatProcessingTime(processingMs))}" + } else { + "" + } + binding.transcriptReadySubtitle.text = + "Language: $langLabel · ${transcript.segments.size} segments$processedSuffix" + + val container = binding.transcriptSegmentsContainer + container.removeAllViews() + segmentBindings.clear() + playheadSegmentIndex = -1 + + val inflater = layoutInflater + transcript.segments.forEachIndexed { idx, segment -> + val itemBinding = ItemTranscriptSegmentBinding.inflate(inflater, container, false) + itemBinding.segmentTimestamp.text = formatTimestamp(segment.startMs) + itemBinding.segmentText.text = segment.text + itemBinding.root.setOnClickListener { + if (isSelectionMode) toggleSegmentSelection(idx) else seekToAndPlay(segment.startMs) + } + itemBinding.root.setOnLongClickListener { + if (isSelectionMode) toggleSegmentSelection(idx) else enterSelectionMode(idx) + true + } + container.addView(itemBinding.root) + segmentBindings.add(itemBinding) + } + + applySearchQuery(searchQuery) + } + + // ---- search ---- + + private fun applySearchQuery(query: String) { + searchQuery = query + val transcript = currentTranscript ?: run { + binding.transcriptSearchBar.visibility = View.GONE + return + } + matches.clear() + if (query.isNotEmpty()) { + collectMatches(transcript, query) + } + currentMatchIndex = 0 + renderHighlights() + renderSearchBar() + if (matches.isNotEmpty()) scrollToMatch(currentMatchIndex) + } + + private fun collectMatches(transcript: Transcript, query: String) { + val needle = query.lowercase(Locale.ROOT) + transcript.segments.forEachIndexed { idx, segment -> + val haystack = segment.text.lowercase(Locale.ROOT) + findOccurrences(haystack, needle).forEach { pos -> + matches.add(Match(idx, pos, pos + query.length)) + } + } + } + + private fun findOccurrences(haystack: String, needle: String): List { + val out = mutableListOf() + var from = 0 + while (from <= haystack.length) { + val pos = haystack.indexOf(needle, from) + if (pos < 0) return out + out.add(pos) + from = pos + 1 + } + return out + } + + private fun renderSearchBar() { + if (searchQuery.isEmpty()) { + binding.transcriptSearchBar.visibility = View.GONE + return + } + binding.transcriptSearchBar.visibility = View.VISIBLE + binding.transcriptSearchCount.text = if (matches.isEmpty()) { + getString(R.string.transcript_search_no_match) + } else { + getString(R.string.transcript_search_count, currentMatchIndex + 1, matches.size) + } + } + + private fun renderHighlights() { + val transcript = currentTranscript ?: return + val highlightColor = getProperPrimaryColor() + val activeBg = highlightColor + val activeFg = highlightColor.getContrastColor() + val passiveBg = (highlightColor and RGB_MASK) or PASSIVE_HIGHLIGHT_ALPHA + + transcript.segments.forEachIndexed { segmentIdx, segment -> + val itemBinding = segmentBindings.getOrNull(segmentIdx) + if (itemBinding != null) { + itemBinding.segmentText.text = highlightSegment( + segment.text, segmentIdx, activeBg, passiveBg, activeFg + ) + } + } + } + + private fun highlightSegment( + text: String, + segmentIdx: Int, + activeBg: Int, + passiveBg: Int, + activeFg: Int, + ): CharSequence { + if (searchQuery.isEmpty()) return text + val span = SpannableString(text) + val segmentMatches = matches.withIndex().filter { it.value.segmentIndex == segmentIdx } + for ((matchIdx, match) in segmentMatches) { + val isActive = matchIdx == currentMatchIndex + val bg = if (isActive) activeBg else passiveBg + span.setSpan(BackgroundColorSpan(bg), match.start, match.end, 0) + if (isActive) { + span.setSpan(ForegroundColorSpan(activeFg), match.start, match.end, 0) + } + } + return span + } + + private fun startBusyElapsedTimer() { + busyElapsedTimer.cancel() + busyElapsedTimer = Timer() + busyElapsedTimer.scheduleAtFixedRate(object : TimerTask() { + override fun run() { + Handler(Looper.getMainLooper()).post { updateBusyElapsedLabel() } + } + }, 0L, MS_PER_SECOND) + } + + private fun stopBusyElapsedTimer() { + busyElapsedTimer.cancel() + } + + private fun updateBusyElapsedLabel() { + val startMs = TranscriptionService.transcriptionStartMs + if (startMs == null) { + binding.transcriptBusyElapsed.text = "" + return + } + val elapsed = (System.currentTimeMillis() - startMs).coerceAtLeast(0L) + val elapsedStr = formatMmSs(elapsed) + val etaStr = computeEtaLabel(elapsed) + binding.transcriptBusyElapsed.text = if (etaStr != null) { + getString(R.string.transcript_elapsed_with_eta, elapsedStr, etaStr) + } else { + getString(R.string.transcript_elapsed, elapsedStr) + } + } + + private fun computeEtaLabel(elapsedMs: Long): String? { + if (latestPhase != TranscriptionPhase.TRANSCRIBING) return null + if (latestFraction < ETA_MIN_FRACTION) return null + val remainingMs = (elapsedMs * (1f - latestFraction) / latestFraction).toLong() + if (remainingMs <= 0L) return null + return formatMmSs(remainingMs) + } + + private fun formatMmSs(ms: Long): String { + val totalSec = ms / MS_PER_SECOND + val mm = totalSec / SEC_PER_MIN + val ss = totalSec % SEC_PER_MIN + return String.format(Locale.ROOT, "%02d:%02d", mm, ss) + } + + private fun goToMatch(index: Int) { + if (matches.isEmpty()) return + val total = matches.size + currentMatchIndex = ((index % total) + total) % total + renderHighlights() + renderSearchBar() + scrollToMatch(currentMatchIndex) + } + + private fun scrollToMatch(index: Int) { + val match = matches.getOrNull(index) ?: return + val itemBinding = segmentBindings.getOrNull(match.segmentIndex) ?: return + val targetTop = itemBinding.root.top + binding.transcriptSegmentsScroller.post { + binding.transcriptSegmentsScroller.smoothScrollTo(0, targetTop) + } + } + + // ---- player ---- + + private fun initMediaPlayer() { + player = MediaPlayer().apply { + setWakeMode(this@TranscriptActivity, PowerManager.PARTIAL_WAKE_LOCK) + setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build() + ) + setOnPreparedListener { + binding.transcriptPlayerProgress.max = duration / 1000 + binding.transcriptPlayerMax.text = (duration / 1000).getFormattedDuration() + if (pendingSeekMs >= 0) { + try { + seekTo(pendingSeekMs) + binding.transcriptPlayerProgress.progress = pendingSeekMs / 1000 + } catch (_: IllegalStateException) { + } + pendingSeekMs = -1 + } + if (playOnPreparation) { + start() + setupProgressTimer() + binding.transcriptPlayPauseBtn.setImageDrawable(playPauseIcon(true)) + } + } + setOnCompletionListener { + progressTimer.cancel() + binding.transcriptPlayerProgress.progress = binding.transcriptPlayerProgress.max + binding.transcriptPlayPauseBtn.setImageDrawable(playPauseIcon(false)) + } + } + } + + private fun prepareMediaSource(uri: Uri) { + val mp = player ?: return + try { + mp.reset() + mp.setDataSource(this, uri) + mp.prepareAsync() + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + // setDataSource and prepareAsync each declare multiple checked exceptions + // (IOException, IllegalStateException, IllegalArgumentException, SecurityException); + // surface any of them via a single user-facing toast. + showErrorToast(e) + } + } + + private fun seekToAndPlay(positionMs: Long) { + val mp = player ?: return + val target = positionMs.toInt() + playOnPreparation = true + try { + mp.seekTo(target) + if (!mp.isPlaying) mp.start() + setupProgressTimer() + binding.transcriptPlayerProgress.progress = target / 1000 + binding.transcriptPlayerCurrent.text = (target / 1000).getFormattedDuration() + binding.transcriptPlayPauseBtn.setImageDrawable(playPauseIcon(true)) + } catch (_: IllegalStateException) { + pendingSeekMs = target + } + } + + private fun togglePlayPause() { + val mp = player ?: return + try { + if (mp.isPlaying) { + mp.pause() + progressTimer.cancel() + binding.transcriptPlayPauseBtn.setImageDrawable(playPauseIcon(false)) + } else { + mp.start() + setupProgressTimer() + binding.transcriptPlayPauseBtn.setImageDrawable(playPauseIcon(true)) + } + } catch (_: IllegalStateException) { + } + } + + private fun setupProgressTimer() { + progressTimer.cancel() + progressTimer = Timer() + progressTimer.scheduleAtFixedRate(object : TimerTask() { + override fun run() { + Handler(Looper.getMainLooper()).post { + val mp = player ?: return@post + val ms = mp.currentPosition + val seconds = ms / MS_PER_SECOND.toInt() + binding.transcriptPlayerProgress.progress = seconds + binding.transcriptPlayerCurrent.text = seconds.getFormattedDuration() + updatePlayheadSegment(ms.toLong()) + } + } + }, PLAYHEAD_TICK_MS, PLAYHEAD_TICK_MS) + } + + private fun updatePlayheadSegment(positionMs: Long) { + val segments = currentTranscript?.segments ?: return + val newIndex = segments.indexOfFirst { positionMs in it.startMs until it.endMs } + if (newIndex == playheadSegmentIndex) return + + val previous = playheadSegmentIndex + playheadSegmentIndex = newIndex + if (previous >= 0) applyRowStyle(previous) + if (newIndex < 0) return + applyRowStyle(newIndex) + segmentBindings.getOrNull(newIndex)?.let { autoScrollToCurrentSegment(it) } + } + + /** + * Compute the row's visual state from selection > playhead > none. Selection wins + * because it's user-initiated; the playhead can pass through silently underneath. + */ + private fun applyRowStyle(segmentIdx: Int) { + val itemBinding = segmentBindings.getOrNull(segmentIdx) ?: return + val isSelected = segmentIdx in selectedSegmentIndices + val isPlayhead = segmentIdx == playheadSegmentIndex + val primary = getProperPrimaryColor() + val bg = when { + isSelected -> (primary and RGB_MASK) or SELECTED_TINT_ALPHA + isPlayhead -> (primary and RGB_MASK) or PLAYHEAD_TINT_ALPHA + else -> Color.TRANSPARENT + } + itemBinding.root.setBackgroundColor(bg) + val timestampColor = if (isPlayhead && !isSelected) primary else getProperTextColor() + val timestampStyle = if (isPlayhead) Typeface.BOLD else Typeface.NORMAL + itemBinding.segmentTimestamp.setTextColor(timestampColor) + itemBinding.segmentTimestamp.setTypeface(null, timestampStyle) + } + + private fun autoScrollToCurrentSegment(itemBinding: ItemTranscriptSegmentBinding) { + val scroller = binding.transcriptSegmentsScroller + val rowTop = itemBinding.root.top + val rowBottom = rowTop + itemBinding.root.height + val visibleTop = scroller.scrollY + val visibleBottom = visibleTop + scroller.height + val isOffScreen = rowBottom > visibleBottom || rowTop < visibleTop + if (isOffScreen) { + scroller.post { scroller.smoothScrollTo(0, rowTop) } + } + } + + private fun playPauseIcon(isPlaying: Boolean): Drawable { + val resId = if (isPlaying) { + org.fossify.commons.R.drawable.ic_pause_vector + } else { + org.fossify.commons.R.drawable.ic_play_vector + } + return resources.getColoredDrawableWithColor(drawableId = resId, color = getProperTextColor()) + } + + // ---- transcription actions ---- + + private fun startTranscription() { + val rec = recording ?: return + if (TranscriptionService.isRunning) { + toast(R.string.transcribing) + return + } + val intent = Intent(this, TranscriptionService::class.java).apply { + action = ACTION_START_TRANSCRIPTION + putExtra(EXTRA_RECORDING_URI, rec.uri.toString()) + putExtra(EXTRA_MODEL_ID, config.transcribeModelId ?: ModelCatalog.DEFAULT.id) + putExtra(EXTRA_LANGUAGE, config.transcribeLanguage) + } + startForegroundService(intent) + renderBusy(getString(R.string.transcribing), 0, indeterminate = true) + } + + private fun cancelTranscription() { + val intent = Intent(this, TranscriptionService::class.java).apply { + action = ACTION_CANCEL_TRANSCRIPTION + } + startService(intent) + } + + private fun shareTranscriptAsText() { + val rec = recording ?: return + val transcript = currentTranscript ?: run { toast(R.string.transcript_failed); return } + startActivity(buildShareTranscriptTextIntent(transcript.toShareableText(rec), rec.title)) + } + + private fun shareTranscriptAsJson() { + val rec = recording ?: return + ensureBackgroundThread { + val uri = TranscriptStore(this, config.saveRecordingsFolder).sidecarUri(rec) + runOnUiThread { + if (uri == null) { toast(R.string.transcript_failed); return@runOnUiThread } + startActivity(buildShareTranscriptJsonIntent(uri, rec.title)) + } + } + } + + private fun copyTranscriptText() { + val rec = recording ?: return + val transcript = currentTranscript ?: run { toast(R.string.transcript_failed); return } + copyToClipboard(transcript.toShareableText(rec)) + toast(R.string.transcript_copied) + } + + private fun confirmDeleteTranscript() { + val rec = recording ?: return + ConfirmationDialog( + activity = this, + message = getString(R.string.delete_transcript) + "?", + positive = org.fossify.commons.R.string.yes, + negative = org.fossify.commons.R.string.no, + ) { + ensureBackgroundThread { + TranscriptStore(this, config.saveRecordingsFolder).delete(rec) + EventBus.getDefault().post(Events.TranscriptDeleted(rec.uri)) + runOnUiThread { + currentTranscript = null + renderIdle() + } + } + } + } + + // ---- multi-segment selection ---- + + private fun enterSelectionMode(initialIdx: Int) { + if (isSelectionMode) return + isSelectionMode = true + selectedSegmentIndices.clear() + selectedSegmentIndices.add(initialIdx) + selectionBackCallback.isEnabled = true + swapToSelectionToolbar() + applyRowStyle(initialIdx) + updateSelectionTitle() + } + + private fun exitSelectionMode() { + if (!isSelectionMode) return + val previouslySelected = selectedSegmentIndices.toList() + isSelectionMode = false + selectedSegmentIndices.clear() + selectionBackCallback.isEnabled = false + previouslySelected.forEach { applyRowStyle(it) } + restoreNormalToolbar() + } + + private fun toggleSegmentSelection(idx: Int) { + if (idx in selectedSegmentIndices) { + selectedSegmentIndices.remove(idx) + if (selectedSegmentIndices.isEmpty()) { + exitSelectionMode() + return + } + } else { + selectedSegmentIndices.add(idx) + } + applyRowStyle(idx) + updateSelectionTitle() + } + + private fun selectAllSegments() { + val transcript = currentTranscript ?: return + if (transcript.segments.isEmpty()) return + val newlySelected = transcript.segments.indices.filter { it !in selectedSegmentIndices } + selectedSegmentIndices.addAll(transcript.segments.indices.toList()) + newlySelected.forEach { applyRowStyle(it) } + updateSelectionTitle() + } + + private fun copySelectedSegments() { + val text = selectionAsText() ?: return + copyToClipboard(text) + toast(R.string.transcript_copied) + exitSelectionMode() + } + + private fun shareSelectedSegments() { + val text = selectionAsText() ?: return + val rec = recording ?: return + startActivity(buildShareTranscriptTextIntent(text, rec.title)) + } + + private fun selectionAsText(): String? { + val transcript = currentTranscript ?: return null + if (selectedSegmentIndices.isEmpty()) return null + return selectedSegmentIndices.joinToString("\n") { idx -> + val seg = transcript.segments[idx] + "[${formatTimestamp(seg.startMs)}] ${seg.text}" + } + } + + private fun swapToSelectionToolbar() { + val toolbar = binding.transcriptToolbar + val contrast = getProperPrimaryColor().getContrastColor() + toolbar.menu.clear() + toolbar.inflateMenu(R.menu.menu_transcript_selection) + tintToolbarMenuIcons() + toolbar.navigationIcon = resources.getColoredDrawableWithColor( + org.fossify.commons.R.drawable.ic_cross_vector, contrast + ) + toolbar.setNavigationOnClickListener { exitSelectionMode() } + toolbar.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.transcript_sel_copy -> { copySelectedSegments(); true } + R.id.transcript_sel_share -> { shareSelectedSegments(); true } + R.id.transcript_sel_select_all -> { selectAllSegments(); true } + else -> false + } + } + } + + private fun restoreNormalToolbar() { + val toolbar = binding.transcriptToolbar + toolbar.menu.clear() + toolbar.title = recording?.title ?: getString(R.string.transcript) + setupToolbarMenu() + setupTopAppBar(binding.transcriptAppbar, NavigationIcon.Arrow) + applyColors() + } + + private fun updateSelectionTitle() { + binding.transcriptToolbar.title = getString( + R.string.transcript_selected_count, selectedSegmentIndices.size + ) + } + + // ---- formatting ---- + + private fun formatTimestamp(ms: Long): String { + val totalSec = ms / MS_PER_SECOND + val mm = totalSec / SEC_PER_MIN + val ss = totalSec % SEC_PER_MIN + return String.format(Locale.ROOT, "%02d:%02d", mm, ss) + } + + private fun formatProcessingTime(ms: Long): String { + val totalSec = ms / MS_PER_SECOND + return if (totalSec >= SEC_PER_MIN) { + val minutes = totalSec / SEC_PER_MIN + val seconds = totalSec % SEC_PER_MIN + getString(R.string.transcript_processing_time_minutes, minutes, seconds) + } else { + getString(R.string.transcript_processing_time_seconds, ms / MS_PER_SECOND.toFloat()) + } + } + + private fun isOurs(uri: Uri): Boolean = recording?.uri == uri + + // ---- event subscriptions ---- + + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + fun onTranscriptionStarted(e: Events.TranscriptionStarted) { + if (!isOurs(e.recordingUri)) return + renderBusy(getString(R.string.transcribing), 0, indeterminate = true) + } + + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + fun onTranscriptionProgress(e: Events.TranscriptionProgress) { + if (!isOurs(e.recordingUri)) return + latestPhase = e.phase + latestFraction = e.fraction + val labelRes = when (e.phase) { + TranscriptionPhase.DOWNLOADING_MODEL -> R.string.downloading_model + TranscriptionPhase.DECODING -> R.string.decoding_audio + TranscriptionPhase.TRANSCRIBING -> R.string.transcribing + TranscriptionPhase.WRITING -> R.string.transcribing + } + val pct = (e.fraction * PCT_MAX).toInt().coerceIn(0, PCT_MAX) + renderBusy("${getString(labelRes)} $pct%", pct, indeterminate = false) + } + + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + fun onTranscriptionCompleted(e: Events.TranscriptionCompleted) { + if (!isOurs(e.recordingUri)) return + val rec = recording ?: return + ensureBackgroundThread { + val transcript = TranscriptStore(this, config.saveRecordingsFolder).read(rec) + runOnUiThread { + currentTranscript = transcript + if (transcript != null) renderReady(transcript) else renderIdle() + } + } + } + + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + fun onTranscriptionFailed(e: Events.TranscriptionFailed) { + if (!isOurs(e.recordingUri)) return + toast(getString(R.string.transcript_failed, e.cause.message ?: "?")) + renderState(currentTranscript) + } + + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + fun onTranscriptionCancelled(e: Events.TranscriptionCancelled) { + if (!isOurs(e.recordingUri)) return + toast(R.string.transcript_cancelled) + renderState(currentTranscript) + } +} diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/activities/WidgetRecordDisplayConfigureActivity.kt b/app/src/main/kotlin/org/fossify/voicerecorder/activities/WidgetRecordDisplayConfigureActivity.kt index 32450faa..57edcd82 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/WidgetRecordDisplayConfigureActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/WidgetRecordDisplayConfigureActivity.kt @@ -1,6 +1,5 @@ package org.fossify.voicerecorder.activities -import android.app.Activity import android.appwidget.AppWidgetManager import android.content.Intent import android.content.res.ColorStateList @@ -34,13 +33,17 @@ class WidgetRecordDisplayConfigureActivity : SimpleActivity() { public override fun onCreate(savedInstanceState: Bundle?) { useDynamicTheme = false super.onCreate(savedInstanceState) - setResult(Activity.RESULT_CANCELED) + setResult(RESULT_CANCELED) binding = WidgetRecordDisplayConfigBinding.inflate(layoutInflater) setContentView(binding.root) - setupEdgeToEdge(padTopSystem = listOf(binding.configHolder), padBottomSystem = listOf(binding.root)) + setupEdgeToEdge( + padTopSystem = listOf(binding.configHolder), + padBottomSystem = listOf(binding.root) + ) initVariables() - val isCustomizingColors = intent.extras?.getBoolean(IS_CUSTOMIZING_COLORS) ?: false + val isCustomizingColors = + intent.extras?.getBoolean(IS_CUSTOMIZING_COLORS) ?: false mWidgetId = intent.extras?.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID) ?: AppWidgetManager.INVALID_APPWIDGET_ID @@ -52,7 +55,9 @@ class WidgetRecordDisplayConfigureActivity : SimpleActivity() { binding.configWidgetColor.setOnClickListener { pickBackgroundColor() } val primaryColor = getProperPrimaryColor() - binding.configWidgetSeekbar.setColors(getProperTextColor(), primaryColor, primaryColor) + binding.configWidgetSeekbar.setColors( + getProperTextColor(), primaryColor, primaryColor + ) if (!isCustomizingColors && !isOrWasThankYouInstalled()) { mFeatureLockedDialog = FeatureLockedDialog(this) { @@ -62,7 +67,8 @@ class WidgetRecordDisplayConfigureActivity : SimpleActivity() { } } - binding.configSave.backgroundTintList = ColorStateList.valueOf(getProperPrimaryColor()) + binding.configSave.backgroundTintList = + ColorStateList.valueOf(getProperPrimaryColor()) binding.configSave.setTextColor(getProperPrimaryColor().getContrastColor()) } @@ -79,7 +85,9 @@ class WidgetRecordDisplayConfigureActivity : SimpleActivity() { mWidgetColor = config.widgetBgColor @Suppress("DEPRECATION") if (mWidgetColor == resources.getColor(R.color.default_widget_bg_color) && isDynamicTheme()) { - mWidgetColor = resources.getColor(org.fossify.commons.R.color.you_primary_color, theme) + mWidgetColor = resources.getColor( + org.fossify.commons.R.color.you_primary_color, theme + ) } mWidgetAlpha = Color.alpha(mWidgetColor) / 255.toFloat() @@ -90,7 +98,9 @@ class WidgetRecordDisplayConfigureActivity : SimpleActivity() { Color.blue(mWidgetColor) ) - binding.configWidgetSeekbar.setOnSeekBarChangeListener(seekbarChangeListener) + binding.configWidgetSeekbar.setOnSeekBarChangeListener( + seekbarChangeListener + ) binding.configWidgetSeekbar.progress = (mWidgetAlpha * 100).toInt() updateColors() } @@ -101,13 +111,15 @@ class WidgetRecordDisplayConfigureActivity : SimpleActivity() { Intent().apply { putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mWidgetId) - setResult(Activity.RESULT_OK, this) + setResult(RESULT_OK, this) } finish() } private fun pickBackgroundColor() { - ColorPickerDialog(this, mWidgetColorWithoutTransparency) { wasPositivePressed, color -> + ColorPickerDialog( + this, mWidgetColorWithoutTransparency + ) { wasPositivePressed, color -> if (wasPositivePressed) { mWidgetColorWithoutTransparency = color updateColors() @@ -122,7 +134,9 @@ class WidgetRecordDisplayConfigureActivity : SimpleActivity() { this, MyWidgetRecordDisplayProvider::class.java ).apply { - putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(mWidgetId)) + putExtra( + AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(mWidgetId) + ) sendBroadcast(this) } } @@ -133,14 +147,17 @@ class WidgetRecordDisplayConfigureActivity : SimpleActivity() { binding.configImage.background.mutate().applyColorFilter(mWidgetColor) } - private val seekbarChangeListener = object : SeekBar.OnSeekBarChangeListener { - override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { - mWidgetAlpha = progress.toFloat() / 100.toFloat() - updateColors() - } + private val seekbarChangeListener = + object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged( + seekBar: SeekBar, progress: Int, fromUser: Boolean + ) { + mWidgetAlpha = progress.toFloat() / 100.toFloat() + updateColors() + } - override fun onStartTrackingTouch(seekBar: SeekBar) {} + override fun onStartTrackingTouch(seekBar: SeekBar) {} - override fun onStopTrackingTouch(seekBar: SeekBar) {} - } + override fun onStopTrackingTouch(seekBar: SeekBar) {} + } } diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt index f31c8129..e2c7b939 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt @@ -4,8 +4,11 @@ import android.annotation.SuppressLint import android.view.Menu import android.view.View import android.view.ViewGroup +import androidx.appcompat.widget.PopupMenu import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller import org.fossify.commons.adapters.MyRecyclerViewAdapter +import org.fossify.commons.dialogs.ConfirmationDialog +import org.fossify.commons.extensions.copyToClipboard import org.fossify.commons.extensions.formatDate import org.fossify.commons.extensions.formatSize import org.fossify.commons.extensions.getFormattedDuration @@ -13,34 +16,47 @@ import org.fossify.commons.extensions.getProperPrimaryColor import org.fossify.commons.extensions.openPathIntent import org.fossify.commons.extensions.setupViewBackground import org.fossify.commons.extensions.sharePathsIntent +import org.fossify.commons.extensions.toast import org.fossify.commons.helpers.ensureBackgroundThread import org.fossify.commons.views.MyRecyclerView import org.fossify.voicerecorder.BuildConfig import org.fossify.voicerecorder.R +import org.fossify.voicerecorder.activities.ExternalStoragePermission import org.fossify.voicerecorder.activities.SimpleActivity import org.fossify.voicerecorder.databinding.ItemRecordingBinding import org.fossify.voicerecorder.dialogs.DeleteConfirmationDialog import org.fossify.voicerecorder.dialogs.RenameRecordingDialog +import org.fossify.voicerecorder.extensions.buildShareTranscriptJsonIntent +import org.fossify.voicerecorder.extensions.buildShareTranscriptTextIntent import org.fossify.voicerecorder.extensions.config -import org.fossify.voicerecorder.extensions.deleteRecordings -import org.fossify.voicerecorder.extensions.trashRecordings +import org.fossify.voicerecorder.extensions.recordingStore +import org.fossify.voicerecorder.extensions.toShareableText import org.fossify.voicerecorder.interfaces.RefreshRecordingsListener import org.fossify.voicerecorder.models.Events -import org.fossify.voicerecorder.models.Recording +import org.fossify.voicerecorder.store.Recording +import org.fossify.voicerecorder.store.TranscriptStore import org.greenrobot.eventbus.EventBus import kotlin.math.min class RecordingsAdapter( activity: SimpleActivity, - var recordings: ArrayList, + var recordings: MutableList, private val refreshListener: RefreshRecordingsListener, recyclerView: MyRecyclerView, + private val onTranscriptIndicatorClick: (Recording) -> Unit, itemClick: (Any) -> Unit -) : MyRecyclerViewAdapter(activity, recyclerView, itemClick), - RecyclerViewFastScroller.OnPopupTextUpdate { +) : MyRecyclerViewAdapter(activity, recyclerView, itemClick), RecyclerViewFastScroller.OnPopupTextUpdate { var currRecordingId = 0 + /** Map of recording id → first-line transcript preview. Recordings absent from this map have no transcript. */ + var transcriptPreviews: Map = emptyMap() + set(value) { + field = value + @SuppressLint("NotifyDataSetChanged") + notifyDataSetChanged() + } + init { setupDragListener(true) } @@ -48,23 +64,20 @@ class RecordingsAdapter( override fun getActionMenuId() = R.menu.cab_recordings override fun prepareActionMode(menu: Menu) { - menu.apply { - findItem(R.id.cab_rename).isVisible = isOneItemSelected() - findItem(R.id.cab_open_with).isVisible = isOneItemSelected() - } + val selected = getSelectedItems() + val anyHasTranscript = selected.any { it.id in transcriptPreviews } + menu.findItem(R.id.cab_delete_transcript).isVisible = anyHasTranscript } override fun actionItemPressed(id: Int) { - if (selectedKeys.isEmpty()) { - return - } - + if (selectedKeys.isEmpty()) return when (id) { - R.id.cab_rename -> renameRecording() - R.id.cab_share -> shareRecordings() - R.id.cab_delete -> askConfirmDelete() + R.id.cab_share -> shareRecordings(getSelectedItems()) + R.id.cab_delete -> askConfirmDelete(getSelectedItems()) R.id.cab_select_all -> selectAll() - R.id.cab_open_with -> openRecordingWith() + R.id.cab_delete_transcript -> askConfirmDeleteTranscripts( + getSelectedItems().filter { it.id in transcriptPreviews } + ) } } @@ -93,9 +106,7 @@ class RecordingsAdapter( override fun onBindViewHolder(holder: ViewHolder, position: Int) { val recording = recordings[position] holder.bindView( - any = recording, - allowSingleClick = true, - allowLongClick = true + any = recording, allowSingleClick = true, allowLongClick = true ) { itemView, _ -> setupView(itemView, recording) } @@ -105,8 +116,6 @@ class RecordingsAdapter( override fun getItemCount() = recordings.size - private fun getItemWithKey(key: Int): Recording? = recordings.firstOrNull { it.id == key } - @SuppressLint("NotifyDataSetChanged") fun updateItems(newItems: ArrayList) { if (newItems.hashCode() != recordings.hashCode()) { @@ -116,38 +125,34 @@ class RecordingsAdapter( } } - private fun renameRecording() { - val recording = getItemWithKey(selectedKeys.first()) ?: return + private fun renameRecording(recording: Recording) { RenameRecordingDialog(activity, recording) { finishActMode() refreshListener.refreshRecordings() } } - private fun openRecordingWith() { - val recording = getItemWithKey(selectedKeys.first()) ?: return - val path = recording.path + private fun openRecordingWith(recording: Recording) { activity.openPathIntent( - path = path, + path = recording.uri.toString(), forceChooser = true, applicationId = BuildConfig.APPLICATION_ID, forceMimeType = "audio/*" ) } - private fun shareRecordings() { - val selectedItems = getSelectedItems() - val paths = selectedItems.map { it.path } + private fun shareRecordings(items: List) { + if (items.isEmpty()) return + val paths = items.map { it.uri.toString() } activity.sharePathsIntent(paths, BuildConfig.APPLICATION_ID) } - private fun askConfirmDelete() { - val itemsCnt = selectedKeys.size - val firstItem = getSelectedItems().firstOrNull() ?: return - val items = if (itemsCnt == 1) { - "\"${firstItem.title}\"" + private fun askConfirmDelete(items: List) { + if (items.isEmpty()) return + val displayName = if (items.size == 1) { + "\"${items.first().title}\"" } else { - resources.getQuantityString(R.plurals.delete_recordings, itemsCnt, itemsCnt) + resources.getQuantityString(R.plurals.delete_recordings, items.size, items.size) } val baseString = if (activity.config.useRecycleBin) { @@ -155,65 +160,53 @@ class RecordingsAdapter( } else { R.string.delete_recordings_confirmation } - val question = String.format(resources.getString(baseString), items) + val question = String.format(resources.getString(baseString), displayName) DeleteConfirmationDialog( - activity = activity, - message = question, - showSkipRecycleBinOption = activity.config.useRecycleBin + activity = activity, message = question, showSkipRecycleBinOption = activity.config.useRecycleBin ) { skipRecycleBin -> ensureBackgroundThread { val toRecycleBin = !skipRecycleBin && activity.config.useRecycleBin if (toRecycleBin) { - trashRecordings() + trashRecordings(items) } else { - deleteRecordings() + deleteRecordings(items) } } } } - private fun deleteRecordings() { - if (selectedKeys.isEmpty()) { - return - } - - val oldRecordingIndex = recordings.indexOfFirst { it.id == currRecordingId } - val recordingsToRemove = recordings - .filter { selectedKeys.contains(it.id) } as ArrayList - - val positions = getSelectedItemPositions() - - activity.deleteRecordings(recordingsToRemove) { success -> - if (success) { - doDeleteAnimation(oldRecordingIndex, recordingsToRemove, positions) + private fun deleteRecordings(items: List) { + if (items.isEmpty()) return + runWithWriteExternalStoragePermission { + val oldRecordingIndex = recordings.indexOfFirst { it.id == currRecordingId } + val positions = items.mapNotNull { item -> + recordings.indexOfFirst { it.id == item.id }.takeIf { it >= 0 } + } + ensureBackgroundThread { + activity.recordingStore.delete(items) + doDeleteAnimation(oldRecordingIndex, items, ArrayList(positions)) } } } - private fun trashRecordings() { - if (selectedKeys.isEmpty()) { - return - } - - val oldRecordingIndex = recordings.indexOfFirst { it.id == currRecordingId } - val recordingsToRemove = recordings - .filter { selectedKeys.contains(it.id) } as ArrayList - - val positions = getSelectedItemPositions() - - activity.trashRecordings(recordingsToRemove) { success -> - if (success) { - doDeleteAnimation(oldRecordingIndex, recordingsToRemove, positions) + private fun trashRecordings(items: List) { + if (items.isEmpty()) return + runWithWriteExternalStoragePermission { + val oldRecordingIndex = recordings.indexOfFirst { it.id == currRecordingId } + val positions = items.mapNotNull { item -> + recordings.indexOfFirst { it.id == item.id }.takeIf { it >= 0 } + } + ensureBackgroundThread { + activity.recordingStore.trash(items) + doDeleteAnimation(oldRecordingIndex, items, ArrayList(positions)) EventBus.getDefault().post(Events.RecordingTrashUpdated()) } } } private fun doDeleteAnimation( - oldRecordingIndex: Int, - recordingsToRemove: ArrayList, - positions: ArrayList + oldRecordingIndex: Int, recordingsToRemove: List, positions: ArrayList ) { recordings.removeAll(recordingsToRemove.toSet()) activity.runOnUiThread { @@ -249,10 +242,7 @@ class RecordingsAdapter( recordingFrame.isSelected = selectedKeys.contains(recording.id) arrayListOf( - recordingTitle, - recordingDate, - recordingDuration, - recordingSize + recordingTitle, recordingDate, recordingDuration, recordingSize, transcriptPreview ).forEach { it.setTextColor(textColor) } @@ -265,8 +255,141 @@ class RecordingsAdapter( recordingDate.text = recording.timestamp.formatDate(root.context) recordingDuration.text = recording.duration.getFormattedDuration() recordingSize.text = recording.size.formatSize() + + transcriptIndicator.visibility = View.VISIBLE + val preview = transcriptPreviews[recording.id] + if (preview != null) { + transcriptPreview.text = preview + transcriptPreview.setTypeface(null, android.graphics.Typeface.ITALIC) + transcriptPreview.alpha = TRANSCRIPT_PREVIEW_ALPHA + transcriptIndicatorIcon.alpha = 1f + transcriptIndicatorIcon.setColorFilter(root.context.getProperPrimaryColor()) + } else { + transcriptPreview.text = activity.getString(R.string.transcribe) + transcriptPreview.setTypeface(null, android.graphics.Typeface.NORMAL) + transcriptPreview.alpha = TRANSCRIBE_PROMPT_ALPHA + transcriptIndicatorIcon.alpha = TRANSCRIBE_PROMPT_ALPHA + transcriptIndicatorIcon.clearColorFilter() + } + transcriptIndicator.setOnClickListener { onTranscriptIndicatorClick(recording) } + transcriptIndicator.setOnLongClickListener { + // Forward long-press to the row so it triggers selection mode like the rest. + view.performLongClick() + } + + recordingOverflow.setColorFilter(textColor) + recordingOverflow.setOnClickListener { showRowOverflowMenu(it, recording) } } } + companion object { + private const val TRANSCRIPT_PREVIEW_ALPHA = 0.7f + private const val TRANSCRIBE_PROMPT_ALPHA = 0.5f + } + override fun onChange(position: Int) = recordings.getOrNull(position)?.title ?: "" + + // Runs the callback only after the WRITE_STORAGE_PERMISSON has been granted or if running on a SDK that no + // longer requires it. + private fun runWithWriteExternalStoragePermission(callback: () -> Unit) = (activity as SimpleActivity?)?.run { + handleExternalStoragePermission(ExternalStoragePermission.WRITE) { granted -> + if (granted == true) { + callback() + } + } + } + + private fun transcriptStore() = TranscriptStore(activity, activity.config.saveRecordingsFolder) + + private fun shareTranscriptAsText(recording: Recording) { + ensureBackgroundThread { + val transcript = transcriptStore().read(recording) + activity.runOnUiThread { + if (transcript == null) { + activity.toast(R.string.transcript_failed) + return@runOnUiThread + } + val text = transcript.toShareableText(recording) + activity.startActivity( + activity.buildShareTranscriptTextIntent(text, recording.title) + ) + } + } + } + + private fun shareTranscriptAsJson(recording: Recording) { + ensureBackgroundThread { + val uri = transcriptStore().sidecarUri(recording) + activity.runOnUiThread { + if (uri == null) { + activity.toast(R.string.transcript_failed) + return@runOnUiThread + } + activity.startActivity( + activity.buildShareTranscriptJsonIntent(uri, recording.title) + ) + } + } + } + + private fun copyTranscript(recording: Recording) { + ensureBackgroundThread { + val transcript = transcriptStore().read(recording) + activity.runOnUiThread { + if (transcript == null) { + activity.toast(R.string.transcript_failed) + return@runOnUiThread + } + activity.copyToClipboard(transcript.toShareableText(recording)) + activity.toast(R.string.transcript_copied) + } + } + } + + private fun askConfirmDeleteTranscripts(items: List) { + if (items.isEmpty()) return + ConfirmationDialog( + activity = activity, + message = activity.getString(R.string.delete_transcript) + "?", + positive = org.fossify.commons.R.string.yes, + negative = org.fossify.commons.R.string.no, + ) { + ensureBackgroundThread { + val store = transcriptStore() + items.forEach { store.delete(it) } + activity.runOnUiThread { + finishActMode() + refreshListener.refreshRecordings() + } + } + } + } + + /** + * Inflates the per-row 3-dot popup, hides transcript-related items if the recording + * has none, and routes selections to the matching single-item action. + */ + private fun showRowOverflowMenu(anchor: View, recording: Recording) { + val hasTranscript = recording.id in transcriptPreviews + val popup = PopupMenu(activity, anchor) + popup.menuInflater.inflate(R.menu.menu_recording_row, popup.menu) + popup.menu.findItem(R.id.row_copy_transcript).isVisible = hasTranscript + popup.menu.findItem(R.id.row_share_transcript_text).isVisible = hasTranscript + popup.menu.findItem(R.id.row_share_transcript_json).isVisible = hasTranscript + popup.menu.findItem(R.id.row_delete_transcript).isVisible = hasTranscript + popup.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.row_rename -> { renameRecording(recording); true } + R.id.row_open_with -> { openRecordingWith(recording); true } + R.id.row_share_audio -> { shareRecordings(listOf(recording)); true } + R.id.row_delete_audio -> { askConfirmDelete(listOf(recording)); true } + R.id.row_copy_transcript -> { copyTranscript(recording); true } + R.id.row_share_transcript_text -> { shareTranscriptAsText(recording); true } + R.id.row_share_transcript_json -> { shareTranscriptAsJson(recording); true } + R.id.row_delete_transcript -> { askConfirmDeleteTranscripts(listOf(recording)); true } + else -> false + } + } + popup.show() + } } diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/TrashAdapter.kt b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/TrashAdapter.kt index 8d2d4a0a..02240a64 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/TrashAdapter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/TrashAdapter.kt @@ -16,11 +16,10 @@ import org.fossify.commons.views.MyRecyclerView import org.fossify.voicerecorder.R import org.fossify.voicerecorder.activities.SimpleActivity import org.fossify.voicerecorder.databinding.ItemRecordingBinding -import org.fossify.voicerecorder.extensions.deleteRecordings -import org.fossify.voicerecorder.extensions.restoreRecordings +import org.fossify.voicerecorder.extensions.recordingStore import org.fossify.voicerecorder.interfaces.RefreshRecordingsListener import org.fossify.voicerecorder.models.Events -import org.fossify.voicerecorder.models.Recording +import org.fossify.voicerecorder.store.Recording import org.greenrobot.eventbus.EventBus class TrashAdapter( @@ -28,8 +27,7 @@ class TrashAdapter( var recordings: ArrayList, private val refreshListener: RefreshRecordingsListener, recyclerView: MyRecyclerView -) : - MyRecyclerViewAdapter(activity, recyclerView, {}), RecyclerViewFastScroller.OnPopupTextUpdate { +) : MyRecyclerViewAdapter(activity, recyclerView, {}), RecyclerViewFastScroller.OnPopupTextUpdate { init { setupDragListener(true) @@ -72,9 +70,7 @@ class TrashAdapter( override fun onBindViewHolder(holder: ViewHolder, position: Int) { val recording = recordings[position] holder.bindView( - any = recording, - allowSingleClick = true, - allowLongClick = true + any = recording, allowSingleClick = true, allowLongClick = true ) { itemView, _ -> setupView(itemView, recording) } @@ -98,16 +94,15 @@ class TrashAdapter( return } - val recordingsToRestore = recordings - .filter { selectedKeys.contains(it.id) } as ArrayList + val recordingsToRestore = recordings.filter { selectedKeys.contains(it.id) }.toList() val positions = getSelectedItemPositions() - activity.restoreRecordings(recordingsToRestore) { success -> - if (success) { - doDeleteAnimation(recordingsToRestore, positions) - EventBus.getDefault().post(Events.RecordingTrashUpdated()) - } + ensureBackgroundThread { + activity.recordingStore.restore(recordingsToRestore) + + doDeleteAnimation(recordingsToRestore, positions) + EventBus.getDefault().post(Events.RecordingTrashUpdated()) } } @@ -135,21 +130,18 @@ class TrashAdapter( return } - val recordingsToRemove = recordings - .filter { selectedKeys.contains(it.id) } as ArrayList + val recordingsToRemove = recordings.filter { selectedKeys.contains(it.id) }.toList() val positions = getSelectedItemPositions() - activity.deleteRecordings(recordingsToRemove) { success -> - if (success) { - doDeleteAnimation(recordingsToRemove, positions) - } + ensureBackgroundThread { + activity.recordingStore.delete(recordingsToRemove) + doDeleteAnimation(recordingsToRemove, positions) } } private fun doDeleteAnimation( - recordingsToRemove: ArrayList, - positions: ArrayList + recordingsToRemove: List, positions: ArrayList ) { recordings.removeAll(recordingsToRemove.toSet()) activity.runOnUiThread { @@ -173,10 +165,7 @@ class TrashAdapter( recordingFrame.isSelected = selectedKeys.contains(recording.id) arrayListOf( - recordingTitle, - recordingDate, - recordingDuration, - recordingSize + recordingTitle, recordingDate, recordingDuration, recordingSize ).forEach { it.setTextColor(textColor) } diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/ViewPagerAdapter.kt b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/ViewPagerAdapter.kt index 28d2bc1b..f9aba73d 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/ViewPagerAdapter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/ViewPagerAdapter.kt @@ -8,6 +8,7 @@ import org.fossify.voicerecorder.R import org.fossify.voicerecorder.activities.SimpleActivity import org.fossify.voicerecorder.fragments.MyViewPagerFragment import org.fossify.voicerecorder.fragments.PlayerFragment +import org.fossify.voicerecorder.fragments.RecorderFragment import org.fossify.voicerecorder.fragments.TrashFragment class ViewPagerAdapter( @@ -59,6 +60,10 @@ class ViewPagerAdapter( } } + fun onPermissionResult(requestCode: Int, grantResults: IntArray) { + (fragments[0] as? RecorderFragment)?.onPermissionResult(requestCode, grantResults) + } + fun searchTextChanged(text: String) { (fragments[1] as? PlayerFragment)?.onSearchTextChanged(text) if (showRecycleBin) { diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/MoveRecordingsDialog.kt b/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/MoveRecordingsDialog.kt index 24f73b37..8c902efe 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/MoveRecordingsDialog.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/MoveRecordingsDialog.kt @@ -1,21 +1,21 @@ package org.fossify.voicerecorder.dialogs +import android.net.Uri import androidx.appcompat.app.AlertDialog -import org.fossify.commons.activities.BaseSimpleActivity import org.fossify.commons.extensions.getAlertDialogBuilder import org.fossify.commons.extensions.getProperPrimaryColor import org.fossify.commons.extensions.setupDialogStuff import org.fossify.commons.helpers.MEDIUM_ALPHA import org.fossify.commons.helpers.ensureBackgroundThread import org.fossify.voicerecorder.R +import org.fossify.voicerecorder.activities.SimpleActivity import org.fossify.voicerecorder.databinding.DialogMoveRecordingsBinding -import org.fossify.voicerecorder.extensions.getAllRecordings -import org.fossify.voicerecorder.extensions.moveRecordings +import org.fossify.voicerecorder.store.RecordingStore class MoveRecordingsDialog( - private val activity: BaseSimpleActivity, - private val previousFolder: String, - private val newFolder: String, + private val activity: SimpleActivity, + private val oldFolder: Uri, + private val newFolder: Uri, private val callback: () -> Unit ) { private lateinit var dialog: AlertDialog @@ -25,17 +25,13 @@ class MoveRecordingsDialog( } init { - activity.getAlertDialogBuilder() - .setPositiveButton(org.fossify.commons.R.string.yes, null) - .setNegativeButton(org.fossify.commons.R.string.no, null) - .apply { + activity.getAlertDialogBuilder().setPositiveButton(org.fossify.commons.R.string.yes, null) + .setNegativeButton(org.fossify.commons.R.string.no, null).apply { activity.setupDialogStuff( - view = binding.root, - dialog = this, - titleId = R.string.move_recordings + view = binding.root, dialog = this, titleId = R.string.move_recordings ) { dialog = it - dialog.setOnDismissListener { callback() } + dialog.setOnCancelListener { callback() } dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { callback() dialog.dismiss() @@ -62,17 +58,15 @@ class MoveRecordingsDialog( } } - private fun moveAllRecordings() { - ensureBackgroundThread { - activity.moveRecordings( - recordingsToMove = activity.getAllRecordings(), - sourceParent = previousFolder, - destinationParent = newFolder - ) { - activity.runOnUiThread { - callback() - dialog.dismiss() - } + private fun moveAllRecordings() = ensureBackgroundThread { + RecordingStore(activity, oldFolder).let { store -> + try { + store.migrate(newFolder) + activity.runOnUiThread { callback() } + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + activity.handleRecordingStoreError(e) + } finally { + dialog.dismiss() } } } diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/RenameRecordingDialog.kt b/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/RenameRecordingDialog.kt index afe55f0d..81008605 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/RenameRecordingDialog.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/RenameRecordingDialog.kt @@ -1,31 +1,24 @@ package org.fossify.voicerecorder.dialogs +import android.provider.DocumentsContract import androidx.appcompat.app.AlertDialog import org.fossify.commons.activities.BaseSimpleActivity import org.fossify.commons.extensions.getAlertDialogBuilder import org.fossify.commons.extensions.getFilenameExtension -import org.fossify.commons.extensions.getParentPath import org.fossify.commons.extensions.isAValidFilename -import org.fossify.commons.extensions.renameDocumentSdk30 -import org.fossify.commons.extensions.renameFile import org.fossify.commons.extensions.setupDialogStuff import org.fossify.commons.extensions.showErrorToast import org.fossify.commons.extensions.showKeyboard import org.fossify.commons.extensions.toast import org.fossify.commons.extensions.value import org.fossify.commons.helpers.ensureBackgroundThread -import org.fossify.commons.helpers.isRPlus import org.fossify.voicerecorder.databinding.DialogRenameRecordingBinding -import org.fossify.voicerecorder.extensions.config import org.fossify.voicerecorder.models.Events -import org.fossify.voicerecorder.models.Recording +import org.fossify.voicerecorder.store.Recording import org.greenrobot.eventbus.EventBus -import java.io.File class RenameRecordingDialog( - val activity: BaseSimpleActivity, - val recording: Recording, - val callback: () -> Unit + val activity: BaseSimpleActivity, val recording: Recording, val callback: () -> Unit ) { init { val binding = DialogRenameRecordingBinding.inflate(activity.layoutInflater).apply { @@ -33,14 +26,10 @@ class RenameRecordingDialog( } val view = binding.root - activity.getAlertDialogBuilder() - .setPositiveButton(org.fossify.commons.R.string.ok, null) - .setNegativeButton(org.fossify.commons.R.string.cancel, null) - .apply { + activity.getAlertDialogBuilder().setPositiveButton(org.fossify.commons.R.string.ok, null) + .setNegativeButton(org.fossify.commons.R.string.cancel, null).apply { activity.setupDialogStuff( - view = view, - dialog = this, - titleId = org.fossify.commons.R.string.rename + view = view, dialog = this, titleId = org.fossify.commons.R.string.rename ) { alertDialog -> alertDialog.showKeyboard(binding.renameRecordingTitle) alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { @@ -56,11 +45,7 @@ class RenameRecordingDialog( } ensureBackgroundThread { - if (isRPlus()) { - renameRecording(recording, newTitle) - } else { - renameRecordingLegacy(recording, newTitle) - } + renameRecording(recording, newTitle) activity.runOnUiThread { callback() @@ -77,24 +62,10 @@ class RenameRecordingDialog( val newDisplayName = "${newTitle.removeSuffix(".$oldExtension")}.$oldExtension" try { - val path = "${activity.config.saveRecordingsFolder}/${recording.title}" - val newPath = "${path.getParentPath()}/$newDisplayName" - activity.handleSAFDialogSdk30(path) { - val success = activity.renameDocumentSdk30(path, newPath) - if (success) { - EventBus.getDefault().post(Events.RecordingCompleted()) - } - } + DocumentsContract.renameDocument(activity.contentResolver, recording.uri, newDisplayName) + EventBus.getDefault().post(Events.RecordingCompleted()) } catch (e: Exception) { activity.showErrorToast(e) } } - - private fun renameRecordingLegacy(recording: Recording, newTitle: String) { - val oldExtension = recording.title.getFilenameExtension() - val oldPath = recording.path - val newFilename = "${newTitle.removeSuffix(".$oldExtension")}.$oldExtension" - val newPath = File(oldPath.getParentPath(), newFilename).absolutePath - activity.renameFile(oldPath, newPath, false) - } } diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/TranscriptionModelsDialog.kt b/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/TranscriptionModelsDialog.kt new file mode 100644 index 00000000..739380f3 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/TranscriptionModelsDialog.kt @@ -0,0 +1,193 @@ +package org.fossify.voicerecorder.dialogs + +import android.content.Intent +import android.view.LayoutInflater +import android.view.View +import androidx.appcompat.app.AlertDialog +import org.fossify.commons.activities.BaseSimpleActivity +import org.fossify.commons.dialogs.ConfirmationDialog +import org.fossify.commons.extensions.getAlertDialogBuilder +import org.fossify.commons.extensions.setupDialogStuff +import org.fossify.commons.extensions.toast +import org.fossify.voicerecorder.R +import org.fossify.voicerecorder.databinding.DialogTranscriptionModelsBinding +import org.fossify.voicerecorder.databinding.ItemTranscriptionModelBinding +import org.fossify.voicerecorder.extensions.config +import org.fossify.voicerecorder.helpers.ACTION_CANCEL_MODEL_DOWNLOAD +import org.fossify.voicerecorder.helpers.ACTION_DOWNLOAD_MODEL +import org.fossify.voicerecorder.helpers.EXTRA_MODEL_ID +import org.fossify.voicerecorder.models.Events +import org.fossify.voicerecorder.services.TranscriptionService +import org.fossify.voicerecorder.transcribe.model.ModelCatalog +import org.fossify.voicerecorder.transcribe.model.ModelManager +import org.fossify.voicerecorder.transcribe.model.ModelSpec +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode + +/** + * Lets the user choose the active transcription model and manage downloads + * (download / delete / cancel). + */ +@Suppress("TooManyFunctions") +class TranscriptionModelsDialog( + private val activity: BaseSimpleActivity, + private val onActiveModelChanged: () -> Unit = {}, +) { + private val binding: DialogTranscriptionModelsBinding = + DialogTranscriptionModelsBinding.inflate(LayoutInflater.from(activity)) + private val rowBindings = mutableMapOf() + private val modelManager = ModelManager(activity) + + init { + EventBus.getDefault().register(this) + + for (spec in ModelCatalog.ALL) { + val itemBinding = ItemTranscriptionModelBinding.inflate( + LayoutInflater.from(activity), binding.transcriptionModelsContainer, false + ) + itemBinding.modelName.text = spec.displayName + itemBinding.root.setOnClickListener { setActive(spec) } + itemBinding.modelRadio.setOnClickListener { setActive(spec) } + itemBinding.modelActionBtn.setOnClickListener { onActionClicked(spec) } + binding.transcriptionModelsContainer.addView(itemBinding.root) + rowBindings[spec.id] = itemBinding + renderRow(spec) + } + + activity.getAlertDialogBuilder() + .setNegativeButton(org.fossify.commons.R.string.close, null) + .setOnDismissListener { EventBus.getDefault().unregister(this) } + .apply { + activity.setupDialogStuff( + view = binding.root, + dialog = this, + titleId = R.string.manage_models, + ) { _: AlertDialog -> } + } + } + + private fun setActive(spec: ModelSpec) { + if (activity.config.transcribeModelId == spec.id) return + activity.config.transcribeModelId = spec.id + for (s in ModelCatalog.ALL) renderRow(s) + activity.toast(activity.getString(R.string.model_active_set, spec.displayName)) + onActiveModelChanged() + } + + private fun onActionClicked(spec: ModelSpec) { + val downloadingId = TranscriptionService.downloadingModelId + when { + downloadingId == spec.id -> cancelDownload() + modelManager.isModelInstalled(spec) -> confirmDelete(spec) + downloadingId != null -> activity.toast(R.string.transcribing) + else -> startDownload(spec) + } + } + + private fun startDownload(spec: ModelSpec) { + if (TranscriptionService.isRunning) { + activity.toast(R.string.transcribing) + return + } + val intent = Intent(activity, TranscriptionService::class.java).apply { + action = ACTION_DOWNLOAD_MODEL + putExtra(EXTRA_MODEL_ID, spec.id) + } + activity.startForegroundService(intent) + renderRow(spec, downloadingFraction = 0f) + } + + private fun cancelDownload() { + val intent = Intent(activity, TranscriptionService::class.java).apply { + action = ACTION_CANCEL_MODEL_DOWNLOAD + } + activity.startService(intent) + } + + private fun confirmDelete(spec: ModelSpec) { + ConfirmationDialog( + activity = activity, + message = activity.getString(R.string.model_delete_confirmation), + positive = org.fossify.commons.R.string.yes, + negative = org.fossify.commons.R.string.no, + ) { + modelManager.deleteModel(spec) + renderRow(spec) + } + } + + private fun renderRow( + spec: ModelSpec, + downloadingFraction: Float? = null, + forceFinished: Boolean = false, + ) { + val item = rowBindings[spec.id] ?: return + val isInstalled = modelManager.isModelInstalled(spec) + val isActive = (activity.config.transcribeModelId ?: ModelCatalog.DEFAULT.id) == spec.id + val sizeMb = spec.archiveSizeBytes / BYTES_PER_MB + + item.modelRadio.isChecked = isActive + + val downloadingId = TranscriptionService.downloadingModelId + val isDownloading = !forceFinished && + (downloadingFraction != null || downloadingId == spec.id) + + when { + isDownloading -> { + val pct = ((downloadingFraction ?: 0f) * PCT_MAX).toInt().coerceIn(0, PCT_MAX) + item.modelSubtitle.text = + activity.getString(R.string.model_download_in_progress, pct) + item.modelProgress.visibility = View.VISIBLE + item.modelProgress.setProgressCompat(pct, true) + item.modelActionBtn.text = activity.getString(R.string.cancel_transcription) + } + isInstalled -> { + val state = activity.getString(R.string.model_installed) + item.modelSubtitle.text = "~$sizeMb MB · $state" + item.modelProgress.visibility = View.GONE + item.modelActionBtn.text = activity.getString(R.string.delete_downloaded_model) + } + else -> { + val state = activity.getString(R.string.model_not_installed) + item.modelSubtitle.text = "~$sizeMb MB · $state" + item.modelProgress.visibility = View.GONE + item.modelActionBtn.text = activity.getString(R.string.download_model) + } + } + } + + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + fun onDownloadProgress(e: Events.ModelDownloadProgress) { + val spec = ModelCatalog.byId(e.modelId) ?: return + renderRow(spec, downloadingFraction = e.fraction) + } + + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + fun onDownloadCompleted(e: Events.ModelDownloadCompleted) { + val spec = ModelCatalog.byId(e.modelId) ?: return + renderRow(spec, forceFinished = true) + } + + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + fun onDownloadFailed(e: Events.ModelDownloadFailed) { + val spec = ModelCatalog.byId(e.modelId) ?: return + activity.toast(activity.getString(R.string.transcript_failed, e.cause.message ?: "?")) + renderRow(spec, forceFinished = true) + } + + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + fun onDownloadCancelled(e: Events.ModelDownloadCancelled) { + val spec = ModelCatalog.byId(e.modelId) ?: return + renderRow(spec, forceFinished = true) + } + + private companion object { + const val PCT_MAX = 100 + const val BYTES_PER_MB = 1_000_000L + } +} diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt index c8c58946..0f4d7dd8 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt @@ -1,25 +1,15 @@ package org.fossify.voicerecorder.extensions import android.app.Activity -import android.provider.DocumentsContract +import android.os.Build import android.view.WindowManager -import androidx.core.net.toUri import org.fossify.commons.activities.BaseSimpleActivity -import org.fossify.commons.dialogs.FilePickerDialog -import org.fossify.commons.extensions.createDocumentUriUsingFirstParentTreeUri -import org.fossify.commons.extensions.createSAFDirectorySdk30 -import org.fossify.commons.extensions.deleteFile -import org.fossify.commons.extensions.getDoesFilePathExistSdk30 -import org.fossify.commons.extensions.hasProperStoredFirstParentUri -import org.fossify.commons.extensions.toFileDirItem +import org.fossify.commons.extensions.hasPermission import org.fossify.commons.helpers.DAY_SECONDS import org.fossify.commons.helpers.MONTH_SECONDS +import org.fossify.commons.helpers.PERMISSION_READ_STORAGE +import org.fossify.commons.helpers.PERMISSION_WRITE_STORAGE import org.fossify.commons.helpers.ensureBackgroundThread -import org.fossify.commons.helpers.isRPlus -import org.fossify.commons.models.FileDirItem -import org.fossify.voicerecorder.dialogs.StoragePermissionDialog -import org.fossify.voicerecorder.models.Recording -import java.io.File fun Activity.setKeepScreenAwake(keepScreenOn: Boolean) { if (keepScreenOn) { @@ -29,193 +19,22 @@ fun Activity.setKeepScreenAwake(keepScreenOn: Boolean) { } } -fun BaseSimpleActivity.ensureStoragePermission(callback: (result: Boolean) -> Unit) { - if (isRPlus() && !hasProperStoredFirstParentUri(config.saveRecordingsFolder)) { - StoragePermissionDialog(this) { - launchFolderPicker(config.saveRecordingsFolder) { newPath -> - if (!newPath.isNullOrEmpty()) { - config.saveRecordingsFolder = newPath - callback(true) - } else { - callback(false) - } - } - } - } else { - callback(true) - } -} - -fun BaseSimpleActivity.launchFolderPicker( - currentPath: String, - callback: (newPath: String?) -> Unit -) { - FilePickerDialog( - activity = this, - currPath = currentPath, - pickFile = false, - showFAB = true, - showRationale = false - ) { path -> - handleSAFDialog(path) { grantedSAF -> - if (!grantedSAF) { - callback(null) - return@handleSAFDialog - } - - handleSAFDialogSdk30(path, showRationale = false) { grantedSAF30 -> - if (!grantedSAF30) { - callback(null) - return@handleSAFDialogSdk30 - } - - callback(path) - } - } - } -} - -fun BaseSimpleActivity.deleteRecordings( - recordingsToRemove: Collection, - callback: (success: Boolean) -> Unit -) { - ensureBackgroundThread { - if (isRPlus()) { - val resolver = contentResolver - recordingsToRemove.forEach { - DocumentsContract.deleteDocument(resolver, it.path.toUri()) - } - } else { - recordingsToRemove.forEach { - val fileDirItem = File(it.path).toFileDirItem(this) - deleteFile(fileDirItem) - } - } - - callback(true) - } -} - -fun BaseSimpleActivity.trashRecordings( - recordingsToMove: Collection, - callback: (success: Boolean) -> Unit -) = moveRecordings( - recordingsToMove = recordingsToMove, - sourceParent = config.saveRecordingsFolder, - destinationParent = getOrCreateTrashFolder(), - callback = callback -) - -fun BaseSimpleActivity.restoreRecordings( - recordingsToRestore: Collection, - callback: (success: Boolean) -> Unit -) = moveRecordings( - recordingsToMove = recordingsToRestore, - sourceParent = getOrCreateTrashFolder(), - destinationParent = config.saveRecordingsFolder, - callback = callback -) - -fun BaseSimpleActivity.moveRecordings( - recordingsToMove: Collection, - sourceParent: String, - destinationParent: String, - callback: (success: Boolean) -> Unit -) { - if (isRPlus()) { - moveRecordingsSAF( - recordings = recordingsToMove, - sourceParent = sourceParent, - destinationParent = destinationParent, - callback = callback - ) - } else { - moveRecordingsLegacy( - recordings = recordingsToMove, - sourceParent = sourceParent, - destinationParent = destinationParent, - callback = callback - ) - } -} - -private fun BaseSimpleActivity.moveRecordingsSAF( - recordings: Collection, - sourceParent: String, - destinationParent: String, - callback: (success: Boolean) -> Unit -) { - ensureBackgroundThread { - val contentResolver = contentResolver - val sourceParentDocumentUri = createDocumentUriUsingFirstParentTreeUri(sourceParent) - val destinationParentDocumentUri = - createDocumentUriUsingFirstParentTreeUri(destinationParent) - - if (!getDoesFilePathExistSdk30(destinationParent)) { - createSAFDirectorySdk30(destinationParent) - } - - recordings.forEach { recording -> - try { - DocumentsContract.moveDocument( - contentResolver, - recording.path.toUri(), - sourceParentDocumentUri, - destinationParentDocumentUri - ) - } catch (@Suppress("SwallowedException") e: IllegalStateException) { - val sourceUri = recording.path.toUri() - contentResolver.openInputStream(sourceUri)?.use { inputStream -> - val targetPath = File(destinationParent, recording.title).absolutePath - val targetUri = createDocumentFile(targetPath) ?: return@forEach - contentResolver.openOutputStream(targetUri)?.use { outputStream -> - inputStream.copyTo(outputStream) - } - DocumentsContract.deleteDocument(contentResolver, sourceUri) - } - } +fun BaseSimpleActivity.deleteExpiredTrashedRecordings() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + if (!hasPermission(PERMISSION_READ_STORAGE) || !hasPermission(PERMISSION_WRITE_STORAGE)) { + return } - - callback(true) } -} -private fun BaseSimpleActivity.moveRecordingsLegacy( - recordings: Collection, - sourceParent: String, - destinationParent: String, - callback: (success: Boolean) -> Unit -) { - copyMoveFilesTo( - fileDirItems = recordings - .map { File(it.path).toFileDirItem(this) } - .toMutableList() as ArrayList, - source = sourceParent, - destination = destinationParent, - isCopyOperation = false, - copyPhotoVideoOnly = false, - copyHidden = false - ) { - callback(true) - } -} - -fun BaseSimpleActivity.deleteTrashedRecordings() { - deleteRecordings(getAllRecordings(trashed = true)) {} -} - -fun BaseSimpleActivity.deleteExpiredTrashedRecordings() { - if ( - config.useRecycleBin && - config.lastRecycleBinCheck < System.currentTimeMillis() - DAY_SECONDS * 1000 - ) { + if (config.useRecycleBin && config.lastRecycleBinCheck < System.currentTimeMillis() - DAY_SECONDS * 1000) { config.lastRecycleBinCheck = System.currentTimeMillis() ensureBackgroundThread { try { - val recordingsToRemove = getAllRecordings(trashed = true) - .filter { it.timestamp < System.currentTimeMillis() - MONTH_SECONDS * 1000L } + val store = recordingStore + val recordingsToRemove = store.all(trashed = true) + .filter { it.timestamp < System.currentTimeMillis() - MONTH_SECONDS * 1000L }.toList() if (recordingsToRemove.isNotEmpty()) { - deleteRecordings(recordingsToRemove) {} + store.delete(recordingsToRemove) } } catch (e: Exception) { e.printStackTrace() @@ -223,3 +42,4 @@ fun BaseSimpleActivity.deleteExpiredTrashedRecordings() { } } } + diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Context.kt b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Context.kt index a16f9c7a..99d4c92e 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Context.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Context.kt @@ -7,41 +7,21 @@ import android.content.Intent import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.drawable.Drawable -import android.media.MediaMetadataRetriever import android.net.Uri -import android.os.Environment -import android.provider.DocumentsContract import androidx.core.graphics.createBitmap -import androidx.documentfile.provider.DocumentFile -import org.fossify.commons.extensions.createFirstParentTreeUri -import org.fossify.commons.extensions.createSAFDirectorySdk30 -import org.fossify.commons.extensions.getDocumentSdk30 -import org.fossify.commons.extensions.getDoesFilePathExistSdk30 -import org.fossify.commons.extensions.getDuration -import org.fossify.commons.extensions.getFilenameFromPath -import org.fossify.commons.extensions.getMimeType -import org.fossify.commons.extensions.getParentPath -import org.fossify.commons.extensions.getSAFDocumentId -import org.fossify.commons.extensions.internalStoragePath -import org.fossify.commons.extensions.isAudioFast -import org.fossify.commons.helpers.isQPlus -import org.fossify.commons.helpers.isRPlus -import org.fossify.voicerecorder.R import org.fossify.voicerecorder.helpers.Config -import org.fossify.voicerecorder.helpers.DEFAULT_RECORDINGS_FOLDER import org.fossify.voicerecorder.helpers.IS_RECORDING import org.fossify.voicerecorder.helpers.MyWidgetRecordDisplayProvider import org.fossify.voicerecorder.helpers.TOGGLE_WIDGET_UI -import org.fossify.voicerecorder.models.Recording -import java.io.File +import org.fossify.voicerecorder.store.RecordingStore import java.util.Calendar import java.util.Locale -import kotlin.math.roundToLong val Context.config: Config get() = Config.newInstance(applicationContext) -val Context.trashFolder - get() = "${config.saveRecordingsFolder}/.trash" +val Context.recordingStore: RecordingStore get() = recordingStoreFor(config.saveRecordingsFolder) + +fun Context.recordingStoreFor(uri: Uri): RecordingStore = RecordingStore(this, uri) fun Context.drawableToBitmap(drawable: Drawable): Bitmap { val size = (60 * resources.displayMetrics.density).toInt() @@ -53,11 +33,9 @@ fun Context.drawableToBitmap(drawable: Drawable): Bitmap { } fun Context.updateWidgets(isRecording: Boolean) { - val widgetIDs = AppWidgetManager.getInstance(applicationContext) - ?.getAppWidgetIds( + val widgetIDs = AppWidgetManager.getInstance(applicationContext)?.getAppWidgetIds( ComponentName( - applicationContext, - MyWidgetRecordDisplayProvider::class.java + applicationContext, MyWidgetRecordDisplayProvider::class.java ) ) ?: return @@ -70,175 +48,6 @@ fun Context.updateWidgets(isRecording: Boolean) { } } -fun Context.getOrCreateTrashFolder(): String { - val folder = File(trashFolder) - if (!folder.exists()) { - folder.mkdir() - } - return trashFolder -} - -fun Context.getDefaultRecordingsFolder(): String { - val defaultPath = getDefaultRecordingsRelativePath() - return "$internalStoragePath/$defaultPath" -} - -fun Context.getDefaultRecordingsRelativePath(): String { - return if (isQPlus()) { - "${Environment.DIRECTORY_MUSIC}/$DEFAULT_RECORDINGS_FOLDER" - } else { - getString(R.string.app_name) - } -} - -fun Context.hasRecordings(): Boolean { - val recordingsFolder = config.saveRecordingsFolder - return if (isRPlus()) { - getDocumentSdk30(recordingsFolder) - ?.listFiles() - ?.any { it.isAudioRecording() } - ?: false - } else { - File(recordingsFolder) - .listFiles() - ?.any { it.isAudioFast() } - ?: false - } -} - -fun Context.getAllRecordings(trashed: Boolean = false): ArrayList { - return if (isRPlus()) { - val recordings = arrayListOf() - recordings.addAll(getRecordings(trashed)) - if (trashed) { - // Return recordings trashed using MediaStore, this won't be needed in the future - @Suppress("DEPRECATION") - recordings.addAll(getMediaStoreTrashedRecordings()) - } - - recordings - } else { - getLegacyRecordings(trashed) - } -} - -private fun Context.getRecordings(trashed: Boolean = false): ArrayList { - val recordings = ArrayList() - val folder = if (trashed) trashFolder else config.saveRecordingsFolder - val files = getDocumentSdk30(folder)?.listFiles() ?: return recordings - files.forEach { file -> - if (file.isAudioRecording()) { - recordings.add( - readRecordingFromFile(file) - ) - } - } - - return recordings -} - -@Deprecated( - message = "Use getRecordings instead. This method is only here for backward compatibility.", - replaceWith = ReplaceWith("getRecordings(trashed = true)") -) -private fun Context.getMediaStoreTrashedRecordings(): ArrayList { - val recordings = ArrayList() - val folder = config.saveRecordingsFolder - val documentFiles = getDocumentSdk30(folder)?.listFiles() ?: return recordings - documentFiles.forEach { file -> - if (file.isTrashedMediaStoreRecording()) { - val recording = readRecordingFromFile(file) - recordings.add( - recording.copy( - title = "^\\.trashed-\\d+-".toRegex().replace(file.name!!, "") - ) - ) - } - } - - return recordings -} - -private fun Context.getLegacyRecordings(trashed: Boolean = false): ArrayList { - val recordings = ArrayList() - val folder = if (trashed) { - trashFolder - } else { - config.saveRecordingsFolder - } - val files = File(folder).listFiles() ?: return recordings - - files.filter { it.isAudioFast() }.forEach { - val id = it.hashCode() - val title = it.name - val path = it.absolutePath - val timestamp = it.lastModified() - val duration = getDuration(it.absolutePath) ?: 0 - val size = it.length().toInt() - recordings.add( - Recording( - id = id, - title = title, - path = path, - timestamp = timestamp, - duration = duration, - size = size - ) - ) - } - return recordings -} - -private fun Context.readRecordingFromFile(file: DocumentFile): Recording { - val id = file.hashCode() - val title = file.name!! - val path = file.uri.toString() - val timestamp = file.lastModified() - val duration = getDurationFromUri(file.uri) - val size = file.length().toInt() - return Recording( - id = id, - title = title, - path = path, - timestamp = timestamp, - duration = duration.toInt(), - size = size - ) -} - -private fun Context.getDurationFromUri(uri: Uri): Long { - return try { - val retriever = MediaMetadataRetriever() - retriever.setDataSource(this, uri) - val time = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)!! - (time.toLong() / 1000.toDouble()).roundToLong() - } catch (e: Exception) { - 0L - } -} - -// Based on common's `Context.createSAFFileSdk30` extension -fun Context.createDocumentFile(path: String): Uri? { - return try { - val treeUri = createFirstParentTreeUri(path) - val parentPath = path.getParentPath() - if (!getDoesFilePathExistSdk30(parentPath)) { - createSAFDirectorySdk30(parentPath) - } - - val documentId = getSAFDocumentId(parentPath) - val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId) - DocumentsContract.createDocument( - contentResolver, - parentUri, - path.getMimeType(), - path.getFilenameFromPath() - ) - } catch (@Suppress("SwallowedException") e: IllegalStateException) { - null - } -} - // move to commons in the future fun Context.getFormattedFilename(): String { val pattern = config.filenamePattern diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/DocumentFile.kt b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/DocumentFile.kt deleted file mode 100644 index 5a2fb006..00000000 --- a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/DocumentFile.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.fossify.voicerecorder.extensions - -import androidx.documentfile.provider.DocumentFile - -fun DocumentFile.isAudioRecording(): Boolean { - return type.isAudioMimeType() && !name.isNullOrEmpty() && !name!!.startsWith(".") -} - -fun DocumentFile.isTrashedMediaStoreRecording(): Boolean { - return type.isAudioMimeType() && !name.isNullOrEmpty() && name!!.startsWith(".trashed-") -} diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/String.kt b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/String.kt deleted file mode 100644 index e7fce211..00000000 --- a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/String.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.fossify.voicerecorder.extensions - -fun String?.isAudioMimeType(): Boolean { - return this?.startsWith("audio") == true -} diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/TranscriptShare.kt b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/TranscriptShare.kt new file mode 100644 index 00000000..71a4dc9b --- /dev/null +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/TranscriptShare.kt @@ -0,0 +1,53 @@ +package org.fossify.voicerecorder.extensions + +import android.content.Context +import android.content.Intent +import android.net.Uri +import org.fossify.voicerecorder.R +import org.fossify.voicerecorder.store.Recording +import org.fossify.voicerecorder.store.Transcript +import java.util.Locale + +private const val MS_PER_SECOND = 1000L +private const val SEC_PER_MIN = 60L + +/** + * Plain-text rendering of a transcript suitable for clipboard / share-as-text. + * Header line names the recording, followed by `[mm:ss] segment text` lines. + */ +fun Transcript.toShareableText(recording: Recording): String { + val header = "Transcript of ${recording.title}" + val lang = language.ifBlank { "?" } + val durationLabel = formatTimestamp(durationMs) + val subheader = "Duration: $durationLabel · Language: $lang" + val body = segments.joinToString(separator = "\n") { seg -> + "[${formatTimestamp(seg.startMs)}] ${seg.text}" + } + return "$header\n$subheader\n\n$body" +} + +private fun formatTimestamp(ms: Long): String { + val totalSec = ms / MS_PER_SECOND + val mm = totalSec / SEC_PER_MIN + val ss = totalSec % SEC_PER_MIN + return String.format(Locale.ROOT, "%02d:%02d", mm, ss) +} + +fun Context.buildShareTranscriptTextIntent(text: String, subject: String): Intent { + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_TEXT, text) + } + return Intent.createChooser(send, getString(R.string.share_transcript)) +} + +fun Context.buildShareTranscriptJsonIntent(uri: Uri, subject: String): Intent { + val send = Intent(Intent.ACTION_SEND).apply { + type = "application/json" + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + return Intent.createChooser(send, getString(R.string.share_transcript_json)) +} diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/MyViewPagerFragment.kt b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/MyViewPagerFragment.kt index 4fe66c6e..9d2795c9 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/MyViewPagerFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/MyViewPagerFragment.kt @@ -1,15 +1,18 @@ package org.fossify.voicerecorder.fragments -import android.app.Activity import android.content.Context import android.util.AttributeSet import androidx.constraintlayout.widget.ConstraintLayout import org.fossify.commons.helpers.ensureBackgroundThread -import org.fossify.voicerecorder.extensions.getAllRecordings -import org.fossify.voicerecorder.models.Recording +import org.fossify.voicerecorder.activities.ExternalStoragePermission +import org.fossify.voicerecorder.activities.SimpleActivity +import org.fossify.voicerecorder.extensions.recordingStore +import org.fossify.voicerecorder.store.Recording -abstract class MyViewPagerFragment(context: Context, attributeSet: AttributeSet) : - ConstraintLayout(context, attributeSet) { +abstract class MyViewPagerFragment( + context: Context, + attributeSet: AttributeSet +) : ConstraintLayout(context, attributeSet) { abstract fun onResume() abstract fun onDestroy() @@ -20,13 +23,31 @@ abstract class MyViewPagerFragment(context: Context, attributeSet: AttributeSet) open fun loadRecordings(trashed: Boolean = false) { onLoadingStart() - ensureBackgroundThread { - val recordings = context.getAllRecordings(trashed) - .apply { sortByDescending { it.timestamp } } - (context as? Activity)?.runOnUiThread { - onLoadingEnd(recordings) + (context as? SimpleActivity)?.apply { + handleExternalStoragePermission(ExternalStoragePermission.READ) { granted -> + if (granted == true) { + ensureBackgroundThread { + val recordings = try { + recordingStore.all(trashed) + .sortedByDescending { it.timestamp } + .toCollection(ArrayList()) + } catch ( + @Suppress("TooGenericExceptionCaught") e: Exception + ) { + handleRecordingStoreError(e) + ArrayList() + } + + runOnUiThread { + onLoadingEnd(recordings) + } + } + } else { + onLoadingEnd(ArrayList()) + } } } } } + diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/PlayerFragment.kt b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/PlayerFragment.kt index 988ff366..d998f256 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/PlayerFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/PlayerFragment.kt @@ -8,12 +8,12 @@ import android.graphics.drawable.Drawable import android.media.AudioAttributes import android.media.AudioManager import android.media.MediaPlayer +import android.net.Uri import android.os.Handler import android.os.Looper import android.os.PowerManager import android.util.AttributeSet import android.widget.SeekBar -import androidx.core.net.toUri import org.fossify.commons.extensions.applyColorFilter import org.fossify.commons.extensions.areSystemAnimationsEnabled import org.fossify.commons.extensions.beVisibleIf @@ -26,17 +26,20 @@ import org.fossify.commons.extensions.getProperTextColor import org.fossify.commons.extensions.showErrorToast import org.fossify.commons.extensions.updateTextColors import org.fossify.commons.extensions.value +import org.fossify.commons.helpers.ensureBackgroundThread import org.fossify.commons.helpers.isQPlus import org.fossify.commons.helpers.isTiramisuPlus import org.fossify.voicerecorder.R import org.fossify.voicerecorder.activities.SimpleActivity +import org.fossify.voicerecorder.activities.TranscriptActivity import org.fossify.voicerecorder.adapters.RecordingsAdapter import org.fossify.voicerecorder.databinding.FragmentPlayerBinding import org.fossify.voicerecorder.extensions.config import org.fossify.voicerecorder.interfaces.RefreshRecordingsListener import org.fossify.voicerecorder.models.Events -import org.fossify.voicerecorder.models.Recording import org.fossify.voicerecorder.receivers.BecomingNoisyReceiver +import org.fossify.voicerecorder.store.Recording +import org.fossify.voicerecorder.store.TranscriptStore import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -45,8 +48,7 @@ import java.util.Timer import java.util.TimerTask class PlayerFragment( - context: Context, - attributeSet: AttributeSet + context: Context, attributeSet: AttributeSet ) : MyViewPagerFragment(context, attributeSet), RefreshRecordingsListener { companion object { @@ -59,9 +61,11 @@ class PlayerFragment( private var itemsIgnoringSearch = ArrayList() private var lastSearchQuery = "" private var bus: EventBus? = null - private var prevSavePath = "" + private var prevSaveFolder: Uri? = null private var prevRecycleBinState = context.config.useRecycleBin private var playOnPreparation = true + private var pendingSeekMs: Int = -1 + private var transcriptPreviews: Map = emptyMap() private lateinit var binding: FragmentPlayerBinding private var becomingNoisyReceiver: BecomingNoisyReceiver? = null @@ -74,7 +78,7 @@ class PlayerFragment( override fun onResume() { setupColors() - if (prevSavePath.isNotEmpty() && context!!.config.saveRecordingsFolder != prevSavePath || context.config.useRecycleBin != prevRecycleBinState) { + if (prevSaveFolder != null && context!!.config.saveRecordingsFolder != prevSaveFolder || context.config.useRecycleBin != prevRecycleBinState) { loadRecordings() } else { getRecordingsAdapter()?.updateTextColor(context.getProperTextColor()) @@ -115,9 +119,12 @@ class PlayerFragment( override fun onLoadingEnd(recordings: ArrayList) { binding.loadingIndicator.hide() - binding.recordingsPlaceholder.beVisibleIf(recordings.isEmpty()) itemsIgnoringSearch = recordings - setupAdapter(itemsIgnoringSearch) + setupAdapter(filteredItems()) + recomputeTranscriptPreviews { previews -> + transcriptPreviews = previews + getRecordingsAdapter()?.transcriptPreviews = previews + } } private fun setupViews() { @@ -149,8 +156,7 @@ class PlayerFragment( } val prevRecordingIndex = adapter.recordings.indexOfFirst { it.id == wantedRecordingID } - val prevRecording = adapter.recordings - .getOrNull(prevRecordingIndex) ?: return@setOnClickListener + val prevRecording = adapter.recordings.getOrNull(prevRecordingIndex) ?: return@setOnClickListener playRecording(prevRecording, true) } @@ -167,29 +173,84 @@ class PlayerFragment( return@setOnClickListener } - val oldRecordingIndex = - adapter.recordings.indexOfFirst { it.id == adapter.currRecordingId } + val oldRecordingIndex = adapter.recordings.indexOfFirst { it.id == adapter.currRecordingId } val newRecordingIndex = (oldRecordingIndex + 1) % adapter.recordings.size - val newRecording = - adapter.recordings.getOrNull(newRecordingIndex) ?: return@setOnClickListener + val newRecording = adapter.recordings.getOrNull(newRecordingIndex) ?: return@setOnClickListener playRecording(newRecording, true) playedRecordingIDs.push(newRecording.id) } + + } + + private fun openTranscriptActivity(recording: Recording) { + pausePlayback() + val intent = android.content.Intent(context, TranscriptActivity::class.java).apply { + putExtra(TranscriptActivity.EXTRA_RECORDING_URI_STRING, recording.uri.toString()) + } + context.startActivity(intent) + } + + private fun filteredItems(): ArrayList { + val base = if (lastSearchQuery.isEmpty()) { + itemsIgnoringSearch + } else { + itemsIgnoringSearch.filter { it.title.contains(lastSearchQuery, true) } + } + return ArrayList(base) + } + + /** + * Reads each recording's sidecar transcript on a background thread and produces a + * map of recording id → first-line preview snippet. Recordings with no transcript + * are absent from the resulting map; callers can use `id in map` as the + * has-transcript check. + */ + private fun recomputeTranscriptPreviews(then: (Map) -> Unit) { + val snapshot = itemsIgnoringSearch.toList() + ensureBackgroundThread { + val store = TranscriptStore(context, context.config.saveRecordingsFolder) + val previews = snapshot.mapNotNull { recording -> + loadTranscriptPreview(store, recording)?.let { recording.id to it } + }.toMap() + (context as? SimpleActivity)?.runOnUiThread { then(previews) } + } + } + + private fun loadTranscriptPreview(store: TranscriptStore, recording: Recording): String? { + if (!store.hasTranscript(recording)) return null + val transcript = store.read(recording) ?: return null + val firstSegmentText = transcript.segments.firstOrNull()?.text?.trim().orEmpty() + return firstSegmentText.takeIf { it.isNotEmpty() } + } + + /** + * Seek the currently-playing recording to [positionMs] and start playback. + * If the player hasn't finished preparing yet, the seek is queued and applied + * once `onPrepared` fires. + */ + fun seekToAndPlay(positionMs: Long) { + val target = positionMs.toInt() + val mediaPlayer = player ?: return + try { + mediaPlayer.seekTo(target) + resumePlayback() + binding.playerProgressbar.progress = target / 1000 + } catch (_: IllegalStateException) { + pendingSeekMs = target + playOnPreparation = true + } } override fun refreshRecordings() = loadRecordings() private fun setupAdapter(recordings: ArrayList) { binding.recordingsFastscroller.beVisibleIf(recordings.isNotEmpty()) + binding.recordingsPlaceholder.beVisibleIf(recordings.isEmpty()) if (recordings.isEmpty()) { - val stringId = if (lastSearchQuery.isEmpty()) { - if (isQPlus()) { - R.string.no_recordings_found - } else { - R.string.no_recordings_in_folder_found - } - } else { - org.fossify.commons.R.string.no_items_found + val stringId = when { + lastSearchQuery.isNotEmpty() -> org.fossify.commons.R.string.no_items_found + isQPlus() -> R.string.no_recordings_found + else -> R.string.no_recordings_in_folder_found } binding.recordingsPlaceholder.text = context.getString(stringId) @@ -199,12 +260,16 @@ class PlayerFragment( val adapter = getRecordingsAdapter() if (adapter == null) { - RecordingsAdapter(context as SimpleActivity, recordings, this, binding.recordingsList) { - playRecording(it as Recording, true) - if (playedRecordingIDs.isEmpty() || playedRecordingIDs.peek() != it.id) { - playedRecordingIDs.push(it.id) - } + RecordingsAdapter( + activity = context as SimpleActivity, + recordings = recordings, + refreshListener = this, + recyclerView = binding.recordingsList, + onTranscriptIndicatorClick = { openTranscriptActivity(it) }, + ) { + onRecordingTapped(it as Recording) }.apply { + transcriptPreviews = this@PlayerFragment.transcriptPreviews binding.recordingsList.adapter = this } @@ -216,14 +281,19 @@ class PlayerFragment( } } + private fun onRecordingTapped(recording: Recording) { + playRecording(recording, true) + if (playedRecordingIDs.isEmpty() || playedRecordingIDs.peek() != recording.id) { + playedRecordingIDs.push(recording.id) + } + } + private fun initMediaPlayer() { player = MediaPlayer().apply { setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK) setAudioAttributes( - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_MEDIA) - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .build() + AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build() ) setOnCompletionListener { @@ -235,6 +305,14 @@ class PlayerFragment( } setOnPreparedListener { + if (pendingSeekMs >= 0) { + try { + seekTo(pendingSeekMs) + binding.playerProgressbar.progress = pendingSeekMs / 1000 + } catch (_: IllegalStateException) { + } + pendingSeekMs = -1 + } if (playOnPreparation) { resumePlayback() } @@ -253,7 +331,7 @@ class PlayerFragment( reset() try { - setDataSource(context, recording.path.toUri()) + setDataSource(context, recording.uri) } catch (e: Exception) { context?.showErrorToast(e) return @@ -268,8 +346,7 @@ class PlayerFragment( } binding.playPauseBtn.setImageDrawable(getToggleButtonIcon(playOnPreparation)) - binding.playerProgressbar.setOnSeekBarChangeListener(object : - SeekBar.OnSeekBarChangeListener { + binding.playerProgressbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { if (fromUser && !playedRecordingIDs.isEmpty()) { player?.seekTo(progress * 1000) @@ -317,10 +394,7 @@ class PlayerFragment( fun onSearchTextChanged(text: String) { lastSearchQuery = text - val filtered = itemsIgnoringSearch - .filter { it.title.contains(text, true) } - .toMutableList() as ArrayList - setupAdapter(filtered) + setupAdapter(filteredItems()) } private fun togglePlayPause() { @@ -353,8 +427,7 @@ class PlayerFragment( } return resources.getColoredDrawableWithColor( - drawableId = drawable, - color = context.getProperPrimaryColor().getContrastColor() + drawableId = drawable, color = context.getProperPrimaryColor().getContrastColor() ) } @@ -378,7 +451,7 @@ class PlayerFragment( private fun getRecordingsAdapter() = binding.recordingsList.adapter as? RecordingsAdapter private fun storePrevState() { - prevSavePath = context!!.config.saveRecordingsFolder + prevSaveFolder = context!!.config.saveRecordingsFolder prevRecycleBinState = context.config.useRecycleBin } @@ -412,6 +485,26 @@ class PlayerFragment( refreshRecordings() } + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + fun transcriptionCompleted(@Suppress("UNUSED_PARAMETER") event: Events.TranscriptionCompleted) { + // A new sidecar JSON now exists on disk — re-read previews so the affected row's + // indicator flips from "Transcribe" to its first-segment snippet without a manual refresh. + recomputeTranscriptPreviews { previews -> + transcriptPreviews = previews + getRecordingsAdapter()?.transcriptPreviews = previews + } + } + + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + fun transcriptDeleted(@Suppress("UNUSED_PARAMETER") event: Events.TranscriptDeleted) { + recomputeTranscriptPreviews { previews -> + transcriptPreviews = previews + getRecordingsAdapter()?.transcriptPreviews = previews + } + } + private fun registerNoisyAudioReceiver() { if (isReceiverRegistered) return if (becomingNoisyReceiver == null) { @@ -433,7 +526,7 @@ class PlayerFragment( try { isReceiverRegistered = false context.unregisterReceiver(becomingNoisyReceiver) - } catch (ignored: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { } } } diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt index 557bfc4d..581f7cb8 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt @@ -1,12 +1,20 @@ package org.fossify.voicerecorder.fragments +import android.Manifest import android.annotation.SuppressLint import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.graphics.drawable.Drawable +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build import android.os.Handler import android.os.Looper import android.util.AttributeSet +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import org.fossify.commons.activities.BaseSimpleActivity import org.fossify.commons.compose.extensions.getActivity import org.fossify.commons.dialogs.ConfirmationDialog @@ -20,13 +28,16 @@ import org.fossify.commons.extensions.getProperPrimaryColor import org.fossify.commons.extensions.getProperTextColor import org.fossify.commons.extensions.openNotificationSettings import org.fossify.commons.extensions.setDebouncedClickListener -import org.fossify.commons.extensions.toast import org.fossify.voicerecorder.R +import org.fossify.voicerecorder.activities.ExternalStoragePermission +import org.fossify.voicerecorder.activities.SimpleActivity import org.fossify.voicerecorder.databinding.FragmentRecorderBinding import org.fossify.voicerecorder.extensions.config -import org.fossify.voicerecorder.extensions.ensureStoragePermission import org.fossify.voicerecorder.extensions.setKeepScreenAwake +import org.fossify.voicerecorder.helpers.BluetoothScoManager import org.fossify.voicerecorder.helpers.CANCEL_RECORDING +import org.fossify.voicerecorder.helpers.EXTRA_BT_OUTPUT_DEVICE_ID +import org.fossify.voicerecorder.helpers.EXTRA_PREFERRED_AUDIO_DEVICE_ID import org.fossify.voicerecorder.helpers.GET_RECORDER_INFO import org.fossify.voicerecorder.helpers.RECORDING_PAUSED import org.fossify.voicerecorder.helpers.RECORDING_RUNNING @@ -41,13 +52,14 @@ import java.util.Timer import java.util.TimerTask class RecorderFragment( - context: Context, - attributeSet: AttributeSet + context: Context, attributeSet: AttributeSet ) : MyViewPagerFragment(context, attributeSet) { private var status = RECORDING_STOPPED private var pauseBlinkTimer = Timer() private var bus: EventBus? = null + private var bluetoothSelected = false + private var audioDeviceCallback: AudioDeviceCallback? = null private lateinit var binding: FragmentRecorderBinding override fun onFinishInflate() { @@ -62,11 +74,24 @@ class RecorderFragment( } refreshView() + refreshBluetoothVisibility() + } + + fun onPermissionResult(requestCode: Int, grantResults: IntArray) { + if (requestCode != BLUETOOTH_PERMISSION_REQUEST_CODE) return + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED + && findBluetoothInputDevice() != null + ) { + bluetoothSelected = true + refreshBluetoothVisibility() + refreshDeviceSelectorStatus() + } } override fun onDestroy() { bus?.unregister(this) pauseBlinkTimer.cancel() + unregisterAudioDeviceCallback() } override fun onAttachedToWindow() { @@ -76,26 +101,26 @@ class RecorderFragment( bus = EventBus.getDefault() bus!!.register(this) + setupTabSelector() + registerAudioDeviceCallback() updateRecordingDuration(0) binding.toggleRecordingButton.setDebouncedClickListener { - val activity = context as? BaseSimpleActivity - activity?.ensureStoragePermission { - if (it) { - activity.handleNotificationPermission { granted -> - if (granted) { - cycleRecordingState() - } else { - PermissionRequiredDialog( - activity = context as BaseSimpleActivity, - textId = org.fossify.commons.R.string.allow_notifications_voice_recorder, - positiveActionCallback = { - (context as BaseSimpleActivity).openNotificationSettings() - } - ) + (context as? SimpleActivity)?.apply { + handleExternalStoragePermission(ExternalStoragePermission.WRITE) { granted -> + if (granted == true) { + handleNotificationPermission { granted -> + if (granted) { + cycleRecordingState() + } else { + PermissionRequiredDialog( + activity = this, + textId = org.fossify.commons.R.string.allow_notifications_voice_recorder, + positiveActionCallback = { + (context as BaseSimpleActivity).openNotificationSettings() + }) + } } } - } else { - activity.toast(org.fossify.commons.R.string.no_storage_permissions) } } } @@ -106,7 +131,7 @@ class RecorderFragment( action = GET_RECORDER_INFO try { context.startService(this) - } catch (ignored: Exception) { + } catch (_: Exception) { } } } @@ -123,6 +148,49 @@ class RecorderFragment( binding.saveRecordingButton.applyColorFilter(properTextColor) binding.recorderVisualizer.chunkColor = properPrimaryColor binding.recordingDuration.setTextColor(properTextColor) + refreshDeviceSelectorStatus() + } + + private fun refreshDeviceSelectorStatus() { + val properTextColor = context.getProperTextColor() + val properPrimaryColor = context.getProperPrimaryColor() + val contrastColor = properPrimaryColor.getContrastColor() + val btDevice = findBluetoothInputDevice() + val btAvailable = btDevice != null + + binding.tabBluetooth.text = bluetoothTabLabel(btDevice) + binding.tabBluetooth.alpha = if (btAvailable) 1f else BT_DISABLED_ALPHA + binding.tabBluetooth.isEnabled = btAvailable + + if (bluetoothSelected && btAvailable) { + binding.tabDefault.setBackgroundResource(android.R.color.transparent) + binding.tabDefault.setTextColor(properTextColor) + binding.tabBluetooth.setBackgroundResource(R.drawable.tab_selector_selected) + binding.tabBluetooth.background.applyColorFilter(properPrimaryColor) + binding.tabBluetooth.setTextColor(contrastColor) + } else { + binding.tabDefault.setBackgroundResource(R.drawable.tab_selector_selected) + binding.tabDefault.background.applyColorFilter(properPrimaryColor) + binding.tabDefault.setTextColor(contrastColor) + binding.tabBluetooth.setBackgroundResource(android.R.color.transparent) + binding.tabBluetooth.setTextColor(properTextColor) + } + } + + private fun bluetoothTabLabel(device: AudioDeviceInfo?): String { + if (device == null) { + return context.getString(R.string.mic_type_bluetooth_not_connected) + } + val name = if (hasBluetoothPermission()) { + device.productName?.toString()?.takeIf { it.isNotBlank() } + } else { + null + } + return if (name != null) { + context.getString(R.string.mic_type_bluetooth_named, name) + } else { + context.getString(R.string.mic_type_bluetooth) + } } private fun updateRecordingDuration(duration: Int) { @@ -137,15 +205,13 @@ class RecorderFragment( } return resources.getColoredDrawableWithColor( - drawableId = drawable, - color = context.getProperPrimaryColor().getContrastColor() + drawableId = drawable, color = context.getProperPrimaryColor().getContrastColor() ) } private fun cycleRecordingState() { when (status) { - RECORDING_PAUSED, - RECORDING_RUNNING -> { + RECORDING_PAUSED, RECORDING_RUNNING -> { Intent(context, RecorderService::class.java).apply { action = TOGGLE_PAUSE context.startService(this) @@ -163,10 +229,109 @@ class RecorderFragment( private fun startRecording() { Intent(context, RecorderService::class.java).apply { + if (bluetoothSelected) { + val inputDevice = findBluetoothInputDevice() + val outputDevice = findBluetoothOutputDevice() + if (inputDevice != null) { + putExtra(EXTRA_PREFERRED_AUDIO_DEVICE_ID, inputDevice.id) + } + if (outputDevice != null) { + putExtra(EXTRA_BT_OUTPUT_DEVICE_ID, outputDevice.id) + } + } context.startService(this) } } + private fun setupTabSelector() { + refreshBluetoothVisibility() + + binding.tabDefault.setDebouncedClickListener { + if (bluetoothSelected) { + bluetoothSelected = false + refreshDeviceSelectorStatus() + } + } + + binding.tabBluetooth.setDebouncedClickListener { + if (findBluetoothInputDevice() == null) return@setDebouncedClickListener + if (!bluetoothSelected) { + ensureBluetoothPermission { + bluetoothSelected = true + refreshDeviceSelectorStatus() + } + } + } + } + + private fun refreshBluetoothVisibility() { + val hasBtDevice = findBluetoothInputDevice() != null + if (!hasBtDevice && bluetoothSelected) { + bluetoothSelected = false + } + refreshDeviceSelectorStatus() + binding.microphoneSelectorHolder.beVisibleIf(status == RECORDING_STOPPED) + } + + private fun registerAudioDeviceCallback() { + if (audioDeviceCallback != null) return + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val callback = object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array?) { + refreshBluetoothVisibility() + } + + override fun onAudioDevicesRemoved(removedDevices: Array?) { + refreshBluetoothVisibility() + } + } + audioManager.registerAudioDeviceCallback(callback, Handler(Looper.getMainLooper())) + audioDeviceCallback = callback + } + + private fun unregisterAudioDeviceCallback() { + val callback = audioDeviceCallback ?: return + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + audioManager.unregisterAudioDeviceCallback(callback) + audioDeviceCallback = null + } + + private fun hasBluetoothPermission(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + ContextCompat.checkSelfPermission( + context, Manifest.permission.BLUETOOTH_CONNECT + ) == PackageManager.PERMISSION_GRANTED + } + + private fun findBluetoothInputDevice(): AudioDeviceInfo? { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + return audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS) + .firstOrNull { BluetoothScoManager.isBluetoothDevice(it) } + } + + private fun findBluetoothOutputDevice(): AudioDeviceInfo? { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + return audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + .firstOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO } + ?: audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + .firstOrNull { BluetoothScoManager.isBluetoothDevice(it) } + } + + @SuppressLint("InlinedApi") + private fun ensureBluetoothPermission(callback: () -> Unit) { + if (hasBluetoothPermission()) { + callback() + return + } + + val activity = context as? BaseSimpleActivity ?: return + ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.BLUETOOTH_CONNECT), + BLUETOOTH_PERMISSION_REQUEST_CODE + ) + } + private fun showCancelRecordingDialog() { val activity = context as? BaseSimpleActivity ?: return ConfirmationDialog( @@ -200,8 +365,7 @@ class RecorderFragment( if (status == RECORDING_PAUSED) { // update just the alpha so that it will always be clickable Handler(Looper.getMainLooper()).post { - binding.toggleRecordingButton.alpha = - if (binding.toggleRecordingButton.alpha == 0f) 1f else 0f + binding.toggleRecordingButton.alpha = if (binding.toggleRecordingButton.alpha == 0f) 1f else 0f } } } @@ -212,6 +376,11 @@ class RecorderFragment( binding.toggleRecordingButton.setImageDrawable(getToggleButtonIcon()) binding.saveRecordingButton.beVisibleIf(status != RECORDING_STOPPED) binding.cancelRecordingButton.beVisibleIf(status != RECORDING_STOPPED) + if (status == RECORDING_STOPPED) { + refreshBluetoothVisibility() + } else { + binding.microphoneSelectorHolder.beVisibleIf(false) + } pauseBlinkTimer.cancel() when (status) { @@ -231,6 +400,7 @@ class RecorderFragment( binding.toggleRecordingButton.alpha = 1f binding.recorderVisualizer.recreate() binding.recordingDuration.text = null + context.getActivity().setKeepScreenAwake(false) } } } @@ -256,4 +426,9 @@ class RecorderFragment( binding.recorderVisualizer.update(amplitude) } } + + companion object { + private const val BLUETOOTH_PERMISSION_REQUEST_CODE = 100 + private const val BT_DISABLED_ALPHA = 0.4f + } } diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/TrashFragment.kt b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/TrashFragment.kt index 87fdd22c..e6c6c18f 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/TrashFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/TrashFragment.kt @@ -1,6 +1,7 @@ package org.fossify.voicerecorder.fragments import android.content.Context +import android.net.Uri import android.util.AttributeSet import org.fossify.commons.extensions.areSystemAnimationsEnabled import org.fossify.commons.extensions.beVisibleIf @@ -13,20 +14,19 @@ import org.fossify.voicerecorder.databinding.FragmentTrashBinding import org.fossify.voicerecorder.extensions.config import org.fossify.voicerecorder.interfaces.RefreshRecordingsListener import org.fossify.voicerecorder.models.Events -import org.fossify.voicerecorder.models.Recording +import org.fossify.voicerecorder.store.Recording import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode class TrashFragment( - context: Context, - attributeSet: AttributeSet + context: Context, attributeSet: AttributeSet ) : MyViewPagerFragment(context, attributeSet), RefreshRecordingsListener { private var itemsIgnoringSearch = ArrayList() private var lastSearchQuery = "" private var bus: EventBus? = null - private var prevSavePath = "" + private var prevSaveFolder: Uri? = null private lateinit var binding: FragmentTrashBinding override fun onFinishInflate() { @@ -36,7 +36,7 @@ class TrashFragment( override fun onResume() { setupColors() - if (prevSavePath.isNotEmpty() && context!!.config.saveRecordingsFolder != prevSavePath) { + if (prevSaveFolder != null && context!!.config.saveRecordingsFolder != prevSaveFolder) { loadRecordings(trashed = true) } else { getRecordingsAdapter()?.updateTextColor(context.getProperTextColor()) @@ -106,15 +106,15 @@ class TrashFragment( fun onSearchTextChanged(text: String) { lastSearchQuery = text - val filtered = itemsIgnoringSearch.filter { it.title.contains(text, true) } - .toMutableList() as ArrayList + val filtered = + itemsIgnoringSearch.filter { it.title.contains(text, true) }.toMutableList() as ArrayList setupAdapter(filtered) } private fun getRecordingsAdapter() = binding.trashList.adapter as? TrashAdapter private fun storePrevPath() { - prevSavePath = context!!.config.saveRecordingsFolder + prevSaveFolder = context!!.config.saveRecordingsFolder } private fun setupColors() { diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/BluetoothScoManager.kt b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/BluetoothScoManager.kt new file mode 100644 index 00000000..c7e99770 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/BluetoothScoManager.kt @@ -0,0 +1,59 @@ +package org.fossify.voicerecorder.helpers + +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build + +class BluetoothScoManager(private val audioManager: AudioManager) { + + companion object { + fun isBluetoothDevice(device: AudioDeviceInfo): Boolean { + return device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || + device.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + device.type == AudioDeviceInfo.TYPE_BLE_HEADSET) + } + } + + var isActive: Boolean = false + private set + + private var previousAudioMode: Int = AudioManager.MODE_NORMAL + + fun start(device: AudioDeviceInfo? = null, onReady: (() -> Unit)? = null) { + if (isActive) { + onReady?.invoke() + return + } + + previousAudioMode = audioManager.mode + audioManager.mode = AudioManager.MODE_IN_COMMUNICATION + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (device != null) { + audioManager.setCommunicationDevice(device) + } + } else { + @Suppress("DEPRECATION") + audioManager.startBluetoothSco() + @Suppress("DEPRECATION") + audioManager.isBluetoothScoOn = true + } + isActive = true + onReady?.invoke() + } + + fun stop() { + if (!isActive) return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + audioManager.clearCommunicationDevice() + } else { + @Suppress("DEPRECATION") + audioManager.isBluetoothScoOn = false + @Suppress("DEPRECATION") + audioManager.stopBluetoothSco() + } + audioManager.mode = previousAudioMode + isActive = false + } +} diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Config.kt b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Config.kt index 2ab91c17..7c53e70e 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Config.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Config.kt @@ -1,26 +1,32 @@ package org.fossify.voicerecorder.helpers -import android.annotation.SuppressLint import android.content.Context import android.media.MediaRecorder +import android.net.Uri import androidx.core.content.edit +import androidx.core.net.toUri +import org.fossify.commons.extensions.createFirstParentTreeUri import org.fossify.commons.helpers.BaseConfig import org.fossify.voicerecorder.R -import org.fossify.voicerecorder.extensions.getDefaultRecordingsFolder +import org.fossify.voicerecorder.store.DEFAULT_MEDIA_URI +import org.fossify.voicerecorder.store.RecordingFormat class Config(context: Context) : BaseConfig(context) { companion object { fun newInstance(context: Context) = Config(context) } - var saveRecordingsFolder: String - get() = prefs.getString(SAVE_RECORDINGS, context.getDefaultRecordingsFolder())!! - set(saveRecordingsFolder) = prefs.edit().putString(SAVE_RECORDINGS, saveRecordingsFolder) - .apply() + var saveRecordingsFolder: Uri + get() = when (val value = prefs.getString(SAVE_RECORDINGS, null)) { + is String if value.startsWith("content:") -> value.toUri() + is String -> context.createFirstParentTreeUri(value) + null -> DEFAULT_MEDIA_URI + } + set(uri) = prefs.edit { putString(SAVE_RECORDINGS, uri.toString()) } - var extension: Int - get() = prefs.getInt(EXTENSION, EXTENSION_M4A) - set(extension) = prefs.edit().putInt(EXTENSION, extension).apply() + var recordingFormat: RecordingFormat + get() = prefs.getInt(EXTENSION, -1).let(RecordingFormat::fromInt) ?: RecordingFormat.M4A + set(format) = prefs.edit { putInt(EXTENSION, format.value) } var microphoneMode: Int get() = prefs.getInt(MICROPHONE_MODE, MediaRecorder.AudioSource.DEFAULT) @@ -50,34 +56,6 @@ class Config(context: Context) : BaseConfig(context) { set(recordAfterLaunch) = prefs.edit().putBoolean(RECORD_AFTER_LAUNCH, recordAfterLaunch) .apply() - fun getExtensionText() = context.getString( - when (extension) { - EXTENSION_M4A -> R.string.m4a - EXTENSION_OGG -> R.string.ogg_opus - else -> R.string.mp3_experimental - } - ) - - fun getExtension() = context.getString( - when (extension) { - EXTENSION_M4A -> R.string.m4a - EXTENSION_OGG -> R.string.ogg - else -> R.string.mp3 - } - ) - - @SuppressLint("InlinedApi") - fun getOutputFormat() = when (extension) { - EXTENSION_OGG -> MediaRecorder.OutputFormat.OGG - else -> MediaRecorder.OutputFormat.MPEG_4 - } - - @SuppressLint("InlinedApi") - fun getAudioEncoder() = when (extension) { - EXTENSION_OGG -> MediaRecorder.AudioEncoder.OPUS - else -> MediaRecorder.AudioEncoder.AAC - } - var useRecycleBin: Boolean get() = prefs.getBoolean(USE_RECYCLE_BIN, true) set(useRecycleBin) = prefs.edit().putBoolean(USE_RECYCLE_BIN, useRecycleBin).apply() @@ -100,4 +78,12 @@ class Config(context: Context) : BaseConfig(context) { var filenamePattern: String get() = prefs.getString(FILENAME_PATTERN, DEFAULT_FILENAME_PATTERN)!! set(filenamePattern) = prefs.edit { putString(FILENAME_PATTERN, filenamePattern) } + + var transcribeModelId: String? + get() = prefs.getString(TRANSCRIBE_MODEL_ID, null) + set(value) = prefs.edit { putString(TRANSCRIBE_MODEL_ID, value) } + + var transcribeLanguage: String + get() = prefs.getString(TRANSCRIBE_LANGUAGE, DEFAULT_TRANSCRIBE_LANGUAGE)!! + set(value) = prefs.edit { putString(TRANSCRIBE_LANGUAGE, value) } } diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Constants.kt b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Constants.kt index 847b696b..a083febd 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Constants.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Constants.kt @@ -2,9 +2,12 @@ package org.fossify.voicerecorder.helpers +import org.fossify.voicerecorder.store.RecordingFormat + const val REPOSITORY_NAME = "Voice-Recorder" const val RECORDER_RUNNING_NOTIF_ID = 10000 +const val TRANSCRIPTION_NOTIF_ID = 10001 private const val PATH = "com.fossify.voicerecorder.action." const val GET_RECORDER_INFO = PATH + "GET_RECORDER_INFO" @@ -12,9 +15,16 @@ const val STOP_AMPLITUDE_UPDATE = PATH + "STOP_AMPLITUDE_UPDATE" const val TOGGLE_PAUSE = PATH + "TOGGLE_PAUSE" const val CANCEL_RECORDING = PATH + "CANCEL_RECORDING" -const val EXTENSION_M4A = 0 -const val EXTENSION_MP3 = 1 -const val EXTENSION_OGG = 2 +const val ACTION_START_TRANSCRIPTION = PATH + "START_TRANSCRIPTION" +const val ACTION_CANCEL_TRANSCRIPTION = PATH + "CANCEL_TRANSCRIPTION" +const val ACTION_DOWNLOAD_MODEL = PATH + "DOWNLOAD_MODEL" +const val ACTION_CANCEL_MODEL_DOWNLOAD = PATH + "CANCEL_MODEL_DOWNLOAD" + +const val EXTRA_PREFERRED_AUDIO_DEVICE_ID = "preferred_audio_device_id" +const val EXTRA_BT_OUTPUT_DEVICE_ID = "bt_output_device_id" +const val EXTRA_RECORDING_URI = "recording_uri" +const val EXTRA_MODEL_ID = "model_id" +const val EXTRA_LANGUAGE = "language" val BITRATES_MP3 = arrayListOf( 8000, 16000, 24000, 32000, 64000, 96000, 128000, 160000, 192000, 256000, 320000 @@ -26,9 +36,9 @@ val BITRATES_OPUS = arrayListOf( 8000, 16000, 24000, 32000, 64000, 96000, 128000, 160000, 192000, 256000, 320000 ) val BITRATES = mapOf( - EXTENSION_M4A to BITRATES_M4A, - EXTENSION_MP3 to BITRATES_MP3, - EXTENSION_OGG to BITRATES_OPUS + RecordingFormat.M4A to BITRATES_M4A, + RecordingFormat.MP3 to BITRATES_MP3, + RecordingFormat.OGG to BITRATES_OPUS ) const val DEFAULT_BITRATE = 96000 @@ -36,9 +46,9 @@ val SAMPLING_RATES_MP3 = arrayListOf(8000, 11025, 12000, 16000, 22050, 24000, 32 val SAMPLING_RATES_M4A = arrayListOf(11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000) val SAMPLING_RATES_OPUS = arrayListOf(8000, 12000, 16000, 24000, 48000) val SAMPLING_RATES = mapOf( - EXTENSION_M4A to SAMPLING_RATES_M4A, - EXTENSION_MP3 to SAMPLING_RATES_MP3, - EXTENSION_OGG to SAMPLING_RATES_OPUS + RecordingFormat.M4A to SAMPLING_RATES_M4A, + RecordingFormat.MP3 to SAMPLING_RATES_MP3, + RecordingFormat.OGG to SAMPLING_RATES_OPUS ) const val DEFAULT_SAMPLING_RATE = 48000 @@ -79,9 +89,9 @@ val SAMPLING_RATE_BITRATE_LIMITS_OPUS = mapOf( ) val SAMPLING_RATE_BITRATE_LIMITS = mapOf( - EXTENSION_M4A to SAMPLING_RATE_BITRATE_LIMITS_M4A, - EXTENSION_MP3 to SAMPLING_RATE_BITRATE_LIMITS_MP3, - EXTENSION_OGG to SAMPLING_RATE_BITRATE_LIMITS_OPUS + RecordingFormat.M4A to SAMPLING_RATE_BITRATE_LIMITS_M4A, + RecordingFormat.MP3 to SAMPLING_RATE_BITRATE_LIMITS_MP3, + RecordingFormat.OGG to SAMPLING_RATE_BITRATE_LIMITS_OPUS ) const val RECORDING_RUNNING = 0 @@ -103,6 +113,8 @@ const val LAST_RECYCLE_BIN_CHECK = "last_recycle_bin_check" const val KEEP_SCREEN_ON = "keep_screen_on" const val WAS_MIC_MODE_WARNING_SHOWN = "was_mic_mode_warning_shown" const val FILENAME_PATTERN = "filename_pattern" +const val TRANSCRIBE_MODEL_ID = "transcribe_model_id" +const val TRANSCRIBE_LANGUAGE = "transcribe_language" -const val DEFAULT_RECORDINGS_FOLDER = "Recordings" const val DEFAULT_FILENAME_PATTERN = "%Y%M%D_%h%m%s" +const val DEFAULT_TRANSCRIBE_LANGUAGE = "" diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/interfaces/RefreshRecordingsListener.kt b/app/src/main/kotlin/org/fossify/voicerecorder/interfaces/RefreshRecordingsListener.kt index 55bbb108..4d5cefcb 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/interfaces/RefreshRecordingsListener.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/interfaces/RefreshRecordingsListener.kt @@ -1,6 +1,7 @@ package org.fossify.voicerecorder.interfaces -import org.fossify.voicerecorder.models.Recording +import org.fossify.voicerecorder.store.Recording + interface RefreshRecordingsListener { fun refreshRecordings() diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/models/Events.kt b/app/src/main/kotlin/org/fossify/voicerecorder/models/Events.kt index d83cca10..93321733 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/models/Events.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/models/Events.kt @@ -8,5 +8,26 @@ class Events { class RecordingAmplitude internal constructor(val amplitude: Int) class RecordingCompleted internal constructor() class RecordingTrashUpdated internal constructor() - class RecordingSaved internal constructor(val uri: Uri?) + class RecordingSaved internal constructor(val uri: Uri) + class RecordingFailed internal constructor(val exception: Exception) + + class TranscriptionStarted internal constructor(val recordingUri: Uri) + class TranscriptionProgress internal constructor( + val recordingUri: Uri, + val phase: TranscriptionPhase, + val fraction: Float, + ) + class TranscriptionCompleted internal constructor(val recordingUri: Uri) + class TranscriptionFailed internal constructor(val recordingUri: Uri, val cause: Throwable) + class TranscriptionCancelled internal constructor(val recordingUri: Uri) + class TranscriptDeleted internal constructor(val recordingUri: Uri) + + class ModelDownloadStarted internal constructor(val modelId: String) + class ModelDownloadProgress internal constructor(val modelId: String, val fraction: Float) + class ModelDownloadCompleted internal constructor(val modelId: String) + class ModelDownloadFailed internal constructor(val modelId: String, val cause: Throwable) + class ModelDownloadCancelled internal constructor(val modelId: String) } + +enum class TranscriptionPhase { DOWNLOADING_MODEL, DECODING, TRANSCRIBING, WRITING } + diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/models/Recording.kt b/app/src/main/kotlin/org/fossify/voicerecorder/models/Recording.kt deleted file mode 100644 index 24285ee4..00000000 --- a/app/src/main/kotlin/org/fossify/voicerecorder/models/Recording.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.fossify.voicerecorder.models - -data class Recording( - val id: Int, - val title: String, - val path: String, - val timestamp: Long, - val duration: Int, - val size: Int -) diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/recorder/MediaRecorderWrapper.kt b/app/src/main/kotlin/org/fossify/voicerecorder/recorder/MediaRecorderWrapper.kt index 6af3e964..aeb2e442 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/recorder/MediaRecorderWrapper.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/recorder/MediaRecorderWrapper.kt @@ -2,30 +2,49 @@ package org.fossify.voicerecorder.recorder import android.annotation.SuppressLint import android.content.Context +import android.media.AudioDeviceInfo import android.media.MediaRecorder +import android.os.Build import android.os.ParcelFileDescriptor import org.fossify.voicerecorder.extensions.config +import org.fossify.voicerecorder.store.RecordingFormat -class MediaRecorderWrapper(val context: Context) : Recorder { +class MediaRecorderWrapper(val context: Context, audioSourceOverride: Int? = null) : Recorder { + + private var outputParcelFileDescriptor: ParcelFileDescriptor? = null + + private var recorder = createMediaRecorder().apply { + setAudioSource(audioSourceOverride ?: context.config.microphoneMode) + + when (context.config.recordingFormat) { + RecordingFormat.M4A -> { + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + } + RecordingFormat.OGG -> { + setOutputFormat(MediaRecorder.OutputFormat.OGG) + setAudioEncoder(MediaRecorder.AudioEncoder.OPUS) + } + else -> error("unsupported format for MediaRecorder: ${context.config.recordingFormat}") + } - @Suppress("DEPRECATION") - private var recorder = MediaRecorder().apply { - setAudioSource(context.config.microphoneMode) - setOutputFormat(context.config.getOutputFormat()) - setAudioEncoder(context.config.getAudioEncoder()) setAudioEncodingBitRate(context.config.bitrate) setAudioSamplingRate(context.config.samplingRate) } - override fun setOutputFile(path: String) { - recorder.setOutputFile(path) - } - override fun setOutputFile(parcelFileDescriptor: ParcelFileDescriptor) { + outputParcelFileDescriptor?.close() val pFD = ParcelFileDescriptor.dup(parcelFileDescriptor.fileDescriptor) + outputParcelFileDescriptor = pFD recorder.setOutputFile(pFD.fileDescriptor) } + override fun setPreferredDevice(device: AudioDeviceInfo?) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + recorder.setPreferredDevice(device) + } + } + override fun prepare() { recorder.prepare() } @@ -50,9 +69,20 @@ class MediaRecorderWrapper(val context: Context) : Recorder { override fun release() { recorder.release() + outputParcelFileDescriptor?.close() + outputParcelFileDescriptor = null } override fun getMaxAmplitude(): Int { return recorder.maxAmplitude } + + @Suppress("DEPRECATION") + private fun createMediaRecorder(): MediaRecorder { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + MediaRecorder() + } + } } diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Mp3Recorder.kt b/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Mp3Recorder.kt index 68985145..f81b11d9 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Mp3Recorder.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Mp3Recorder.kt @@ -2,31 +2,27 @@ package org.fossify.voicerecorder.recorder import android.annotation.SuppressLint import android.content.Context +import android.media.AudioDeviceInfo import android.media.AudioFormat import android.media.AudioRecord import android.os.ParcelFileDescriptor import com.naman14.androidlame.AndroidLame import com.naman14.androidlame.LameBuilder import org.fossify.commons.extensions.showErrorToast -import org.fossify.commons.helpers.ensureBackgroundThread import org.fossify.voicerecorder.extensions.config -import java.io.File -import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.IOException import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import kotlin.math.abs -class Mp3Recorder(val context: Context) : Recorder { +class Mp3Recorder(val context: Context, audioSourceOverride: Int? = null) : Recorder { private var mp3buffer: ByteArray = ByteArray(0) private var isPaused = AtomicBoolean(false) private var isStopped = AtomicBoolean(false) private var amplitude = AtomicInteger(0) - private var outputPath: String? = null private var androidLame: AndroidLame? = null - private var fileDescriptor: ParcelFileDescriptor? = null - private var outputStream: FileOutputStream? = null + private var outputFileDescriptor: ParcelFileDescriptor? = null private val minBufferSize = AudioRecord.getMinBufferSize( context.config.samplingRate, AudioFormat.CHANNEL_IN_MONO, @@ -35,15 +31,17 @@ class Mp3Recorder(val context: Context) : Recorder { @SuppressLint("MissingPermission") private val audioRecord = AudioRecord( - context.config.microphoneMode, + audioSourceOverride ?: context.config.microphoneMode, context.config.samplingRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, minBufferSize * 2 ) - override fun setOutputFile(path: String) { - outputPath = path + private var thread: Thread? = null + + override fun setPreferredDevice(device: AudioDeviceInfo?) { + audioRecord.setPreferredDevice(device) } override fun prepare() {} @@ -52,16 +50,8 @@ class Mp3Recorder(val context: Context) : Recorder { val rawData = ShortArray(minBufferSize) mp3buffer = ByteArray((7200 + rawData.size * 2 * 1.25).toInt()) - outputStream = try { - if (fileDescriptor != null) { - FileOutputStream(fileDescriptor!!.fileDescriptor) - } else { - FileOutputStream(File(outputPath!!)) - } - } catch (e: FileNotFoundException) { - e.printStackTrace() - return - } + val outputFileDescriptor = requireNotNull(this.outputFileDescriptor) + val outputStream = FileOutputStream(outputFileDescriptor.fileDescriptor) androidLame = LameBuilder() .setInSampleRate(context.config.samplingRate) @@ -70,37 +60,42 @@ class Mp3Recorder(val context: Context) : Recorder { .setOutChannels(1) .build() - ensureBackgroundThread { + thread = Thread { try { audioRecord.startRecording() } catch (e: Exception) { context.showErrorToast(e) - return@ensureBackgroundThread + return@Thread } - while (!isStopped.get()) { - if (!isPaused.get()) { - val count = audioRecord.read(rawData, 0, minBufferSize) - if (count > 0) { - val encoded = androidLame!!.encode(rawData, rawData, count, mp3buffer) - if (encoded > 0) { - try { - updateAmplitude(rawData) - outputStream!!.write(mp3buffer, 0, encoded) - } catch (e: IOException) { - e.printStackTrace() + outputStream.use { outputStream -> + while (!isStopped.get()) { + if (!isPaused.get()) { + val count = audioRecord.read(rawData, 0, minBufferSize) + if (count > 0) { + val encoded = androidLame!!.encode(rawData, rawData, count, mp3buffer) + if (encoded > 0) { + try { + updateAmplitude(rawData) + outputStream.write(mp3buffer, 0, encoded) + } catch (e: IOException) { + e.printStackTrace() + } } } } } } - } + }.apply { start() } } override fun stop() { isPaused.set(true) isStopped.set(true) audioRecord.stop() + + thread?.join() // ensures the buffer is fully written to the output file before continuing + thread = null } override fun pause() { @@ -113,7 +108,6 @@ class Mp3Recorder(val context: Context) : Recorder { override fun release() { androidLame?.flush(mp3buffer) - outputStream?.close() audioRecord.release() } @@ -122,7 +116,7 @@ class Mp3Recorder(val context: Context) : Recorder { } override fun setOutputFile(parcelFileDescriptor: ParcelFileDescriptor) { - this.fileDescriptor = ParcelFileDescriptor.dup(parcelFileDescriptor.fileDescriptor) + this.outputFileDescriptor = parcelFileDescriptor } private fun updateAmplitude(data: ShortArray) { diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Recorder.kt b/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Recorder.kt index 06a938c0..e788dbca 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Recorder.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Recorder.kt @@ -1,10 +1,11 @@ package org.fossify.voicerecorder.recorder +import android.media.AudioDeviceInfo import android.os.ParcelFileDescriptor interface Recorder { - fun setOutputFile(path: String) fun setOutputFile(parcelFileDescriptor: ParcelFileDescriptor) + fun setPreferredDevice(device: AudioDeviceInfo?) fun prepare() fun start() fun stop() diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt b/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt index 755c6efd..272cfd32 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt @@ -6,33 +6,31 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service +import android.content.Context import android.content.Intent -import android.media.MediaScannerConnection +import android.content.pm.ServiceInfo +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.media.MediaRecorder import android.net.Uri import android.os.IBinder -import android.provider.DocumentsContract +import android.util.Log import androidx.core.app.NotificationCompat -import androidx.core.content.FileProvider -import org.fossify.commons.extensions.createDocumentUriUsingFirstParentTreeUri -import org.fossify.commons.extensions.createSAFFileSdk30 -import org.fossify.commons.extensions.getDocumentFile -import org.fossify.commons.extensions.getFilenameFromPath import org.fossify.commons.extensions.getLaunchIntent -import org.fossify.commons.extensions.getMimeType -import org.fossify.commons.extensions.getParentPath -import org.fossify.commons.extensions.isPathOnSD import org.fossify.commons.extensions.showErrorToast import org.fossify.commons.extensions.toast import org.fossify.commons.helpers.ensureBackgroundThread -import org.fossify.commons.helpers.isRPlus -import org.fossify.voicerecorder.BuildConfig +import org.fossify.commons.helpers.isQPlus import org.fossify.voicerecorder.R import org.fossify.voicerecorder.activities.SplashActivity import org.fossify.voicerecorder.extensions.config import org.fossify.voicerecorder.extensions.getFormattedFilename +import org.fossify.voicerecorder.extensions.recordingStore import org.fossify.voicerecorder.extensions.updateWidgets +import org.fossify.voicerecorder.helpers.BluetoothScoManager import org.fossify.voicerecorder.helpers.CANCEL_RECORDING -import org.fossify.voicerecorder.helpers.EXTENSION_MP3 +import org.fossify.voicerecorder.helpers.EXTRA_BT_OUTPUT_DEVICE_ID +import org.fossify.voicerecorder.helpers.EXTRA_PREFERRED_AUDIO_DEVICE_ID import org.fossify.voicerecorder.helpers.GET_RECORDER_INFO import org.fossify.voicerecorder.helpers.RECORDER_RUNNING_NOTIF_ID import org.fossify.voicerecorder.helpers.RECORDING_PAUSED @@ -44,8 +42,9 @@ import org.fossify.voicerecorder.models.Events import org.fossify.voicerecorder.recorder.MediaRecorderWrapper import org.fossify.voicerecorder.recorder.Mp3Recorder import org.fossify.voicerecorder.recorder.Recorder +import org.fossify.voicerecorder.store.RecordingFormat +import org.fossify.voicerecorder.store.RecordingStore import org.greenrobot.eventbus.EventBus -import java.io.File import java.util.Timer import java.util.TimerTask @@ -54,17 +53,17 @@ class RecorderService : Service() { var isRunning = false private const val AMPLITUDE_UPDATE_MS = 75L - } - - private var recordingPath = "" - private var resultUri: Uri? = null + private const val TAG = "RecorderService" + } private var duration = 0 private var status = RECORDING_STOPPED private var durationTimer = Timer() private var amplitudeTimer = Timer() private var recorder: Recorder? = null + private var writer: RecordingStore.Writer? = null + private var bluetoothScoManager: BluetoothScoManager? = null override fun onBind(intent: Intent?): IBinder? = null @@ -76,7 +75,7 @@ class RecorderService : Service() { STOP_AMPLITUDE_UPDATE -> amplitudeTimer.cancel() TOGGLE_PAUSE -> togglePause() CANCEL_RECORDING -> cancelRecording() - else -> startRecording() + else -> startRecording(intent) } return START_NOT_STICKY @@ -85,121 +84,173 @@ class RecorderService : Service() { override fun onDestroy() { super.onDestroy() stopRecording() - isRunning = false updateWidgets(false) } // mp4 output format with aac encoding should produce good enough m4a files according to https://stackoverflow.com/a/33054794/1967672 @SuppressLint("DiscouragedApi") - private fun startRecording() { + private fun startRecording(intent: Intent) { isRunning = true updateWidgets(true) - if (status == RECORDING_RUNNING) { + if (status == RECORDING_RUNNING || status == RECORDING_PAUSED) { return } - val defaultFolder = File(config.saveRecordingsFolder) - if (!defaultFolder.exists()) { - defaultFolder.mkdir() - } - - val recordingFolder = defaultFolder.absolutePath - recordingPath = "$recordingFolder/${getFormattedFilename()}.${config.getExtension()}" - resultUri = null + val recordingFormat = config.recordingFormat try { - recorder = if (recordMp3()) { - Mp3Recorder(this) - } else { - MediaRecorderWrapper(this) + val recordingName = "${getFormattedFilename()}.${recordingFormat.getExtension(this)}" + try { + this.writer = recordingStore.createWriter(recordingName) + } catch (e: Exception) { + cancelRecording() + EventBus.getDefault().post(Events.RecordingFailed(e)) + return } - if (isRPlus()) { - val fileUri = createDocumentUriUsingFirstParentTreeUri(recordingPath) - createSAFFileSdk30(recordingPath) - resultUri = fileUri - contentResolver.openFileDescriptor(fileUri, "w")!! - .use { recorder?.setOutputFile(it) } - } else if (isPathOnSD(recordingPath)) { - var document = getDocumentFile(recordingPath.getParentPath()) - document = document?.createFile("", recordingPath.getFilenameFromPath()) - check(document != null) { "Failed to create document on SD Card" } - resultUri = document.uri - contentResolver.openFileDescriptor(document.uri, "w")!! - .use { recorder?.setOutputFile(it) } - } else { - recorder?.setOutputFile(recordingPath) - resultUri = FileProvider.getUriForFile( - this, "${BuildConfig.APPLICATION_ID}.provider", File(recordingPath) - ) - } + val preferredDeviceId = intent.getIntExtra(EXTRA_PREFERRED_AUDIO_DEVICE_ID, -1) + val btOutputDeviceId = intent.getIntExtra(EXTRA_BT_OUTPUT_DEVICE_ID, -1) - recorder?.prepare() - recorder?.start() - duration = 0 - status = RECORDING_RUNNING - broadcastRecorderInfo() - startForeground(RECORDER_RUNNING_NOTIF_ID, showNotification()) + if (preferredDeviceId != -1) { + val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + val scoManager = BluetoothScoManager(audioManager) + bluetoothScoManager = scoManager + + val inputDevice = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS) + .firstOrNull { it.id == preferredDeviceId } - durationTimer = Timer() - durationTimer.scheduleAtFixedRate(getDurationUpdateTask(), 1000, 1000) + // Not setting the output device doesn't seem to enable the microphone. + // So, we set both an OUTPUT device and an INPUT device + val outputDevice = if (btOutputDeviceId != -1) { + audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + .firstOrNull { it.id == btOutputDeviceId } + } else { + null + } + + if (inputDevice != null && BluetoothScoManager.isBluetoothDevice(inputDevice)) { + scoManager.start(outputDevice ?: inputDevice) { + try { + createAndStartRecorder( + recordingFormat = recordingFormat, + audioSourceOverride = MediaRecorder.AudioSource.VOICE_COMMUNICATION, + preferredDevice = inputDevice + ) + } catch (e: Exception) { + showErrorToast(e) + stopRecording() + } + } + return + } + } - startAmplitudeUpdates() + createAndStartRecorder( + recordingFormat = recordingFormat, + audioSourceOverride = null, + preferredDevice = null + ) } catch (e: Exception) { showErrorToast(e) stopRecording() } } + private fun createAndStartRecorder( + recordingFormat: RecordingFormat, + audioSourceOverride: Int?, + preferredDevice: AudioDeviceInfo?, + ) { + val writer = checkNotNull(writer) { "writer must be created before recorder" } + + recorder = when (recordingFormat) { + RecordingFormat.M4A, RecordingFormat.OGG -> MediaRecorderWrapper(this, audioSourceOverride) + RecordingFormat.MP3 -> Mp3Recorder(this, audioSourceOverride) + } + recorder?.setPreferredDevice(preferredDevice) + recorder?.setOutputFile(writer.fileDescriptor) + + if (isQPlus()) { + startForeground( + RECORDER_RUNNING_NOTIF_ID, + showNotification(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + ) + } else { + startForeground(RECORDER_RUNNING_NOTIF_ID, showNotification()) + } + + recorder?.prepare() + recorder?.start() + duration = 0 + status = RECORDING_RUNNING + broadcastRecorderInfo() + + durationTimer = Timer() + durationTimer.scheduleAtFixedRate(getDurationUpdateTask(), 1000, 1000) + + startAmplitudeUpdates() + } + private fun stopRecording() { durationTimer.cancel() amplitudeTimer.cancel() status = RECORDING_STOPPED + isRunning = false + broadcastStatus() + bluetoothScoManager?.stop() - recorder?.apply { - try { + try { + recorder?.apply { stop() release() - } catch ( - @Suppress( - "TooGenericExceptionCaught", - "SwallowedException" - ) e: RuntimeException - ) { - toast(R.string.recording_too_short) - } catch (e: Exception) { - showErrorToast(e) - e.printStackTrace() } + } catch ( + @Suppress( + "TooGenericExceptionCaught", "SwallowedException" + ) e: RuntimeException + ) { + toast(R.string.recording_too_short) + } catch (e: Exception) { + Log.e(TAG, "failed to stop recording", e) + showErrorToast(e) + } finally { + recorder = null + } + writer?.let { writer -> ensureBackgroundThread { - scanRecording() - EventBus.getDefault().post(Events.RecordingCompleted()) + try { + val uri = writer.commit() + recordingSavedSuccessfully(uri) + EventBus.getDefault().post(Events.RecordingCompleted()) + } catch (e: Exception) { + Log.e(TAG, "failed to commit recording writer", e) + showErrorToast(e) + } } } - recorder = null + writer = null } private fun cancelRecording() { durationTimer.cancel() amplitudeTimer.cancel() status = RECORDING_STOPPED + bluetoothScoManager?.stop() - recorder?.apply { - try { + try { + recorder?.apply { stop() release() - } catch (ignored: Exception) { } + } catch (_: Exception) { } recorder = null - if (isRPlus()) { - val recordingUri = createDocumentUriUsingFirstParentTreeUri(recordingPath) - DocumentsContract.deleteDocument(contentResolver, recordingUri) - } else { - File(recordingPath).delete() - } + + writer?.cancel() + writer = null EventBus.getDefault().post(Events.RecordingCompleted()) stopSelf() @@ -229,27 +280,20 @@ class RecorderService : Service() { status = RECORDING_RUNNING } broadcastStatus() - startForeground(RECORDER_RUNNING_NOTIF_ID, showNotification()) + if (isQPlus()) { + startForeground( + RECORDER_RUNNING_NOTIF_ID, + showNotification(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + ) + } else { + startForeground(RECORDER_RUNNING_NOTIF_ID, showNotification()) + } } catch (e: Exception) { showErrorToast(e) } } - private fun scanRecording() { - MediaScannerConnection.scanFile( - this, - arrayOf(recordingPath), - arrayOf(recordingPath.getMimeType()) - ) { _, uri -> - if (uri == null) { - toast(org.fossify.commons.R.string.unknown_error_occurred) - return@scanFile - } - - recordingSavedSuccessfully(resultUri ?: uri) - } - } - private fun recordingSavedSuccessfully(savedUri: Uri) { toast(R.string.recording_saved_successfully) EventBus.getDefault().post(Events.RecordingSaved(savedUri)) @@ -268,9 +312,8 @@ class RecorderService : Service() { override fun run() { if (recorder != null) { try { - EventBus.getDefault() - .post(Events.RecordingAmplitude(recorder!!.getMaxAmplitude())) - } catch (ignored: Exception) { + EventBus.getDefault().post(Events.RecordingAmplitude(recorder!!.getMaxAmplitude())) + } catch (_: Exception) { } } } @@ -287,23 +330,16 @@ class RecorderService : Service() { } val icon = R.drawable.ic_graphic_eq_vector - val title = label val visibility = NotificationCompat.VISIBILITY_PUBLIC var text = getString(R.string.recording) if (status == RECORDING_PAUSED) { text += " (${getString(R.string.paused)})" } - val builder = NotificationCompat.Builder(this, channelId) - .setContentTitle(title) - .setContentText(text) - .setSmallIcon(icon) - .setContentIntent(getOpenAppIntent()) - .setPriority(NotificationManager.IMPORTANCE_DEFAULT) - .setVisibility(visibility) - .setSound(null) - .setOngoing(true) - .setAutoCancel(true) + val builder = + NotificationCompat.Builder(this, channelId).setContentTitle(label).setContentText(text).setSmallIcon(icon) + .setContentIntent(getOpenAppIntent()).setPriority(NotificationManager.IMPORTANCE_DEFAULT) + .setVisibility(visibility).setSound(null).setOngoing(true).setAutoCancel(true) return builder.build() } @@ -311,10 +347,7 @@ class RecorderService : Service() { private fun getOpenAppIntent(): PendingIntent { val intent = getLaunchIntent() ?: Intent(this, SplashActivity::class.java) return PendingIntent.getActivity( - this, - RECORDER_RUNNING_NOTIF_ID, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + this, RECORDER_RUNNING_NOTIF_ID, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } @@ -325,8 +358,4 @@ class RecorderService : Service() { private fun broadcastStatus() { EventBus.getDefault().post(Events.RecordingStatus(status)) } - - private fun recordMp3(): Boolean { - return config.extension == EXTENSION_MP3 - } } diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/services/TranscriptionService.kt b/app/src/main/kotlin/org/fossify/voicerecorder/services/TranscriptionService.kt new file mode 100644 index 00000000..d5fa57d7 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/voicerecorder/services/TranscriptionService.kt @@ -0,0 +1,545 @@ +package org.fossify.voicerecorder.services + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.Uri +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.net.toUri +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.fossify.commons.helpers.isQPlus +import org.fossify.voicerecorder.R +import org.fossify.voicerecorder.extensions.config +import org.fossify.voicerecorder.extensions.recordingStore +import org.fossify.voicerecorder.helpers.ACTION_CANCEL_MODEL_DOWNLOAD +import org.fossify.voicerecorder.helpers.ACTION_CANCEL_TRANSCRIPTION +import org.fossify.voicerecorder.helpers.ACTION_DOWNLOAD_MODEL +import org.fossify.voicerecorder.helpers.EXTRA_LANGUAGE +import org.fossify.voicerecorder.helpers.EXTRA_MODEL_ID +import org.fossify.voicerecorder.helpers.EXTRA_RECORDING_URI +import org.fossify.voicerecorder.helpers.TRANSCRIPTION_NOTIF_ID +import org.fossify.voicerecorder.models.Events +import org.fossify.voicerecorder.models.TranscriptionPhase +import org.fossify.voicerecorder.store.Recording +import org.fossify.voicerecorder.store.TRANSCRIPT_SCHEMA_VERSION +import org.fossify.voicerecorder.store.Transcript +import org.fossify.voicerecorder.store.TranscriptSegment +import org.fossify.voicerecorder.store.TranscriptStore +import org.fossify.voicerecorder.transcribe.audio.AudioDecoder +import org.fossify.voicerecorder.transcribe.engine.SherpaTranscriber +import org.fossify.voicerecorder.transcribe.model.ModelCatalog +import org.fossify.voicerecorder.transcribe.model.ModelManager +import org.greenrobot.eventbus.EventBus +import org.fossify.voicerecorder.transcribe.audio.PcmChunk +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread + +/** + * Foreground service that runs Whisper transcription end-to-end: + * download model (if needed) → decode audio → run inference → write sidecar JSON. + * Posts progress on EventBus and shows a cancellable notification. + * + * v1: single-job; concurrent start requests are rejected while a job is active. + */ +@Suppress("TooManyFunctions") +class TranscriptionService : Service() { + + companion object { + var isRunning: Boolean = false + private set + var downloadingModelId: String? = null + private set + /** Wall-clock ms when the current transcription started, or null if none is running. */ + var transcriptionStartMs: Long? = null + private set + + private const val TAG = "TranscriptionService" + private const val CHANNEL_ID = "voice_recorder_transcription" + private const val ENGINE_NAME = "sherpa-onnx" + private const val ENGINE_VERSION = "1.12.40" + private const val PCT_MAX = 100 + private const val MS_PER_SECOND = 1000L + private const val CHUNK_QUEUE_CAPACITY = 2 + private const val QUEUE_OFFER_TIMEOUT_MS = 100L + private val EOF_SENTINEL = Any() + + /** Smooth-progress ticker cadence; the bar updates this often during a chunk. */ + private const val PROGRESS_TICK_MS = 400L + + /** Cap interpolation inside a chunk so the bar can't reach the chunk's end fraction + * before the chunk has actually finished — that would cause a visible reversal. */ + private const val INTERP_MAX_RATIO = 0.95f + + /** Seed for the rolling avg until the first chunk completes. ~Whisper-tiny on a + * midrange phone; the EMA converges to the real value within 1–2 chunks. */ + private const val INITIAL_CHUNK_WALL_MS = 6_000L + + /** Higher = faster adaptation, more jitter; lower = smoother but slower to track. */ + private const val EMA_ALPHA = 0.4f + } + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val isCancelled = AtomicBoolean(false) + private var currentJob: Job? = null + private var currentRecordingUri: Uri? = null + private var currentDownloadModelId: String? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + when (intent?.action) { + ACTION_CANCEL_TRANSCRIPTION, ACTION_CANCEL_MODEL_DOWNLOAD -> handleCancel() + ACTION_DOWNLOAD_MODEL -> handleDownloadModel(intent) + else -> handleStart(intent) + } + return START_NOT_STICKY + } + + override fun onDestroy() { + super.onDestroy() + scope.cancel() + isRunning = false + } + + private fun handleStart(intent: Intent?) { + intent ?: return + if (currentJob?.isActive == true) { + Log.w(TAG, "transcription already in progress; ignoring new start") + return + } + val uriStr = intent.getStringExtra(EXTRA_RECORDING_URI) ?: run { + stopSelf(); return + } + val recordingUri = uriStr.toUri() + val modelId = intent.getStringExtra(EXTRA_MODEL_ID) + ?: config.transcribeModelId + ?: ModelCatalog.DEFAULT.id + val language = intent.getStringExtra(EXTRA_LANGUAGE) ?: config.transcribeLanguage + + currentRecordingUri = recordingUri + isCancelled.set(false) + isRunning = true + transcriptionStartMs = System.currentTimeMillis() + startForegroundCompat(buildNotification(getString(R.string.transcribing), 0, indeterminate = true)) + EventBus.getDefault().post(Events.TranscriptionStarted(recordingUri)) + + currentJob = scope.launch { + try { + runPipeline(recordingUri, modelId, language) + EventBus.getDefault().post(Events.TranscriptionCompleted(recordingUri)) + } catch (@Suppress("SwallowedException") _: TranscriptionCancelledException) { + Log.i(TAG, "transcription cancelled for $recordingUri") + EventBus.getDefault().post(Events.TranscriptionCancelled(recordingUri)) + } catch (@Suppress("TooGenericExceptionCaught") t: Throwable) { + // Fail-safe: any failure in the pipeline must surface as TranscriptionFailed + // so the UI can leave the busy state. Specific causes are logged below. + Log.e(TAG, "transcription failed for $recordingUri", t) + EventBus.getDefault().post(Events.TranscriptionFailed(recordingUri, t)) + } finally { + stopForeground(STOP_FOREGROUND_REMOVE) + isRunning = false + transcriptionStartMs = null + currentRecordingUri = null + stopSelf() + } + } + } + + private fun handleCancel() { + isCancelled.set(true) + currentJob?.cancel() + } + + private fun handleDownloadModel(intent: Intent) { + if (currentJob?.isActive == true) { + Log.w(TAG, "another job is running; ignoring model download request") + return + } + val modelId = intent.getStringExtra(EXTRA_MODEL_ID) ?: run { + stopSelf(); return + } + val spec = ModelCatalog.byId(modelId) ?: run { + stopSelf(); return + } + + currentDownloadModelId = modelId + downloadingModelId = modelId + isCancelled.set(false) + isRunning = true + startForegroundCompat(buildNotification(getString(R.string.downloading_model), 0, indeterminate = true)) + EventBus.getDefault().post(Events.ModelDownloadStarted(modelId)) + + currentJob = scope.launch { + try { + val modelManager = ModelManager(this@TranscriptionService) + modelManager.downloadModel(spec, isCancelled) { downloaded, total -> + throwIfCancelled() + val frac = if (total > 0L) downloaded.toFloat() / total else 0f + EventBus.getDefault().post(Events.ModelDownloadProgress(modelId, frac)) + val pct = (frac * PCT_MAX).toInt().coerceIn(0, PCT_MAX) + startForegroundCompat( + buildNotification(getString(R.string.downloading_model), pct, indeterminate = false) + ) + } + EventBus.getDefault().post(Events.ModelDownloadCompleted(modelId)) + } catch (@Suppress("SwallowedException") _: TranscriptionCancelledException) { + Log.i(TAG, "model download cancelled for $modelId") + EventBus.getDefault().post(Events.ModelDownloadCancelled(modelId)) + } catch (@Suppress("TooGenericExceptionCaught") t: Throwable) { + // Fail-safe: any failure must surface so UI can leave the busy state. + // ModelManager throws IOException("cancelled") on cancel — treat that as Cancelled, not Failed. + if (isCancelled.get()) { + Log.i(TAG, "model download cancelled for $modelId") + EventBus.getDefault().post(Events.ModelDownloadCancelled(modelId)) + } else { + Log.e(TAG, "model download failed for $modelId", t) + EventBus.getDefault().post(Events.ModelDownloadFailed(modelId, t)) + } + } finally { + stopForeground(STOP_FOREGROUND_REMOVE) + isRunning = false + downloadingModelId = null + currentDownloadModelId = null + stopSelf() + } + } + } + + private fun runPipeline(recordingUri: Uri, modelId: String, language: String) { + val pipelineStartMs = System.currentTimeMillis() + val spec = ModelCatalog.byId(modelId) ?: ModelCatalog.DEFAULT + val modelManager = ModelManager(this) + + if (!modelManager.isModelInstalled(spec)) { + postProgress(recordingUri, TranscriptionPhase.DOWNLOADING_MODEL, 0f, R.string.downloading_model) + modelManager.downloadModel(spec, isCancelled) { downloaded, total -> + throwIfCancelled() + val frac = if (total > 0L) downloaded.toFloat() / total else 0f + postProgress(recordingUri, TranscriptionPhase.DOWNLOADING_MODEL, frac, R.string.downloading_model) + } + } + throwIfCancelled() + + val recording = findRecording(recordingUri) + ?: error("recording not found for uri $recordingUri") + val transcriptStore = TranscriptStore(this, config.saveRecordingsFolder) + val decoder = AudioDecoder(this, recordingUri) + + val transcriber = SherpaTranscriber( + encoderPath = modelManager.getEncoderPath(spec), + decoderPath = modelManager.getDecoderPath(spec), + tokensPath = modelManager.getTokensPath(spec), + language = language, + ) + + val segments = mutableListOf() + var detectedLanguage = language + val languageWasAuto = language.isBlank() + + try { + postProgress(recordingUri, TranscriptionPhase.DECODING, 0f, R.string.transcribing) + val expectedDurationMs = recording.duration.toLong() * MS_PER_SECOND + val pipelineResult = runPipelinedTranscribe( + decoder = decoder, + transcriber = transcriber, + recordingUri = recordingUri, + expectedDurationMs = expectedDurationMs, + segments = segments, + ) { language -> + if (detectedLanguage.isBlank() && language.isNotBlank()) detectedLanguage = language + } + val totalDurationMs = pipelineResult + + throwIfCancelled() + postProgress(recordingUri, TranscriptionPhase.WRITING, 1f, R.string.transcribing) + val transcript = Transcript( + schemaVersion = TRANSCRIPT_SCHEMA_VERSION, + recordingUri = recordingUri.toString(), + recordingName = recording.title, + engine = ENGINE_NAME, + engineVersion = ENGINE_VERSION, + model = spec.id, + modelSha256 = spec.expectedSha256, + language = detectedLanguage.ifBlank { "" }, + languageAutoDetected = languageWasAuto, + createdAtIso = nowIso(), + durationMs = totalDurationMs, + processingMs = System.currentTimeMillis() - pipelineStartMs, + segments = segments, + ) + transcriptStore.write(recording, transcript) + } finally { + transcriber.release() + } + } + + /** + * Runs the decoder on a worker thread and the recognizer on this coroutine, connected by a + * small bounded queue. The decoder hides its I/O / MediaCodec wait behind whatever the + * recognizer is currently doing on a previously emitted chunk. + * + * Progress is posted from the *consumer* side (chunkEndMs / expectedDurationMs) so the bar + * reflects audio actually transcribed, not audio merely decoded ahead. + * + * Returns the total decoded duration in ms (from MediaExtractor metadata). + */ + private fun runPipelinedTranscribe( + decoder: AudioDecoder, + transcriber: SherpaTranscriber, + recordingUri: Uri, + expectedDurationMs: Long, + segments: MutableList, + onLanguageDetected: (String) -> Unit, + ): Long { + val queue = ArrayBlockingQueue(CHUNK_QUEUE_CAPACITY) + val decoderError = AtomicReference() + val totalDurationMsRef = AtomicLong() + val progress = ChunkProgress() + val tickerStop = AtomicBoolean(false) + + val tickerThread = thread( + start = true, name = "TranscriptionProgressTicker", isDaemon = true, + ) { + runProgressTicker(recordingUri, progress, tickerStop) + } + + val decoderThread = thread( + start = true, name = "TranscriptionDecoder", isDaemon = true, + ) { + try { + val total = decoder.decodeChunks(isCancelled = isCancelled) { chunk -> + pollOfferUntilCancelled(queue, chunk) + } + totalDurationMsRef.set(total) + } catch (@Suppress("TooGenericExceptionCaught") t: Throwable) { + decoderError.set(t) + } finally { + runCatching { queue.put(EOF_SENTINEL) } + } + } + + try { + drainAndTranscribe( + queue = queue, + transcriber = transcriber, + expectedDurationMs = expectedDurationMs, + segments = segments, + onLanguageDetected = onLanguageDetected, + progress = progress, + ) + } catch (@Suppress("TooGenericExceptionCaught") t: Throwable) { + isCancelled.set(true) + throw t + } finally { + tickerStop.set(true) + tickerThread.interrupt() + decoderThread.join() + tickerThread.join() + } + + decoderError.get()?.let { throw it } + return totalDurationMsRef.get() + } + + /** + * Small bag of state shared between [drainAndTranscribe] (writer) and the progress + * ticker (reader). All fields are atomic so the reader sees consistent snapshots. + */ + private class ChunkProgress { + val chunkStartFraction = AtomicReference(0f) + val chunkEndFraction = AtomicReference(0f) + val chunkStartedAtMs = AtomicLong(0L) + val avgChunkWallMs = AtomicLong(INITIAL_CHUNK_WALL_MS) + val transcribing = AtomicBoolean(false) + } + + private fun runProgressTicker( + recordingUri: Uri, + progress: ChunkProgress, + stop: AtomicBoolean, + ) { + var lastPostedPct = -1 + var keepRunning = true + while (keepRunning && !stop.get() && !isCancelled.get()) { + keepRunning = sleepInterruptibly(PROGRESS_TICK_MS) + if (keepRunning && !stop.get() && !isCancelled.get()) { + lastPostedPct = postInterpolatedProgress(recordingUri, progress, lastPostedPct) + } + } + } + + private fun sleepInterruptibly(durationMs: Long): Boolean { + return try { + Thread.sleep(durationMs) + true + } catch (@Suppress("SwallowedException") _: InterruptedException) { + false + } + } + + private fun postInterpolatedProgress( + recordingUri: Uri, + progress: ChunkProgress, + lastPostedPct: Int, + ): Int { + val fraction = computeInterpolatedFraction(progress).coerceIn(0f, 1f) + val pct = (fraction * PCT_MAX).toInt().coerceIn(0, PCT_MAX) + // Always emit the EventBus event so the activity's bar moves smoothly, + // but only refresh the foreground notification when the rounded % changes — + // calling startForeground multiple times per second is needlessly expensive. + EventBus.getDefault().post( + Events.TranscriptionProgress(recordingUri, TranscriptionPhase.TRANSCRIBING, fraction) + ) + if (pct == lastPostedPct) return lastPostedPct + startForegroundCompat( + buildNotification(getString(R.string.transcribing), pct, indeterminate = false) + ) + return pct + } + + private fun computeInterpolatedFraction(progress: ChunkProgress): Float { + val startF = progress.chunkStartFraction.get() + val endF = progress.chunkEndFraction.get() + if (!progress.transcribing.get()) return endF + val startedAt = progress.chunkStartedAtMs.get() + if (startedAt <= 0L) return startF + val avgMs = progress.avgChunkWallMs.get().coerceAtLeast(1L) + val elapsed = System.currentTimeMillis() - startedAt + val ratio = (elapsed.toFloat() / avgMs).coerceIn(0f, INTERP_MAX_RATIO) + return startF + ratio * (endF - startF) + } + + private fun pollOfferUntilCancelled(queue: ArrayBlockingQueue, chunk: PcmChunk): Boolean { + while (!isCancelled.get()) { + if (queue.offer(chunk, QUEUE_OFFER_TIMEOUT_MS, TimeUnit.MILLISECONDS)) return true + } + return false + } + + private fun drainAndTranscribe( + queue: ArrayBlockingQueue, + transcriber: SherpaTranscriber, + expectedDurationMs: Long, + segments: MutableList, + onLanguageDetected: (String) -> Unit, + progress: ChunkProgress, + ) { + while (true) { + val item = queue.take() + if (item === EOF_SENTINEL) return + if (isCancelled.get()) return + val chunk = item as PcmChunk + + if (expectedDurationMs > 0L) { + progress.chunkStartFraction.set( + (chunk.startMs.toFloat() / expectedDurationMs).coerceIn(0f, 1f) + ) + progress.chunkEndFraction.set( + (chunk.endMs.toFloat() / expectedDurationMs).coerceIn(0f, 1f) + ) + } + progress.chunkStartedAtMs.set(System.currentTimeMillis()) + progress.transcribing.set(true) + + val result = transcriber.transcribeChunk(chunk) + onLanguageDetected(result.language) + segments += result.segments + + val wallMs = System.currentTimeMillis() - progress.chunkStartedAtMs.get() + val prevAvg = progress.avgChunkWallMs.get() + val newAvg = (EMA_ALPHA * wallMs + (1f - EMA_ALPHA) * prevAvg).toLong().coerceAtLeast(1L) + progress.avgChunkWallMs.set(newAvg) + progress.transcribing.set(false) + } + } + + private fun findRecording(uri: Uri): Recording? { + val store = recordingStore + return store.all().firstOrNull { it.uri == uri } + ?: store.all(trashed = true).firstOrNull { it.uri == uri } + } + + private fun throwIfCancelled() { + if (isCancelled.get()) throw TranscriptionCancelledException() + } + + private fun postProgress(uri: Uri, phase: TranscriptionPhase, fraction: Float, statusRes: Int) { + EventBus.getDefault().post(Events.TranscriptionProgress(uri, phase, fraction)) + val pct = (fraction * PCT_MAX).toInt().coerceIn(0, PCT_MAX) + startForegroundCompat(buildNotification(getString(statusRes), pct, indeterminate = false)) + } + + private fun startForegroundCompat(notification: Notification) { + if (isQPlus()) { + startForeground( + TRANSCRIPTION_NOTIF_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } else { + startForeground(TRANSCRIPTION_NOTIF_ID, notification) + } + } + + private fun buildNotification(statusText: String, progress: Int, indeterminate: Boolean): Notification { + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + getString(R.string.transcribe_settings), + NotificationManager.IMPORTANCE_LOW, + ).apply { setSound(null, null) } + ) + + val cancelIntent = Intent(this, TranscriptionService::class.java).apply { + action = ACTION_CANCEL_TRANSCRIPTION + } + val cancelPi = PendingIntent.getService( + this, + 0, + cancelIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.app_name)) + .setContentText(statusText) + .setSmallIcon(R.drawable.ic_graphic_eq_vector) + .setProgress(PCT_MAX, progress, indeterminate) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setSound(null) + .setOngoing(true) + .addAction(0, getString(R.string.cancel_transcription), cancelPi) + .build() + } + + private fun nowIso(): String { + val df = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + return df.format(Date()) + } + + private class TranscriptionCancelledException : RuntimeException() +} diff --git a/app/src/main/res/drawable/ic_microphone_vector.xml b/app/src/main/res/drawable/ic_microphone_vector.xml new file mode 100644 index 00000000..addea13b --- /dev/null +++ b/app/src/main/res/drawable/ic_microphone_vector.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_transcript_vector.xml b/app/src/main/res/drawable/ic_transcript_vector.xml new file mode 100644 index 00000000..ec0e24d5 --- /dev/null +++ b/app/src/main/res/drawable/ic_transcript_vector.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/tab_selector_background.xml b/app/src/main/res/drawable/tab_selector_background.xml new file mode 100644 index 00000000..1f31722f --- /dev/null +++ b/app/src/main/res/drawable/tab_selector_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/tab_selector_selected.xml b/app/src/main/res/drawable/tab_selector_selected.xml new file mode 100644 index 00000000..f6da8092 --- /dev/null +++ b/app/src/main/res/drawable/tab_selector_selected.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 3006feb9..5cc3df79 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -199,13 +199,30 @@ android:layout_height="wrap_content" android:text="@string/save_recordings_in" /> - + + + + + + + @@ -338,6 +355,63 @@ android:id="@+id/settings_audio_divider" layout="@layout/divider" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +