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