From 3dd79248e2aea2a30f676a13f4b932ef4365852a Mon Sep 17 00:00:00 2001 From: julian richards Date: Thu, 30 Apr 2026 00:39:01 +0800 Subject: [PATCH 01/12] feat(storage): add SAF + MediaStore backend via RecordingStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash-merge of upstream PR FossifyOrg/Voice-Recorder#317 (28 commits) onto current main. Adds a new :store Gradle module that abstracts recording I/O behind a unified RecordingStore interface, with two backends: - MediaStore (default; no folder picker on first run) - Storage Access Framework (lets the user save into any folder or document provider, including cloud / sync apps) Switches Recording from path-based to URI-based and removes the old DocumentFile/path extension helpers. Includes the upstream test suite under store/src/androidTest. Conflict resolved: kept main's commons 6.1.6 over the PR's 6.1.0. Verified ./gradlew :app:assembleDebug succeeds across Core/Foss/Gplay. Original PR: https://github.com/FossifyOrg/Voice-Recorder/pull/317 Co-Authored-By: Adam Cigánek Co-Authored-By: Claude Opus 4.7 (1M context) --- app/build.gradle.kts | 9 +- app/src/main/AndroidManifest.xml | 6 +- .../voicerecorder/activities/MainActivity.kt | 116 ++-- .../activities/SettingsActivity.kt | 201 ++++-- .../activities/SimpleActivity.kt | 87 +++ .../WidgetRecordDisplayConfigureActivity.kt | 55 +- .../adapters/RecordingsAdapter.kt | 68 +- .../voicerecorder/adapters/TrashAdapter.kt | 43 +- .../dialogs/MoveRecordingsDialog.kt | 44 +- .../dialogs/RenameRecordingDialog.kt | 47 +- .../voicerecorder/extensions/Activity.kt | 208 +----- .../voicerecorder/extensions/Context.kt | 203 +----- .../voicerecorder/extensions/DocumentFile.kt | 11 - .../voicerecorder/extensions/String.kt | 5 - .../fragments/MyViewPagerFragment.kt | 41 +- .../voicerecorder/fragments/PlayerFragment.kt | 43 +- .../fragments/RecorderFragment.kt | 48 +- .../voicerecorder/fragments/TrashFragment.kt | 16 +- .../fossify/voicerecorder/helpers/Config.kt | 52 +- .../voicerecorder/helpers/Constants.kt | 25 +- .../interfaces/RefreshRecordingsListener.kt | 3 +- .../fossify/voicerecorder/models/Events.kt | 4 +- .../fossify/voicerecorder/models/Recording.kt | 10 - .../recorder/MediaRecorderWrapper.kt | 23 +- .../voicerecorder/recorder/Mp3Recorder.kt | 59 +- .../voicerecorder/recorder/Recorder.kt | 1 - .../voicerecorder/services/RecorderService.kt | 167 ++--- app/src/main/res/layout/activity_settings.xml | 31 +- app/src/main/res/values/donottranslate.xml | 5 - app/src/main/res/values/strings.xml | 2 + build.gradle.kts | 3 +- gradle/libs.versions.toml | 9 +- settings.gradle.kts | 1 + store/.gitignore | 1 + store/README.md | 5 + store/build.gradle.kts | 30 + store/src/androidTest/AndroidManifest.xml | 21 + store/src/androidTest/assets/sample.ogg | Bin 0 -> 104793 bytes .../store/MockDocumentsProvider.kt | 137 ++++ .../voicerecorder/store/RecordingStoreTest.kt | 284 ++++++++ store/src/main/AndroidManifest.xml | 2 + .../voicerecorder/store/DocumentsUtils.kt | 44 ++ .../fossify/voicerecorder/store/Recording.kt | 37 ++ .../voicerecorder/store/RecordingStore.kt | 627 ++++++++++++++++++ store/src/main/res/values/donottranslate.xml | 7 + 45 files changed, 1845 insertions(+), 996 deletions(-) delete mode 100644 app/src/main/kotlin/org/fossify/voicerecorder/extensions/DocumentFile.kt delete mode 100644 app/src/main/kotlin/org/fossify/voicerecorder/extensions/String.kt delete mode 100644 app/src/main/kotlin/org/fossify/voicerecorder/models/Recording.kt create mode 100644 store/.gitignore create mode 100644 store/README.md create mode 100644 store/build.gradle.kts create mode 100644 store/src/androidTest/AndroidManifest.xml create mode 100644 store/src/androidTest/assets/sample.ogg create mode 100644 store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt create mode 100644 store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt create mode 100644 store/src/main/AndroidManifest.xml create mode 100644 store/src/main/kotlin/org/fossify/voicerecorder/store/DocumentsUtils.kt create mode 100644 store/src/main/kotlin/org/fossify/voicerecorder/store/Recording.kt create mode 100644 store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt create mode 100644 store/src/main/res/values/donottranslate.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 238ea798..f70f8d98 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,6 @@ dependencies { implementation(libs.tandroidlame) implementation(libs.autofittextview) detektPlugins(libs.compose.detekt) + + implementation(project(":store")) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fdb2017a..dbccd8d6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,9 +8,13 @@ + + + android:maxSdkVersion="28" /> - 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 +162,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 +179,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 +199,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 +220,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 +242,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 +251,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 +287,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..599f02f7 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,87 @@ 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.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.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" + } + 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() { @@ -98,7 +145,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 +156,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 +185,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 +230,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 +254,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 +273,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 +286,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 +306,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 +359,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 +380,7 @@ class SettingsActivity : SimpleActivity() { negative = org.fossify.commons.R.string.no ) { ensureBackgroundThread { - deleteTrashedRecordings() + recordingStore.deleteTrashed() runOnUiThread { recycleBinContentSize = 0 binding.settingsEmptyRecycleBinSize.text = 0.formatSize() @@ -354,18 +413,14 @@ 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) 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/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..1c105dfe 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt @@ -17,27 +17,26 @@ 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.config -import org.fossify.voicerecorder.extensions.deleteRecordings -import org.fossify.voicerecorder.extensions.trashRecordings +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 import kotlin.math.min class RecordingsAdapter( activity: SimpleActivity, - var recordings: ArrayList, + var recordings: MutableList, private val refreshListener: RefreshRecordingsListener, recyclerView: MyRecyclerView, itemClick: (Any) -> Unit -) : MyRecyclerViewAdapter(activity, recyclerView, itemClick), - RecyclerViewFastScroller.OnPopupTextUpdate { +) : MyRecyclerViewAdapter(activity, recyclerView, itemClick), RecyclerViewFastScroller.OnPopupTextUpdate { var currRecordingId = 0 @@ -93,9 +92,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) } @@ -126,9 +123,8 @@ class RecordingsAdapter( private fun openRecordingWith() { val recording = getItemWithKey(selectedKeys.first()) ?: return - val path = recording.path activity.openPathIntent( - path = path, + path = recording.uri.toString(), forceChooser = true, applicationId = BuildConfig.APPLICATION_ID, forceMimeType = "audio/*" @@ -137,7 +133,7 @@ class RecordingsAdapter( private fun shareRecordings() { val selectedItems = getSelectedItems() - val paths = selectedItems.map { it.path } + val paths = selectedItems.map { it.uri.toString() } activity.sharePathsIntent(paths, BuildConfig.APPLICATION_ID) } @@ -158,9 +154,7 @@ class RecordingsAdapter( val question = String.format(resources.getString(baseString), items) 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 @@ -178,14 +172,14 @@ class RecordingsAdapter( return } - val oldRecordingIndex = recordings.indexOfFirst { it.id == currRecordingId } - val recordingsToRemove = recordings - .filter { selectedKeys.contains(it.id) } as ArrayList + runWithWriteExternalStoragePermission { + val oldRecordingIndex = recordings.indexOfFirst { it.id == currRecordingId } + val recordingsToRemove = recordings.filter { selectedKeys.contains(it.id) }.toList() - val positions = getSelectedItemPositions() + val positions = getSelectedItemPositions() - activity.deleteRecordings(recordingsToRemove) { success -> - if (success) { + ensureBackgroundThread { + activity.recordingStore.delete(recordingsToRemove) doDeleteAnimation(oldRecordingIndex, recordingsToRemove, positions) } } @@ -196,14 +190,15 @@ class RecordingsAdapter( return } - val oldRecordingIndex = recordings.indexOfFirst { it.id == currRecordingId } - val recordingsToRemove = recordings - .filter { selectedKeys.contains(it.id) } as ArrayList + runWithWriteExternalStoragePermission { + val oldRecordingIndex = recordings.indexOfFirst { it.id == currRecordingId } + val recordingsToRemove = recordings.filter { selectedKeys.contains(it.id) }.toList() + + val positions = getSelectedItemPositions() - val positions = getSelectedItemPositions() + ensureBackgroundThread { + activity.recordingStore.trash(recordingsToRemove) - activity.trashRecordings(recordingsToRemove) { success -> - if (success) { doDeleteAnimation(oldRecordingIndex, recordingsToRemove, positions) EventBus.getDefault().post(Events.RecordingTrashUpdated()) } @@ -211,9 +206,7 @@ class RecordingsAdapter( } 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 ).forEach { it.setTextColor(textColor) } @@ -269,4 +259,14 @@ class RecordingsAdapter( } 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() + } + } + } } 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/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/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/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..40eebf0c 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 @@ -35,8 +35,8 @@ 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.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -45,8 +45,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,7 +58,7 @@ 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 lateinit var binding: FragmentPlayerBinding @@ -74,7 +73,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()) @@ -149,8 +148,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,11 +165,9 @@ 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) } @@ -220,10 +216,8 @@ class PlayerFragment( 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 { @@ -253,7 +247,7 @@ class PlayerFragment( reset() try { - setDataSource(context, recording.path.toUri()) + setDataSource(context, recording.uri) } catch (e: Exception) { context?.showErrorToast(e) return @@ -268,8 +262,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,9 +310,8 @@ class PlayerFragment( 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) } @@ -353,8 +345,7 @@ class PlayerFragment( } return resources.getColoredDrawableWithColor( - drawableId = drawable, - color = context.getProperPrimaryColor().getContrastColor() + drawableId = drawable, color = context.getProperPrimaryColor().getContrastColor() ) } @@ -378,7 +369,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 } @@ -433,7 +424,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..0d1d42f9 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt @@ -20,11 +20,11 @@ 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.CANCEL_RECORDING import org.fossify.voicerecorder.helpers.GET_RECORDER_INFO @@ -41,8 +41,7 @@ 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 @@ -78,24 +77,22 @@ class RecorderFragment( 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 +103,7 @@ class RecorderFragment( action = GET_RECORDER_INFO try { context.startService(this) - } catch (ignored: Exception) { + } catch (_: Exception) { } } } @@ -137,15 +134,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) @@ -200,8 +195,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 } } } 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/Config.kt b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Config.kt index 2ab91c17..3117ea5a 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() 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..a8ba9049 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Constants.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Constants.kt @@ -2,6 +2,8 @@ package org.fossify.voicerecorder.helpers +import org.fossify.voicerecorder.store.RecordingFormat + const val REPOSITORY_NAME = "Voice-Recorder" const val RECORDER_RUNNING_NOTIF_ID = 10000 @@ -12,10 +14,6 @@ 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 - val BITRATES_MP3 = arrayListOf( 8000, 16000, 24000, 32000, 64000, 96000, 128000, 160000, 192000, 256000, 320000 ) @@ -26,9 +24,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 +34,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 +77,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 @@ -104,5 +102,4 @@ 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 DEFAULT_RECORDINGS_FOLDER = "Recordings" const val DEFAULT_FILENAME_PATTERN = "%Y%M%D_%h%m%s" 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..763ad0e9 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,7 @@ 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) } + 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..1c674735 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/recorder/MediaRecorderWrapper.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/recorder/MediaRecorderWrapper.kt @@ -5,25 +5,32 @@ import android.content.Context import android.media.MediaRecorder import android.os.ParcelFileDescriptor import org.fossify.voicerecorder.extensions.config +import org.fossify.voicerecorder.store.RecordingFormat class MediaRecorderWrapper(val context: Context) : Recorder { @Suppress("DEPRECATION") private var recorder = MediaRecorder().apply { setAudioSource(context.config.microphoneMode) - setOutputFormat(context.config.getOutputFormat()) - setAudioEncoder(context.config.getAudioEncoder()) + + 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}") + } + setAudioEncodingBitRate(context.config.bitrate) setAudioSamplingRate(context.config.samplingRate) } - override fun setOutputFile(path: String) { - recorder.setOutputFile(path) - } - override fun setOutputFile(parcelFileDescriptor: ParcelFileDescriptor) { - val pFD = ParcelFileDescriptor.dup(parcelFileDescriptor.fileDescriptor) - recorder.setOutputFile(pFD.fileDescriptor) + recorder.setOutputFile(parcelFileDescriptor.fileDescriptor) } override fun prepare() { 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..92ea8cac 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Mp3Recorder.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Mp3Recorder.kt @@ -8,10 +8,7 @@ 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 @@ -23,10 +20,8 @@ class Mp3Recorder(val context: Context) : Recorder { 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, @@ -42,9 +37,7 @@ class Mp3Recorder(val context: Context) : Recorder { minBufferSize * 2 ) - override fun setOutputFile(path: String) { - outputPath = path - } + private var thread: Thread? = null override fun prepare() {} @@ -52,16 +45,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 +55,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 +103,6 @@ class Mp3Recorder(val context: Context) : Recorder { override fun release() { androidLame?.flush(mp3buffer) - outputStream?.close() audioRecord.release() } @@ -122,7 +111,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..89796540 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Recorder.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Recorder.kt @@ -3,7 +3,6 @@ package org.fossify.voicerecorder.recorder import android.os.ParcelFileDescriptor interface Recorder { - fun setOutputFile(path: String) fun setOutputFile(parcelFileDescriptor: ParcelFileDescriptor) fun prepare() fun start() 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..5779c4a0 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt @@ -7,32 +7,21 @@ import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Intent -import android.media.MediaScannerConnection 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.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.CANCEL_RECORDING -import org.fossify.voicerecorder.helpers.EXTENSION_MP3 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 +33,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 +44,16 @@ 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 override fun onBind(intent: Intent?): IBinder? = null @@ -98,42 +87,26 @@ class RecorderService : Service() { 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)}" + val writer = try { + recordingStore.createWriter(recordingName) + } catch (e: Exception) { + cancelRecording() + EventBus.getDefault().post(Events.RecordingFailed(e)) + return + }.also { + this.writer = it } - 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) - ) + recorder = when (recordingFormat) { + RecordingFormat.M4A, RecordingFormat.OGG -> MediaRecorderWrapper(this) + RecordingFormat.MP3 -> Mp3Recorder(this) } + recorder?.setOutputFile(writer.fileDescriptor) recorder?.prepare() recorder?.start() duration = 0 @@ -156,28 +129,37 @@ class RecorderService : Service() { amplitudeTimer.cancel() status = RECORDING_STOPPED - 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() { @@ -185,21 +167,18 @@ class RecorderService : Service() { amplitudeTimer.cancel() status = RECORDING_STOPPED - 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() @@ -235,21 +214,6 @@ class RecorderService : Service() { } } - 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 +232,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 +250,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 +267,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 +278,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/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 3006feb9..e6d47c73 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" /> - + + + + + + + diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index e7ca88b6..2431cdc9 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -1,11 +1,6 @@ org.fossify.voicerecorder Fossify Voice Recorder - m4a - mp3 - mp3 (Experimental) - ogg - ogg (Opus) %d kbps %d Hz diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7f2cd3d..b55f39f5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -42,6 +42,8 @@ Can I hide the notification icon during recording? Well, it depends. While you use your device it is no longer possible to fully hide the notifications of apps like this. If you check the proper setting item, the app will do its best to hide it. You can hide it on the lockscreen though, if you disable the displaying of sensitive notifications in your device settings. + Failed to access the recordings folder + Please make sure the recordings folder exists and this app has the necessary permissions to access it + Device Mic + Bluetooth Can I hide the notification icon during recording? Well, it depends. While you use your device it is no longer possible to fully hide the notifications of apps like this. From e977cd1671bcc3ad1095aa72fa45504c6bf2bb46 Mon Sep 17 00:00:00 2001 From: julian richards Date: Thu, 30 Apr 2026 08:48:26 +0800 Subject: [PATCH 03/12] feat(transcribe): add on-device speech-to-text via sherpa-onnx Whisper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new :transcribe Gradle module wrapping sherpa-onnx (JitPack v1.12.40) with a Whisper-tiny multilingual int8 model downloaded on first use. A new foreground TranscriptionService streams the recording through MediaCodec + a linear resampler to 16 kHz mono Float32, runs inference per 30 s chunk, and writes a JSON sidecar (.transcript.json) next to the audio via TranscriptStore (SAF + MediaStore parity). In the player UI a transcript icon next to the title opens TranscriptDialog, which renders idle/busy/ready states and supports tap-segment-to-seek. Progress is throttled to ≤4 events/sec so the foreground notification isn't re-posted thousands of times per second during model download. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 7 + .../voicerecorder/dialogs/TranscriptDialog.kt | 237 +++++++++++++++ .../voicerecorder/fragments/PlayerFragment.kt | 38 +++ .../fossify/voicerecorder/helpers/Config.kt | 8 + .../voicerecorder/helpers/Constants.kt | 10 + .../fossify/voicerecorder/models/Events.kt | 12 + .../services/TranscriptionService.kt | 276 ++++++++++++++++++ .../res/drawable/ic_transcript_vector.xml | 10 + app/src/main/res/layout/dialog_transcript.xml | 100 +++++++ app/src/main/res/layout/fragment_player.xml | 16 + .../res/layout/item_transcript_segment.xml | 27 ++ app/src/main/res/values/strings.xml | 21 ++ gradle/libs.versions.toml | 16 + settings.gradle.kts | 1 + .../fossify/voicerecorder/store/Transcript.kt | 29 ++ .../voicerecorder/store/TranscriptCodec.kt | 65 +++++ .../voicerecorder/store/TranscriptStore.kt | 156 ++++++++++ transcribe/.gitignore | 1 + transcribe/build.gradle.kts | 38 +++ transcribe/src/main/AndroidManifest.xml | 2 + .../transcribe/audio/AudioDecoder.kt | 253 ++++++++++++++++ .../transcribe/engine/SherpaTranscriber.kt | 122 ++++++++ .../transcribe/model/ModelCatalog.kt | 52 ++++ .../transcribe/model/ModelManager.kt | 201 +++++++++++++ .../transcribe/model/ModelSpec.kt | 27 ++ 26 files changed, 1726 insertions(+) create mode 100644 app/src/main/kotlin/org/fossify/voicerecorder/dialogs/TranscriptDialog.kt create mode 100644 app/src/main/kotlin/org/fossify/voicerecorder/services/TranscriptionService.kt create mode 100644 app/src/main/res/drawable/ic_transcript_vector.xml create mode 100644 app/src/main/res/layout/dialog_transcript.xml create mode 100644 app/src/main/res/layout/item_transcript_segment.xml create mode 100644 store/src/main/kotlin/org/fossify/voicerecorder/store/Transcript.kt create mode 100644 store/src/main/kotlin/org/fossify/voicerecorder/store/TranscriptCodec.kt create mode 100644 store/src/main/kotlin/org/fossify/voicerecorder/store/TranscriptStore.kt create mode 100644 transcribe/.gitignore create mode 100644 transcribe/build.gradle.kts create mode 100644 transcribe/src/main/AndroidManifest.xml create mode 100644 transcribe/src/main/kotlin/org/fossify/voicerecorder/transcribe/audio/AudioDecoder.kt create mode 100644 transcribe/src/main/kotlin/org/fossify/voicerecorder/transcribe/engine/SherpaTranscriber.kt create mode 100644 transcribe/src/main/kotlin/org/fossify/voicerecorder/transcribe/model/ModelCatalog.kt create mode 100644 transcribe/src/main/kotlin/org/fossify/voicerecorder/transcribe/model/ModelManager.kt create mode 100644 transcribe/src/main/kotlin/org/fossify/voicerecorder/transcribe/model/ModelSpec.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f70f8d98..f3800476 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -152,4 +152,5 @@ dependencies { detektPlugins(libs.compose.detekt) implementation(project(":store")) + implementation(project(":transcribe")) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index da99253c..89233765 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,9 +7,11 @@ + + + + Unit, +) { + private val binding: DialogTranscriptBinding = + DialogTranscriptBinding.inflate(LayoutInflater.from(activity)) + private var dialog: AlertDialog? = null + private var currentTranscript: Transcript? = null + + init { + EventBus.getDefault().register(this) + + 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.transcript, + ) { alertDialog -> + dialog = alertDialog + } + } + + binding.transcriptStartBtn.setOnClickListener { startTranscription() } + binding.transcriptCancelBtn.setOnClickListener { cancelTranscription() } + + // Load initial state on a background thread (sidecar I/O can hit ContentResolver). + ensureBackgroundThread { + val store = TranscriptStore(activity, activity.config.saveRecordingsFolder) + val existing = store.read(recording) + activity.runOnUiThread { renderInitialState(existing) } + } + } + + private fun renderInitialState(existing: Transcript?) { + currentTranscript = existing + when { + TranscriptionService.isRunning -> renderBusy(getString(R.string.transcribing), 0, indeterminate = true) + existing != null -> renderReady(existing) + else -> renderIdle() + } + } + + private fun renderIdle() { + binding.transcriptIdle.visibility = View.VISIBLE + binding.transcriptBusy.visibility = View.GONE + binding.transcriptReady.visibility = View.GONE + + val spec = ModelCatalog.byId(activity.config.transcribeModelId ?: ModelCatalog.DEFAULT.id) + ?: ModelCatalog.DEFAULT + val mgr = ModelManager(activity) + val subtitle = if (mgr.isModelInstalled(spec)) { + spec.displayName + } else { + "${spec.displayName} (~${spec.archiveSizeBytes / BYTES_PER_MB} MB will be downloaded)" + } + binding.transcriptIdleSubtitle.text = subtitle + } + + 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) + } + } + + private fun renderReady(transcript: Transcript) { + binding.transcriptIdle.visibility = View.GONE + binding.transcriptBusy.visibility = View.GONE + binding.transcriptReady.visibility = View.VISIBLE + + val langLabel = transcript.language.ifBlank { "?" } + binding.transcriptReadySubtitle.text = + "Language: $langLabel · ${transcript.segments.size} segments" + + val container = binding.transcriptSegmentsContainer + container.removeAllViews() + val inflater = LayoutInflater.from(activity) + for (segment in transcript.segments) { + val itemBinding = ItemTranscriptSegmentBinding.inflate(inflater, container, false) + itemBinding.segmentTimestamp.text = formatTimestamp(segment.startMs) + itemBinding.segmentText.text = segment.text + itemBinding.root.setOnClickListener { + onSeek(segment.startMs) + dialog?.dismiss() + } + itemBinding.root.setOnLongClickListener { + copySegment(segment) + true + } + container.addView(itemBinding.root) + } + } + + private fun startTranscription() { + if (TranscriptionService.isRunning) { + activity.toast(R.string.transcribing) + return + } + val intent = Intent(activity, TranscriptionService::class.java).apply { + action = ACTION_START_TRANSCRIPTION + putExtra(EXTRA_RECORDING_URI, recording.uri.toString()) + putExtra(EXTRA_MODEL_ID, activity.config.transcribeModelId ?: ModelCatalog.DEFAULT.id) + putExtra(EXTRA_LANGUAGE, activity.config.transcribeLanguage) + } + activity.startForegroundService(intent) + renderBusy(getString(R.string.transcribing), 0, indeterminate = true) + } + + private fun cancelTranscription() { + val intent = Intent(activity, TranscriptionService::class.java).apply { + action = ACTION_CANCEL_TRANSCRIPTION + } + activity.startService(intent) + } + + private fun copySegment(segment: TranscriptSegment) { + activity.copyToClipboard(segment.text) + } + + private fun isOurs(uri: android.net.Uri): Boolean = uri == recording.uri + + private fun getString(resId: Int) = activity.getString(resId) + + 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) + } + + @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 + 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) + } + + private companion object { + const val PCT_MAX = 100 + const val MS_PER_SECOND = 1000L + const val SEC_PER_MIN = 60L + const val BYTES_PER_MB = 1_000_000L + } + + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + fun onTranscriptionCompleted(e: Events.TranscriptionCompleted) { + if (!isOurs(e.recordingUri)) return + ensureBackgroundThread { + val store = TranscriptStore(activity, activity.config.saveRecordingsFolder) + val transcript = store.read(recording) + activity.runOnUiThread { + if (transcript != null) renderReady(transcript) else renderIdle() + } + } + } + + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + fun onTranscriptionFailed(e: Events.TranscriptionFailed) { + if (!isOurs(e.recordingUri)) return + activity.toast(activity.getString(R.string.transcript_failed, e.cause.message ?: "?")) + renderInitialState(currentTranscript) + } + + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + fun onTranscriptionCancelled(e: Events.TranscriptionCancelled) { + if (!isOurs(e.recordingUri)) return + activity.toast(R.string.transcript_cancelled) + renderInitialState(currentTranscript) + } +} 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 40eebf0c..2d17a9b9 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/PlayerFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/PlayerFragment.kt @@ -32,6 +32,7 @@ import org.fossify.voicerecorder.R import org.fossify.voicerecorder.activities.SimpleActivity import org.fossify.voicerecorder.adapters.RecordingsAdapter import org.fossify.voicerecorder.databinding.FragmentPlayerBinding +import org.fossify.voicerecorder.dialogs.TranscriptDialog import org.fossify.voicerecorder.extensions.config import org.fossify.voicerecorder.interfaces.RefreshRecordingsListener import org.fossify.voicerecorder.models.Events @@ -61,6 +62,8 @@ class PlayerFragment( private var prevSaveFolder: Uri? = null private var prevRecycleBinState = context.config.useRecycleBin private var playOnPreparation = true + private var currentRecording: Recording? = null + private var pendingSeekMs: Int = -1 private lateinit var binding: FragmentPlayerBinding private var becomingNoisyReceiver: BecomingNoisyReceiver? = null @@ -171,6 +174,31 @@ class PlayerFragment( playRecording(newRecording, true) playedRecordingIDs.push(newRecording.id) } + + binding.transcriptBtn.setOnClickListener { + val recording = currentRecording ?: return@setOnClickListener + TranscriptDialog(context as SimpleActivity, recording) { positionMs -> + seekToAndPlay(positionMs) + } + } + } + + /** + * 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() @@ -229,6 +257,14 @@ class PlayerFragment( } setOnPreparedListener { + if (pendingSeekMs >= 0) { + try { + seekTo(pendingSeekMs) + binding.playerProgressbar.progress = pendingSeekMs / 1000 + } catch (_: IllegalStateException) { + } + pendingSeekMs = -1 + } if (playOnPreparation) { resumePlayback() } @@ -240,6 +276,8 @@ class PlayerFragment( override fun playRecording(recording: Recording, playOnPrepared: Boolean) { resetProgress(recording) + currentRecording = recording + binding.transcriptBtn.visibility = android.view.View.VISIBLE (binding.recordingsList.adapter as RecordingsAdapter).updateCurrentRecording(recording.id) playOnPreparation = playOnPrepared 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 3117ea5a..7c53e70e 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Config.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Config.kt @@ -78,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 ab505f2e..4e169b05 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Constants.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Constants.kt @@ -7,6 +7,7 @@ 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" @@ -14,8 +15,14 @@ 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 ACTION_START_TRANSCRIPTION = PATH + "START_TRANSCRIPTION" +const val ACTION_CANCEL_TRANSCRIPTION = PATH + "CANCEL_TRANSCRIPTION" + 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 @@ -104,5 +111,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_FILENAME_PATTERN = "%Y%M%D_%h%m%s" +const val DEFAULT_TRANSCRIBE_LANGUAGE = "" 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 763ad0e9..70707191 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/models/Events.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/models/Events.kt @@ -10,5 +10,17 @@ class Events { class RecordingTrashUpdated internal constructor() 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) } +enum class TranscriptionPhase { DOWNLOADING_MODEL, DECODING, TRANSCRIBING, WRITING } + 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..305df397 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/voicerecorder/services/TranscriptionService.kt @@ -0,0 +1,276 @@ +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_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.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 java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.concurrent.atomic.AtomicBoolean + +/** + * 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 + + 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 val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val isCancelled = AtomicBoolean(false) + private var currentJob: Job? = null + private var currentRecordingUri: Uri? = 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 -> handleCancel() + 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 + 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 + currentRecordingUri = null + stopSelf() + } + } + } + + private fun handleCancel() { + isCancelled.set(true) + currentJob?.cancel() + } + + private fun runPipeline(recordingUri: Uri, modelId: String, language: String) { + 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 totalDurationMs = decoder.decodeChunks( + isCancelled = isCancelled, + onProgress = { f -> + postProgress(recordingUri, TranscriptionPhase.TRANSCRIBING, f, R.string.transcribing) + }, + ) { chunk -> + if (isCancelled.get()) return@decodeChunks false + val result = transcriber.transcribeChunk(chunk) + if (detectedLanguage.isBlank() && result.language.isNotBlank()) { + detectedLanguage = result.language + } + segments += result.segments + true + } + + 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, + segments = segments, + ) + transcriptStore.write(recording, transcript) + } finally { + transcriber.release() + } + } + + 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_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/layout/dialog_transcript.xml b/app/src/main/res/layout/dialog_transcript.xml new file mode 100644 index 00000000..3e32d3b0 --- /dev/null +++ b/app/src/main/res/layout/dialog_transcript.xml @@ -0,0 +1,100 @@ + + + + + + + + + +