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" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/dialog_transcription_models.xml b/app/src/main/res/layout/dialog_transcription_models.xml
new file mode 100644
index 00000000..303f8e9c
--- /dev/null
+++ b/app/src/main/res/layout/dialog_transcription_models.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_player.xml b/app/src/main/res/layout/fragment_player.xml
index 195911fc..1d93dcb7 100644
--- a/app/src/main/res/layout/fragment_player.xml
+++ b/app/src/main/res/layout/fragment_player.xml
@@ -17,7 +17,8 @@
android:text="@string/no_recordings_found"
android:textSize="@dimen/bigger_text_size"
android:textStyle="italic"
- android:visibility="gone" />
+ android:visibility="gone"
+ app:layout_constraintTop_toTopOf="parent" />
+
+
+
+
+
+
+
+
+ app:layout_constraintTop_toBottomOf="@+id/microphone_selector_holder" />
-
+ android:orientation="vertical">
-
+ android:paddingBottom="@dimen/normal_margin">
-
+
-
+
+
+
-
+
+
+
+
+
+
+ android:layout_marginStart="@dimen/activity_margin"
+ android:layout_marginEnd="@dimen/activity_margin"
+ android:layout_marginBottom="@dimen/normal_margin"
+ android:background="?attr/selectableItemBackground"
+ android:gravity="center_vertical"
+ android:orientation="horizontal"
+ android:paddingStart="@dimen/small_margin"
+ android:paddingTop="@dimen/small_margin"
+ android:paddingEnd="@dimen/small_margin"
+ android:paddingBottom="@dimen/small_margin"
+ android:visibility="gone"
+ tools:visibility="visible">
+
+
-
+
+
+
diff --git a/app/src/main/res/layout/item_transcript_segment.xml b/app/src/main/res/layout/item_transcript_segment.xml
new file mode 100644
index 00000000..f5dfa503
--- /dev/null
+++ b/app/src/main/res/layout/item_transcript_segment.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_transcription_model.xml b/app/src/main/res/layout/item_transcription_model.xml
new file mode 100644
index 00000000..eb7c60c9
--- /dev/null
+++ b/app/src/main/res/layout/item_transcription_model.xml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/cab_recordings.xml b/app/src/main/res/menu/cab_recordings.xml
index 412bc376..f26cd79a 100644
--- a/app/src/main/res/menu/cab_recordings.xml
+++ b/app/src/main/res/menu/cab_recordings.xml
@@ -1,13 +1,8 @@