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