From 186799bd15b8ef0d5d31af4733892fd44b61f594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Tue, 20 Jan 2026 13:12:48 +0100 Subject: [PATCH 01/28] feat: use Storage Access Framework to save recordings --- .../voicerecorder/activities/MainActivity.kt | 42 +--- .../activities/SettingsActivity.kt | 142 ++++++++------ .../activities/SimpleActivity.kt | 2 + .../adapters/RecordingsAdapter.kt | 2 +- .../dialogs/MoveRecordingsDialog.kt | 10 +- .../voicerecorder/extensions/Activity.kt | 185 +++++------------- .../voicerecorder/extensions/Context.kt | 161 +++++---------- .../voicerecorder/fragments/PlayerFragment.kt | 20 +- .../fragments/RecorderFragment.kt | 48 ++--- .../voicerecorder/fragments/TrashFragment.kt | 13 +- .../fossify/voicerecorder/helpers/Config.kt | 50 ++--- .../voicerecorder/helpers/Constants.kt | 24 ++- .../voicerecorder/helpers/DocumentsUtils.kt | 46 +++++ .../voicerecorder/helpers/RecordingWriter.kt | 123 ++++++++++++ .../fossify/voicerecorder/models/Recording.kt | 45 +++++ .../recorder/MediaRecorderWrapper.kt | 23 ++- .../voicerecorder/recorder/Mp3Recorder.kt | 50 ++--- .../voicerecorder/recorder/Recorder.kt | 1 - .../voicerecorder/services/RecorderService.kt | 175 +++++++---------- app/src/main/res/layout/activity_settings.xml | 31 ++- 20 files changed, 588 insertions(+), 605 deletions(-) create mode 100644 app/src/main/kotlin/org/fossify/voicerecorder/helpers/DocumentsUtils.kt create mode 100644 app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingWriter.kt diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/activities/MainActivity.kt b/app/src/main/kotlin/org/fossify/voicerecorder/activities/MainActivity.kt index f790855e..a054b940 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/MainActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/MainActivity.kt @@ -8,22 +8,8 @@ import android.widget.ImageView import android.widget.TextView import androidx.appcompat.content.res.AppCompatResources import me.grantland.widget.AutofitHelper -import org.fossify.commons.extensions.appLaunched -import org.fossify.commons.extensions.checkAppSideloading -import org.fossify.commons.extensions.getBottomNavigationBackgroundColor -import org.fossify.commons.extensions.hideKeyboard -import org.fossify.commons.extensions.launchMoreAppsFromUsIntent -import org.fossify.commons.extensions.onPageChangeListener -import org.fossify.commons.extensions.onTabSelectionChanged -import org.fossify.commons.extensions.toast -import org.fossify.commons.extensions.updateBottomTabItemColors -import org.fossify.commons.helpers.LICENSE_ANDROID_LAME -import org.fossify.commons.helpers.LICENSE_AUDIO_RECORD_VIEW -import org.fossify.commons.helpers.LICENSE_AUTOFITTEXTVIEW -import org.fossify.commons.helpers.LICENSE_EVENT_BUS -import org.fossify.commons.helpers.PERMISSION_RECORD_AUDIO -import org.fossify.commons.helpers.PERMISSION_WRITE_STORAGE -import org.fossify.commons.helpers.isRPlus +import org.fossify.commons.extensions.* +import org.fossify.commons.helpers.* import org.fossify.commons.models.FAQItem import org.fossify.voicerecorder.BuildConfig import org.fossify.voicerecorder.R @@ -31,7 +17,6 @@ import org.fossify.voicerecorder.adapters.ViewPagerAdapter import org.fossify.voicerecorder.databinding.ActivityMainBinding import org.fossify.voicerecorder.extensions.config import org.fossify.voicerecorder.extensions.deleteExpiredTrashedRecordings -import org.fossify.voicerecorder.extensions.ensureStoragePermission import org.fossify.voicerecorder.helpers.STOP_AMPLITUDE_UPDATE import org.fossify.voicerecorder.models.Events import org.fossify.voicerecorder.services.RecorderService @@ -40,6 +25,9 @@ import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode class MainActivity : SimpleActivity() { + companion object { + const val TAG = "MainActivity" + } private var bus: EventBus? = null @@ -166,25 +154,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() { 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..b70d7c97 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt @@ -2,25 +2,16 @@ package org.fossify.voicerecorder.activities import android.content.Intent import android.media.MediaRecorder +import android.net.Uri import android.os.Bundle +import android.provider.DocumentsContract +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 -import org.fossify.commons.extensions.addLockedLabelIfNeeded -import org.fossify.commons.extensions.beGone -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 -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.extensions.* +import org.fossify.commons.helpers.* import org.fossify.commons.models.RadioItem import org.fossify.voicerecorder.R import org.fossify.voicerecorder.databinding.ActivitySettingsBinding @@ -30,16 +21,9 @@ 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.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.helpers.* import org.fossify.voicerecorder.models.Events +import org.fossify.voicerecorder.models.RecordingFormat import org.greenrobot.eventbus.EventBus import java.util.Locale import kotlin.math.abs @@ -49,6 +33,39 @@ class SettingsActivity : SimpleActivity() { 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 = hasRecordings() + + runOnUiThread { + if (oldUri != null && 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) @@ -108,7 +125,7 @@ class SettingsActivity : SimpleActivity() { private fun setupUseEnglish() { binding.settingsUseEnglishHolder.beVisibleIf( (config.wasUseEnglishToggled || Locale.getDefault().language != "en") - && !isTiramisuPlus() + && !isTiramisuPlus() ) binding.settingsUseEnglish.isChecked = config.useEnglish binding.settingsUseEnglishHolder.setOnClickListener { @@ -139,34 +156,35 @@ class SettingsActivity : SimpleActivity() { private fun setupSaveRecordingsFolder() { binding.settingsSaveRecordingsLabel.text = addLockedLabelIfNeeded(R.string.save_recordings_in) - binding.settingsSaveRecordings.text = humanizePath(config.saveRecordingsFolder) 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?) { + if (uri != null) { + val documentId = DocumentsContract.getTreeDocumentId(uri) + binding.settingsSaveRecordings.text = documentId.substringAfter(":").trimEnd('/') + + uri.authority?.let { authority -> + packageManager.resolveContentProvider(authority, 0)?.let { providerInfo -> + val providerIcon = providerInfo.loadIcon(packageManager) + val providerLabel = providerInfo.loadLabel(packageManager) + + binding.settingsSaveRecordingsProviderIcon.apply { + setVisibility(View.VISIBLE) + setImageDrawable(providerIcon) + setContentDescription(providerLabel) } } } + } else { + binding.settingsSaveRecordings.text = "" + binding.settingsSaveRecordingsProviderIcon.setVisibility(View.GONE) } + } private fun setupFilenamePattern() { @@ -179,20 +197,18 @@ 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 + .available + .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,7 +218,7 @@ class SettingsActivity : SimpleActivity() { private fun setupBitrate() { binding.settingsBitrate.text = getBitrateText(config.bitrate) binding.settingsBitrateHolder.setOnClickListener { - val items = BITRATES[config.extension]!! + val items = BITRATES[config.recordingFormat]!! .map { RadioItem(it, getBitrateText(it)) } as ArrayList RadioGroupDialog(this@SettingsActivity, items, config.bitrate) { @@ -218,7 +234,7 @@ 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) } @@ -247,8 +263,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 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..effe1ede 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/SimpleActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/SimpleActivity.kt @@ -5,6 +5,8 @@ import org.fossify.voicerecorder.R import org.fossify.voicerecorder.helpers.REPOSITORY_NAME open class SimpleActivity : BaseSimpleActivity() { + + override fun getAppIconIDs() = arrayListOf( R.mipmap.ic_launcher_red, R.mipmap.ic_launcher_pink, 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..a4f3a69f 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt @@ -32,7 +32,7 @@ import kotlin.math.min class RecordingsAdapter( activity: SimpleActivity, - var recordings: ArrayList, + var recordings: MutableList, private val refreshListener: RefreshRecordingsListener, recyclerView: MyRecyclerView, itemClick: (Any) -> Unit 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..d526211c 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/MoveRecordingsDialog.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/MoveRecordingsDialog.kt @@ -1,5 +1,6 @@ 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 @@ -11,11 +12,12 @@ import org.fossify.voicerecorder.R import org.fossify.voicerecorder.databinding.DialogMoveRecordingsBinding import org.fossify.voicerecorder.extensions.getAllRecordings import org.fossify.voicerecorder.extensions.moveRecordings +import org.fossify.voicerecorder.helpers.buildParentDocumentUri class MoveRecordingsDialog( private val activity: BaseSimpleActivity, - private val previousFolder: String, - private val newFolder: String, + private val oldFolder: Uri, + private val newFolder: Uri, private val callback: () -> Unit ) { private lateinit var dialog: AlertDialog @@ -66,8 +68,8 @@ class MoveRecordingsDialog( ensureBackgroundThread { activity.moveRecordings( recordingsToMove = activity.getAllRecordings(), - sourceParent = previousFolder, - destinationParent = newFolder + sourceParent = buildParentDocumentUri(oldFolder), + targetParent = buildParentDocumentUri(newFolder) ) { activity.runOnUiThread { callback() 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..9bbbccf8 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,17 @@ package org.fossify.voicerecorder.extensions import android.app.Activity +import android.net.Uri import android.provider.DocumentsContract import android.view.WindowManager import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile 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.helpers.DAY_SECONDS import org.fossify.commons.helpers.MONTH_SECONDS 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.helpers.buildParentDocumentUri import org.fossify.voicerecorder.models.Recording -import java.io.File fun Activity.setKeepScreenAwake(keepScreenOn: Boolean) { if (keepScreenOn) { @@ -29,67 +21,14 @@ 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) - } + val resolver = contentResolver + recordingsToRemove.forEach { + DocumentsContract.deleteDocument(resolver, it.path.toUri()) } callback(true) @@ -101,8 +40,8 @@ fun BaseSimpleActivity.trashRecordings( callback: (success: Boolean) -> Unit ) = moveRecordings( recordingsToMove = recordingsToMove, - sourceParent = config.saveRecordingsFolder, - destinationParent = getOrCreateTrashFolder(), + sourceParent = config.saveRecordingsFolder?.let(::buildParentDocumentUri)!!, + targetParent = getOrCreateTrashFolder()!!, callback = callback ) @@ -111,93 +50,67 @@ fun BaseSimpleActivity.restoreRecordings( callback: (success: Boolean) -> Unit ) = moveRecordings( recordingsToMove = recordingsToRestore, - sourceParent = getOrCreateTrashFolder(), - destinationParent = config.saveRecordingsFolder, + sourceParent = getOrCreateTrashFolder()!!, + targetParent = config.saveRecordingsFolder?.let(::buildParentDocumentUri)!!, 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, + sourceParent: Uri, + targetParent: Uri, 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) + if (sourceParent.authority == targetParent.authority) { + for (recording in recordingsToMove) { + try { + DocumentsContract.moveDocument( + contentResolver, + recording.path.toUri(), + sourceParent, + targetParent + ) + } catch (e: IllegalStateException) { + moveDocumentFallback(recording.path.toUri(), sourceParent, targetParent) } } + } else { + for (recording in recordingsToMove) { + moveDocumentFallback(recording.path.toUri(), sourceParent, targetParent) + } } callback(true) } } -private fun BaseSimpleActivity.moveRecordingsLegacy( - recordings: Collection, - sourceParent: String, - destinationParent: String, - callback: (success: Boolean) -> Unit +// Copy source to target, then delete source. Use as fallback when `DocumentsContract.moveDocument` can't used (e.g., when moving between different authorities) +private fun BaseSimpleActivity.moveDocumentFallback( + sourceUri: Uri, + sourceParent: Uri, + targetParent: Uri, ) { - copyMoveFilesTo( - fileDirItems = recordings - .map { File(it.path).toFileDirItem(this) } - .toMutableList() as ArrayList, - source = sourceParent, - destination = destinationParent, - isCopyOperation = false, - copyPhotoVideoOnly = false, - copyHidden = false - ) { - callback(true) + val sourceFile = DocumentFile.fromSingleUri(this, sourceUri)!! + val sourceName = requireNotNull(sourceFile.name) + val sourceType = requireNotNull(sourceFile.type) + + val targetUri = requireNotNull(DocumentsContract.createDocument( + contentResolver, + targetParent, + sourceType, + sourceName + )) + + contentResolver.openInputStream(sourceUri)?.use { inputStream -> + contentResolver.openOutputStream(targetUri)?.use { outputStream -> + inputStream.copyTo(outputStream) + } } + + DocumentsContract.deleteDocument(contentResolver, sourceUri) } fun BaseSimpleActivity.deleteTrashedRecordings() { 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..c6edb133 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Context.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Context.kt @@ -13,35 +13,27 @@ 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.extensions.* 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.helpers.* import org.fossify.voicerecorder.models.Recording -import java.io.File 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" +private const val TRASH_FOLDER_NAME = ".trash" + +/** + * Returns the URI of the trash folder as a sub-folder of the save recordings folder. The trash folder itself might not yet exists. Returns null if the save + * recordings folder is not defined. + */ +val Context.trashFolder: Uri? + get() = config.saveRecordingsFolder?.let { + findChildDocument(contentResolver, it, TRASH_FOLDER_NAME) + } fun Context.drawableToBitmap(drawable: Drawable): Bitmap { val size = (60 * resources.displayMetrics.density).toInt() @@ -70,12 +62,14 @@ fun Context.updateWidgets(isRecording: Boolean) { } } -fun Context.getOrCreateTrashFolder(): String { - val folder = File(trashFolder) - if (!folder.exists()) { - folder.mkdir() - } - return trashFolder +/** + * Returns the URI of the trash folder. Creates the folder if it doesn't yet exist. Returns null if the save recording folder is not defined or if the trash + * folder creation failed. + * + * @see [trashFolder] + */ +fun Context.getOrCreateTrashFolder(): Uri? = config.saveRecordingsFolder?.let { + getOrCreateDocument(contentResolver, it,DocumentsContract.Document.MIME_TYPE_DIR, TRASH_FOLDER_NAME) } fun Context.getDefaultRecordingsFolder(): String { @@ -91,102 +85,53 @@ fun Context.getDefaultRecordingsRelativePath(): String { } } -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.hasRecordings(): Boolean = config.saveRecordingsFolder?.let { uri -> + DocumentFile.fromTreeUri(this, uri)?.listFiles()?.any { it.isAudioRecording() } +} == true 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()) - } + val recordings = arrayListOf() - recordings - } else { - getLegacyRecordings(trashed) - } -} + recordings.addAll(getRecordings(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) - ) - } + if (trashed) { + // Return recordings trashed using MediaStore, this won't be needed in the future + @Suppress("DEPRECATION") + recordings.addAll(getMediaStoreTrashedRecordings()) } return recordings } +private fun Context.getRecordings(trashed: Boolean = false): List { + val uri = if (trashed) trashFolder else config.saveRecordingsFolder + val folder = uri?.let { DocumentFile.fromTreeUri(this, it) } + + return folder + ?.listFiles() + ?.filter { it.isAudioRecording() } + ?.map { readRecordingFromFile(it) } + ?.toList() + ?: emptyList() +} + @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!!, "") - ) - ) +private fun Context.getMediaStoreTrashedRecordings(): List { + val trashedRegex = "^\\.trashed-\\d+-".toRegex() + + return config + .saveRecordingsFolder + ?.let { DocumentFile.fromTreeUri(this, it) } + ?.listFiles() + ?.filter { it.isTrashedMediaStoreRecording() } + ?.map { + readRecordingFromFile(it).copy(title = trashedRegex.replace(it.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 + ?.toList() + ?: emptyList() } private fun Context.readRecordingFromFile(file: DocumentFile): Recording { 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..586d6d09 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/PlayerFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/PlayerFragment.kt @@ -8,24 +8,14 @@ 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 -import org.fossify.commons.extensions.copyToClipboard -import org.fossify.commons.extensions.getColoredDrawableWithColor -import org.fossify.commons.extensions.getContrastColor -import org.fossify.commons.extensions.getFormattedDuration -import org.fossify.commons.extensions.getProperPrimaryColor -import org.fossify.commons.extensions.getProperTextColor -import org.fossify.commons.extensions.showErrorToast -import org.fossify.commons.extensions.updateTextColors -import org.fossify.commons.extensions.value +import org.fossify.commons.extensions.* import org.fossify.commons.helpers.isQPlus import org.fossify.commons.helpers.isTiramisuPlus import org.fossify.voicerecorder.R @@ -59,7 +49,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 +64,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()) @@ -378,7 +368,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 } 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..bdefaae3 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt @@ -11,27 +11,12 @@ import org.fossify.commons.activities.BaseSimpleActivity import org.fossify.commons.compose.extensions.getActivity import org.fossify.commons.dialogs.ConfirmationDialog import org.fossify.commons.dialogs.PermissionRequiredDialog -import org.fossify.commons.extensions.applyColorFilter -import org.fossify.commons.extensions.beVisibleIf -import org.fossify.commons.extensions.getColoredDrawableWithColor -import org.fossify.commons.extensions.getContrastColor -import org.fossify.commons.extensions.getFormattedDuration -import org.fossify.commons.extensions.getProperPrimaryColor -import org.fossify.commons.extensions.getProperTextColor -import org.fossify.commons.extensions.openNotificationSettings -import org.fossify.commons.extensions.setDebouncedClickListener -import org.fossify.commons.extensions.toast +import org.fossify.commons.extensions.* import org.fossify.voicerecorder.R 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 -import org.fossify.voicerecorder.helpers.RECORDING_PAUSED -import org.fossify.voicerecorder.helpers.RECORDING_RUNNING -import org.fossify.voicerecorder.helpers.RECORDING_STOPPED -import org.fossify.voicerecorder.helpers.TOGGLE_PAUSE +import org.fossify.voicerecorder.helpers.* import org.fossify.voicerecorder.models.Events import org.fossify.voicerecorder.services.RecorderService import org.greenrobot.eventbus.EventBus @@ -78,24 +63,19 @@ 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? BaseSimpleActivity)?.let { activity -> + 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() + } + ) } - } else { - activity.toast(org.fossify.commons.R.string.no_storage_permissions) } } } 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..0b92ff40 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/TrashFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/TrashFragment.kt @@ -1,12 +1,9 @@ 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 -import org.fossify.commons.extensions.getProperPrimaryColor -import org.fossify.commons.extensions.getProperTextColor -import org.fossify.commons.extensions.updateTextColors +import org.fossify.commons.extensions.* import org.fossify.voicerecorder.activities.SimpleActivity import org.fossify.voicerecorder.adapters.TrashAdapter import org.fossify.voicerecorder.databinding.FragmentTrashBinding @@ -26,7 +23,7 @@ class TrashFragment( 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 +33,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()) @@ -114,7 +111,7 @@ class TrashFragment( 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..96473737 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,30 @@ 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 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.models.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:") -> Uri.parse(value) + is String -> context.createFirstParentTreeUri(value) + null -> null /*MediaStore.Audio.Media.EXTERNAL_CONTENT_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).apply() var microphoneMode: Int get() = prefs.getInt(MICROPHONE_MODE, MediaRecorder.AudioSource.DEFAULT) @@ -50,34 +54,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..0b413dbf 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.models.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 diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/DocumentsUtils.kt b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/DocumentsUtils.kt new file mode 100644 index 00000000..9520d1ba --- /dev/null +++ b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/DocumentsUtils.kt @@ -0,0 +1,46 @@ +package org.fossify.voicerecorder.helpers + +import android.content.ContentResolver +import android.net.Uri +import android.provider.DocumentsContract + +/** + * Given a tree URI of some directory (such as obtained with `ACTION_OPEN_DOCUMENT_TREE` intent), returns a corresponding parent URI to create child documents + * in that directory. + */ +fun buildParentDocumentUri(treeUri: Uri): Uri { + val parentDocumentId = DocumentsContract.getTreeDocumentId(treeUri) + return DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocumentId) +} + +fun findChildDocument(contentResolver: ContentResolver, treeUri: Uri, displayName: String): Uri? { + val parentDocumentId = DocumentsContract.getTreeDocumentId(treeUri) + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocumentId) + + contentResolver.query( + childrenUri, + arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME), + null, + null, + null, + )?.use { cursor -> + val idIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + val nameIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + + while (cursor.moveToNext()) { + if (cursor.getString(nameIndex) == displayName) { + return DocumentsContract.buildDocumentUriUsingTree(treeUri, cursor.getString(idIndex)) + } + } + } + + return null +} + +fun getOrCreateDocument(contentResolver: ContentResolver, treeUri: Uri, mimeType: String, displayName: String): Uri? { + val uri = findChildDocument(contentResolver, treeUri, displayName) + if (uri != null) return uri + + val parentDocumentUri = buildParentDocumentUri(treeUri) + return DocumentsContract.createDocument(contentResolver, parentDocumentUri, mimeType, displayName) +} diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingWriter.kt b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingWriter.kt new file mode 100644 index 00000000..8dabccfb --- /dev/null +++ b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingWriter.kt @@ -0,0 +1,123 @@ +package org.fossify.voicerecorder.helpers + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import org.fossify.voicerecorder.models.RecordingFormat +import java.io.File +import java.io.FileInputStream + +/** + * Helper class to write recordings to the device. + * + * Note: Why not use [DocumentsContract.createDocument] directly? Because there is currently a bug in [android.provider.MediaStore] (TODO: link to the bugreport) + * which causes crash when writing to some [android.provider.DocumentsProvider]s. Using this class works around the bug. + */ +sealed class RecordingWriter() { + companion object { + fun create(context: Context, parentTreeUri: Uri, name: String, format: RecordingFormat): RecordingWriter { + val direct = DIRECT_FORMATS.contains(format) or DIRECT_AUTHORITIES.contains(parentTreeUri.authority) + + if (direct) { + val uri = createDocument(context, parentTreeUri, name, format) + val fileDescriptor = requireNotNull(context.contentResolver.openFileDescriptor(uri, "w")) { + "failed to open file descriptor at $uri" + } + + return Direct(context.contentResolver, uri, fileDescriptor) + } else { + return Workaround(context, parentTreeUri, name, format) + } + } + + + // Formats not affected by the MediaStore bug + private val DIRECT_FORMATS = arrayOf(RecordingFormat.MP3) + + // Document providers not affected by the MediaStore bug + private val DIRECT_AUTHORITIES = arrayOf("com.android.externalstorage.documents") + + private const val TAG = "RecordingWriter" + } + + /** + * File descriptor to write the recording data to. + */ + abstract val fileDescriptor: ParcelFileDescriptor + + abstract fun commit(): Uri + + abstract fun cancel() + + // Writes directly to the document at the given URI. + class Direct internal constructor(private val contentResolver: ContentResolver, private val uri: Uri, override val fileDescriptor: ParcelFileDescriptor) : + RecordingWriter() { + override fun commit(): Uri { + fileDescriptor.close() + return uri + } + + override fun cancel() { + fileDescriptor.close() + DocumentsContract.deleteDocument(contentResolver, uri) + } + } + + // Writes to a temporary file first, then copies it into the destination document. + class Workaround internal constructor( + private val context: Context, + private val parentTreeUri: Uri, + private val name: String, + private val format: RecordingFormat + ) : RecordingWriter() { + private val tempFile: File = File(context.cacheDir, "$name.${format.getExtension(context)}.tmp") + + override val fileDescriptor: ParcelFileDescriptor + get() = ParcelFileDescriptor.open( + tempFile, + ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_CREATE or ParcelFileDescriptor.MODE_TRUNCATE + ) + + override fun commit(): Uri { + val dstUri = createDocument(context, parentTreeUri, name, format) + val dst = requireNotNull(context.contentResolver.openOutputStream(dstUri)) { + "failed to open output stream at $dstUri" + } + + val src = FileInputStream(tempFile) + + src.use { src -> + dst.use { dst -> + src.copyTo(dst) + } + } + + tempFile.delete() + + return dstUri + } + + override fun cancel() { + tempFile.delete() + } + } +} + +private fun createDocument(context: Context, parentTreeUri: Uri, name: String, format: RecordingFormat): Uri { + val parentDocumentUri = buildParentDocumentUri(parentTreeUri) + val displayName = "$name.${format.getExtension(context)}" + val uri = requireNotNull( + DocumentsContract.createDocument( + context.contentResolver, + parentDocumentUri, + format.getMimeType(context), + displayName, + ) + ) { + "failed to create document '$displayName' in $parentDocumentUri" + } + + return uri +} diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/models/Recording.kt b/app/src/main/kotlin/org/fossify/voicerecorder/models/Recording.kt index 24285ee4..da242b3d 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/models/Recording.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/models/Recording.kt @@ -1,5 +1,10 @@ package org.fossify.voicerecorder.models +import android.content.Context +import android.webkit.MimeTypeMap +import org.fossify.commons.helpers.isOreoPlus +import org.fossify.voicerecorder.R + data class Recording( val id: Int, val title: String, @@ -8,3 +13,43 @@ data class Recording( val duration: Int, val size: Int ) + +enum class RecordingFormat(val value: Int) { + M4A(0), + MP3(1), + OGG(2); + + companion object { + fun fromInt(value: Int): RecordingFormat? = when (value) { + M4A.value -> M4A + MP3.value -> MP3 + OGG.value -> OGG + else -> null + } + + /** + * Return formats that are available on the current platform + */ + val available: List = arrayListOf(M4A, MP3).apply { + if (isOreoPlus()) add(OGG) + } + } + + fun getDescription(context: Context): String = context.getString( + when (this) { + RecordingFormat.M4A -> R.string.m4a + RecordingFormat.MP3 -> R.string.mp3_experimental + OGG -> R.string.ogg_opus + } + ) + + fun getExtension(context: Context): String = context.getString( + when (this) { + RecordingFormat.M4A -> R.string.m4a + RecordingFormat.MP3 -> R.string.mp3 + OGG -> R.string.ogg + } + ) + + fun getMimeType(context: Context): String = MimeTypeMap.getSingleton().getMimeTypeFromExtension(getExtension(context))!! +} 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..9771ceef 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.models.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..a2cf8c51 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Mp3Recorder.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Mp3Recorder.kt @@ -10,8 +10,6 @@ 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 +21,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,26 +38,14 @@ class Mp3Recorder(val context: Context) : Recorder { minBufferSize * 2 ) - override fun setOutputFile(path: String) { - outputPath = path - } - override fun prepare() {} override fun start() { 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) @@ -78,17 +62,20 @@ class Mp3Recorder(val context: Context) : Recorder { return@ensureBackgroundThread } - 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()) { + // FIXME: does this busy-loop when `isPaused` is true? + 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() + } } } } @@ -113,7 +100,6 @@ class Mp3Recorder(val context: Context) : Recorder { override fun release() { androidLame?.flush(mp3buffer) - outputStream?.close() audioRecord.release() } @@ -122,7 +108,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..307d7e3e 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt @@ -1,51 +1,29 @@ package org.fossify.voicerecorder.services import android.annotation.SuppressLint -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service +import android.app.* 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.getCurrentFormattedDateTime 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.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 -import org.fossify.voicerecorder.helpers.RECORDING_RUNNING -import org.fossify.voicerecorder.helpers.RECORDING_STOPPED -import org.fossify.voicerecorder.helpers.STOP_AMPLITUDE_UPDATE -import org.fossify.voicerecorder.helpers.TOGGLE_PAUSE +import org.fossify.voicerecorder.helpers.* import org.fossify.voicerecorder.models.Events +import org.fossify.voicerecorder.models.RecordingFormat import org.fossify.voicerecorder.recorder.MediaRecorderWrapper import org.fossify.voicerecorder.recorder.Mp3Recorder import org.fossify.voicerecorder.recorder.Recorder import org.greenrobot.eventbus.EventBus -import java.io.File import java.util.Timer import java.util.TimerTask @@ -54,17 +32,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: RecordingWriter? = null override fun onBind(intent: Intent?): IBinder? = null @@ -98,42 +75,26 @@ class RecorderService : Service() { return } - val defaultFolder = File(config.saveRecordingsFolder) - if (!defaultFolder.exists()) { - defaultFolder.mkdir() - } + val recordingFolder = config.saveRecordingsFolder ?: return + val recordingFormat = config.recordingFormat - val recordingFolder = defaultFolder.absolutePath - recordingPath = "$recordingFolder/${getFormattedFilename()}.${config.getExtension()}" - resultUri = null try { - recorder = if (recordMp3()) { - Mp3Recorder(this) - } else { - MediaRecorderWrapper(this) + recorder = when (recordingFormat) { + RecordingFormat.M4A, RecordingFormat.OGG -> MediaRecorderWrapper(this) + RecordingFormat.MP3 -> Mp3Recorder(this) } - if (isRPlus()) { - val fileUri = createDocumentUriUsingFirstParentTreeUri(recordingPath) - createSAFFileSdk30(recordingPath) - resultUri = fileUri - contentResolver.openFileDescriptor(fileUri, "w")!! - .use { recorder?.setOutputFile(it) } - } else if (isPathOnSD(recordingPath)) { - var document = getDocumentFile(recordingPath.getParentPath()) - document = document?.createFile("", recordingPath.getFilenameFromPath()) - check(document != null) { "Failed to create document on SD Card" } - resultUri = document.uri - contentResolver.openFileDescriptor(document.uri, "w")!! - .use { recorder?.setOutputFile(it) } - } else { - recorder?.setOutputFile(recordingPath) - resultUri = FileProvider.getUriForFile( - this, "${BuildConfig.APPLICATION_ID}.provider", File(recordingPath) - ) + val writer = RecordingWriter.create( + this, + recordingFolder, + getFormattedFilename(), + recordingFormat + ).also { + this.writer = it } + recorder?.setOutputFile(writer.fileDescriptor) recorder?.prepare() recorder?.start() duration = 0 @@ -146,6 +107,8 @@ class RecorderService : Service() { startAmplitudeUpdates() } catch (e: Exception) { + Log.e(TAG, "failed to start recording", e) + showErrorToast(e) stopRecording() } @@ -156,28 +119,42 @@ 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) { + showErrorToast(e) + e.printStackTrace() + } finally { + recorder = null + } + writer?.let { writer -> ensureBackgroundThread { - scanRecording() - EventBus.getDefault().post(Events.RecordingCompleted()) + try { + val uri = writer.commit() + + // TODO: + // scanRecording() + + 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 +162,18 @@ class RecorderService : Service() { amplitudeTimer.cancel() status = RECORDING_STOPPED - recorder?.apply { - try { + try { + recorder?.apply { stop() release() - } catch (ignored: Exception) { } + } catch (ignored: 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,20 +209,21 @@ 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) - } - } + // TODO: what is this for? +// 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) @@ -325,8 +300,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" /> - + + + + + + + From ec9e37e34b10980e867c95f3b3da1839d74954ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Tue, 20 Jan 2026 14:30:49 +0100 Subject: [PATCH 02/28] chore: fix some warnings and lints --- .../voicerecorder/activities/MainActivity.kt | 13 ++---- .../activities/SettingsActivity.kt | 42 +++++++++++------ .../voicerecorder/extensions/Activity.kt | 23 +++++----- .../voicerecorder/extensions/Context.kt | 45 ++----------------- .../voicerecorder/fragments/PlayerFragment.kt | 13 +++++- .../fragments/RecorderFragment.kt | 19 ++++++-- .../voicerecorder/fragments/TrashFragment.kt | 6 ++- .../voicerecorder/helpers/DocumentsUtils.kt | 8 ++-- .../voicerecorder/helpers/RecordingWriter.kt | 8 ++-- .../voicerecorder/services/RecorderService.kt | 13 +++--- 10 files changed, 95 insertions(+), 95 deletions(-) diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/activities/MainActivity.kt b/app/src/main/kotlin/org/fossify/voicerecorder/activities/MainActivity.kt index a054b940..b8c9967a 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/MainActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/MainActivity.kt @@ -1,6 +1,5 @@ package org.fossify.voicerecorder.activities -import android.app.Activity import android.content.Intent import android.os.Bundle import android.provider.MediaStore @@ -25,10 +24,6 @@ import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode class MainActivity : SimpleActivity() { - companion object { - const val TAG = "MainActivity" - } - private var bus: EventBus? = null override var isSearchBarEnabled = true @@ -68,7 +63,7 @@ class MainActivity : SimpleActivity() { Intent(this@MainActivity, RecorderService::class.java).apply { try { startService(this) - } catch (ignored: Exception) { + } catch (_: Exception) { } } } @@ -98,7 +93,7 @@ class MainActivity : SimpleActivity() { action = STOP_AMPLITUDE_UPDATE try { startService(this) - } catch (ignored: Exception) { + } catch (_: Exception) { } } } @@ -108,7 +103,7 @@ class MainActivity : SimpleActivity() { binding.mainMenu.closeSearch() true } else if (isThirdPartyIntent()) { - setResult(Activity.RESULT_CANCELED, null) + setResult(RESULT_CANCELED, null) false } else { false @@ -293,7 +288,7 @@ class MainActivity : SimpleActivity() { Intent().apply { data = event.uri!! flags = Intent.FLAG_GRANT_READ_URI_PERMISSION - setResult(Activity.RESULT_OK, this) + setResult(RESULT_OK, this) } finish() } 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 b70d7c97..5763d824 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt @@ -10,8 +10,20 @@ import androidx.activity.result.contract.ActivityResultContracts import org.fossify.commons.dialogs.ChangeDateTimeFormatDialog import org.fossify.commons.dialogs.ConfirmationDialog import org.fossify.commons.dialogs.RadioGroupDialog -import org.fossify.commons.extensions.* -import org.fossify.commons.helpers.* +import org.fossify.commons.extensions.addLockedLabelIfNeeded +import org.fossify.commons.extensions.beGone +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.toast +import org.fossify.commons.extensions.updateTextColors +import org.fossify.commons.helpers.IS_CUSTOMIZING_COLORS +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 @@ -21,7 +33,11 @@ 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.helpers.* +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.SAMPLING_RATES +import org.fossify.voicerecorder.helpers.SAMPLING_RATE_BITRATE_LIMITS import org.fossify.voicerecorder.models.Events import org.fossify.voicerecorder.models.RecordingFormat import org.greenrobot.eventbus.EventBus @@ -168,23 +184,21 @@ class SettingsActivity : SimpleActivity() { val documentId = DocumentsContract.getTreeDocumentId(uri) binding.settingsSaveRecordings.text = documentId.substringAfter(":").trimEnd('/') - uri.authority?.let { authority -> - packageManager.resolveContentProvider(authority, 0)?.let { providerInfo -> - val providerIcon = providerInfo.loadIcon(packageManager) - val providerLabel = providerInfo.loadLabel(packageManager) + val authority = uri.authority ?: return + val providerInfo = packageManager.resolveContentProvider(authority, 0) ?: return - binding.settingsSaveRecordingsProviderIcon.apply { - setVisibility(View.VISIBLE) - setImageDrawable(providerIcon) - setContentDescription(providerLabel) - } - } + val providerIcon = providerInfo.loadIcon(packageManager) + val providerLabel = providerInfo.loadLabel(packageManager) + + binding.settingsSaveRecordingsProviderIcon.apply { + setVisibility(View.VISIBLE) + setImageDrawable(providerIcon) + setContentDescription(providerLabel) } } else { binding.settingsSaveRecordings.text = "" binding.settingsSaveRecordingsProviderIcon.setVisibility(View.GONE) } - } private fun setupFilenamePattern() { 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 9bbbccf8..19aac102 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt @@ -73,13 +73,13 @@ fun BaseSimpleActivity.moveRecordings( sourceParent, targetParent ) - } catch (e: IllegalStateException) { - moveDocumentFallback(recording.path.toUri(), sourceParent, targetParent) + } catch (@Suppress("SwallowedException") e: IllegalStateException) { + moveDocumentFallback(recording.path.toUri(), sourceParent) } } } else { for (recording in recordingsToMove) { - moveDocumentFallback(recording.path.toUri(), sourceParent, targetParent) + moveDocumentFallback(recording.path.toUri(), sourceParent) } } @@ -90,19 +90,20 @@ fun BaseSimpleActivity.moveRecordings( // Copy source to target, then delete source. Use as fallback when `DocumentsContract.moveDocument` can't used (e.g., when moving between different authorities) private fun BaseSimpleActivity.moveDocumentFallback( sourceUri: Uri, - sourceParent: Uri, - targetParent: Uri, + targetParentUri: Uri, ) { val sourceFile = DocumentFile.fromSingleUri(this, sourceUri)!! val sourceName = requireNotNull(sourceFile.name) val sourceType = requireNotNull(sourceFile.type) - val targetUri = requireNotNull(DocumentsContract.createDocument( - contentResolver, - targetParent, - sourceType, - sourceName - )) + val targetUri = requireNotNull( + DocumentsContract.createDocument( + contentResolver, + targetParentUri, + sourceType, + sourceName + ) + ) contentResolver.openInputStream(sourceUri)?.use { inputStream -> contentResolver.openOutputStream(targetUri)?.use { outputStream -> 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 c6edb133..fa41152b 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Context.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Context.kt @@ -9,13 +9,9 @@ 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.* -import org.fossify.commons.helpers.isQPlus -import org.fossify.voicerecorder.R import org.fossify.voicerecorder.helpers.* import org.fossify.voicerecorder.models.Recording import java.util.Calendar @@ -72,19 +68,6 @@ fun Context.getOrCreateTrashFolder(): Uri? = config.saveRecordingsFolder?.let { getOrCreateDocument(contentResolver, it,DocumentsContract.Document.MIME_TYPE_DIR, TRASH_FOLDER_NAME) } -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 = config.saveRecordingsFolder?.let { uri -> DocumentFile.fromTreeUri(this, uri)?.listFiles()?.any { it.isAudioRecording() } } == true @@ -112,7 +95,7 @@ private fun Context.getRecordings(trashed: Boolean = false): List { ?.filter { it.isAudioRecording() } ?.map { readRecordingFromFile(it) } ?.toList() - ?: emptyList() + ?: emptyList() } @Deprecated( @@ -131,7 +114,7 @@ private fun Context.getMediaStoreTrashedRecordings(): List { readRecordingFromFile(it).copy(title = trashedRegex.replace(it.name!!, "")) } ?.toList() - ?: emptyList() + ?: emptyList() } private fun Context.readRecordingFromFile(file: DocumentFile): Recording { @@ -157,33 +140,11 @@ private fun Context.getDurationFromUri(uri: Uri): Long { retriever.setDataSource(this, uri) val time = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)!! (time.toLong() / 1000.toDouble()).roundToLong() - } catch (e: Exception) { + } catch (_: 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/fragments/PlayerFragment.kt b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/PlayerFragment.kt index 586d6d09..0642630a 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/PlayerFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/PlayerFragment.kt @@ -15,7 +15,18 @@ import android.os.PowerManager import android.util.AttributeSet import android.widget.SeekBar import androidx.core.net.toUri -import org.fossify.commons.extensions.* +import org.fossify.commons.extensions.applyColorFilter +import org.fossify.commons.extensions.areSystemAnimationsEnabled +import org.fossify.commons.extensions.beVisibleIf +import org.fossify.commons.extensions.copyToClipboard +import org.fossify.commons.extensions.getColoredDrawableWithColor +import org.fossify.commons.extensions.getContrastColor +import org.fossify.commons.extensions.getFormattedDuration +import org.fossify.commons.extensions.getProperPrimaryColor +import org.fossify.commons.extensions.getProperTextColor +import org.fossify.commons.extensions.showErrorToast +import org.fossify.commons.extensions.updateTextColors +import org.fossify.commons.extensions.value import org.fossify.commons.helpers.isQPlus import org.fossify.commons.helpers.isTiramisuPlus import org.fossify.voicerecorder.R 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 bdefaae3..a34a9e31 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt @@ -11,12 +11,25 @@ import org.fossify.commons.activities.BaseSimpleActivity import org.fossify.commons.compose.extensions.getActivity import org.fossify.commons.dialogs.ConfirmationDialog import org.fossify.commons.dialogs.PermissionRequiredDialog -import org.fossify.commons.extensions.* +import org.fossify.commons.extensions.applyColorFilter +import org.fossify.commons.extensions.beVisibleIf +import org.fossify.commons.extensions.getColoredDrawableWithColor +import org.fossify.commons.extensions.getContrastColor +import org.fossify.commons.extensions.getFormattedDuration +import org.fossify.commons.extensions.getProperPrimaryColor +import org.fossify.commons.extensions.getProperTextColor +import org.fossify.commons.extensions.openNotificationSettings +import org.fossify.commons.extensions.setDebouncedClickListener import org.fossify.voicerecorder.R import org.fossify.voicerecorder.databinding.FragmentRecorderBinding import org.fossify.voicerecorder.extensions.config import org.fossify.voicerecorder.extensions.setKeepScreenAwake -import org.fossify.voicerecorder.helpers.* +import org.fossify.voicerecorder.helpers.CANCEL_RECORDING +import org.fossify.voicerecorder.helpers.GET_RECORDER_INFO +import org.fossify.voicerecorder.helpers.RECORDING_PAUSED +import org.fossify.voicerecorder.helpers.RECORDING_RUNNING +import org.fossify.voicerecorder.helpers.RECORDING_STOPPED +import org.fossify.voicerecorder.helpers.TOGGLE_PAUSE import org.fossify.voicerecorder.models.Events import org.fossify.voicerecorder.services.RecorderService import org.greenrobot.eventbus.EventBus @@ -86,7 +99,7 @@ class RecorderFragment( action = GET_RECORDER_INFO try { context.startService(this) - } catch (ignored: Exception) { + } catch (_: Exception) { } } } 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 0b92ff40..de7f682c 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/TrashFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/TrashFragment.kt @@ -3,7 +3,11 @@ package org.fossify.voicerecorder.fragments import android.content.Context import android.net.Uri import android.util.AttributeSet -import org.fossify.commons.extensions.* +import org.fossify.commons.extensions.areSystemAnimationsEnabled +import org.fossify.commons.extensions.beVisibleIf +import org.fossify.commons.extensions.getProperPrimaryColor +import org.fossify.commons.extensions.getProperTextColor +import org.fossify.commons.extensions.updateTextColors import org.fossify.voicerecorder.activities.SimpleActivity import org.fossify.voicerecorder.adapters.TrashAdapter import org.fossify.voicerecorder.databinding.FragmentTrashBinding diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/DocumentsUtils.kt b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/DocumentsUtils.kt index 9520d1ba..ebe03294 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/DocumentsUtils.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/DocumentsUtils.kt @@ -5,8 +5,8 @@ import android.net.Uri import android.provider.DocumentsContract /** - * Given a tree URI of some directory (such as obtained with `ACTION_OPEN_DOCUMENT_TREE` intent), returns a corresponding parent URI to create child documents - * in that directory. + * Given a tree URI of some directory (such as obtained with `ACTION_OPEN_DOCUMENT_TREE` intent), + * returns a corresponding parent URI to create child documents in that directory. */ fun buildParentDocumentUri(treeUri: Uri): Uri { val parentDocumentId = DocumentsContract.getTreeDocumentId(treeUri) @@ -19,7 +19,9 @@ fun findChildDocument(contentResolver: ContentResolver, treeUri: Uri, displayNam contentResolver.query( childrenUri, - arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME), + arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME + ), null, null, null, diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingWriter.kt b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingWriter.kt index 8dabccfb..0fdf96f3 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingWriter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingWriter.kt @@ -12,10 +12,10 @@ import java.io.FileInputStream /** * Helper class to write recordings to the device. * - * Note: Why not use [DocumentsContract.createDocument] directly? Because there is currently a bug in [android.provider.MediaStore] (TODO: link to the bugreport) - * which causes crash when writing to some [android.provider.DocumentsProvider]s. Using this class works around the bug. + * Note: Why not use [DocumentsContract.createDocument] directly? Because there is currently a bug in [android.provider.MediaStore] (TODO: link to the + * bugreport) which causes crash when writing to some [android.provider.DocumentsProvider]s. Using this class works around the bug. */ -sealed class RecordingWriter() { +sealed class RecordingWriter { companion object { fun create(context: Context, parentTreeUri: Uri, name: String, format: RecordingFormat): RecordingWriter { val direct = DIRECT_FORMATS.contains(format) or DIRECT_AUTHORITIES.contains(parentTreeUri.authority) @@ -38,8 +38,6 @@ sealed class RecordingWriter() { // Document providers not affected by the MediaStore bug private val DIRECT_AUTHORITIES = arrayOf("com.android.externalstorage.documents") - - private const val TAG = "RecordingWriter" } /** 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 307d7e3e..ecc8aac3 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt @@ -130,10 +130,11 @@ class RecorderService : Service() { "SwallowedException" ) e: RuntimeException ) { + Log.e(TAG, "failed to stop recorder", e) toast(R.string.recording_too_short) } catch (e: Exception) { + Log.e(TAG, "failed to stop recorder", e) showErrorToast(e) - e.printStackTrace() } finally { recorder = null } @@ -167,7 +168,7 @@ class RecorderService : Service() { stop() release() } - } catch (ignored: Exception) { + } catch (_: Exception) { } recorder = null @@ -245,7 +246,7 @@ class RecorderService : Service() { try { EventBus.getDefault() .post(Events.RecordingAmplitude(recorder!!.getMaxAmplitude())) - } catch (ignored: Exception) { + } catch (_: Exception) { } } } @@ -254,7 +255,8 @@ class RecorderService : Service() { private fun showNotification(): Notification { val channelId = "simple_recorder" val label = getString(R.string.app_name) - val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + val notificationManager = + getSystemService(NOTIFICATION_SERVICE) as NotificationManager NotificationChannel(channelId, label, NotificationManager.IMPORTANCE_DEFAULT).apply { setSound(null, null) @@ -262,7 +264,6 @@ 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) { @@ -270,7 +271,7 @@ class RecorderService : Service() { } val builder = NotificationCompat.Builder(this, channelId) - .setContentTitle(title) + .setContentTitle(label) .setContentText(text) .setSmallIcon(icon) .setContentIntent(getOpenAppIntent()) From a8afe9c9594a240799d78fc58de1113b16a4744a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Tue, 20 Jan 2026 15:04:34 +0100 Subject: [PATCH 03/28] refactor: change path to uri in Recording --- .../adapters/RecordingsAdapter.kt | 5 +- .../dialogs/RenameRecordingDialog.kt | 31 ++------- .../voicerecorder/extensions/Activity.kt | 9 ++- .../voicerecorder/extensions/Context.kt | 64 +++++++------------ .../voicerecorder/fragments/PlayerFragment.kt | 5 +- .../fossify/voicerecorder/models/Recording.kt | 3 +- 6 files changed, 37 insertions(+), 80 deletions(-) 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 a4f3a69f..c11f2ccc 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt @@ -126,9 +126,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 +136,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) } 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..e0a480c1 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/RenameRecordingDialog.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/RenameRecordingDialog.kt @@ -1,26 +1,21 @@ 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.greenrobot.eventbus.EventBus -import java.io.File class RenameRecordingDialog( val activity: BaseSimpleActivity, @@ -56,11 +51,7 @@ class RenameRecordingDialog( } ensureBackgroundThread { - if (isRPlus()) { - renameRecording(recording, newTitle) - } else { - renameRecordingLegacy(recording, newTitle) - } + renameRecording(recording, newTitle) activity.runOnUiThread { callback() @@ -77,24 +68,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 19aac102..ab03affb 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt @@ -4,7 +4,6 @@ import android.app.Activity import android.net.Uri import android.provider.DocumentsContract import android.view.WindowManager -import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import org.fossify.commons.activities.BaseSimpleActivity import org.fossify.commons.helpers.DAY_SECONDS @@ -28,7 +27,7 @@ fun BaseSimpleActivity.deleteRecordings( ensureBackgroundThread { val resolver = contentResolver recordingsToRemove.forEach { - DocumentsContract.deleteDocument(resolver, it.path.toUri()) + DocumentsContract.deleteDocument(resolver, it.uri) } callback(true) @@ -69,17 +68,17 @@ fun BaseSimpleActivity.moveRecordings( try { DocumentsContract.moveDocument( contentResolver, - recording.path.toUri(), + recording.uri, sourceParent, targetParent ) } catch (@Suppress("SwallowedException") e: IllegalStateException) { - moveDocumentFallback(recording.path.toUri(), sourceParent) + moveDocumentFallback(recording.uri, sourceParent) } } } else { for (recording in recordingsToMove) { - moveDocumentFallback(recording.path.toUri(), sourceParent) + moveDocumentFallback(recording.uri, sourceParent) } } 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 fa41152b..9c827bd7 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Context.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Context.kt @@ -12,7 +12,12 @@ import android.net.Uri import android.provider.DocumentsContract import androidx.core.graphics.createBitmap import androidx.documentfile.provider.DocumentFile -import org.fossify.voicerecorder.helpers.* +import org.fossify.voicerecorder.helpers.Config +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.helpers.findChildDocument +import org.fossify.voicerecorder.helpers.getOrCreateDocument import org.fossify.voicerecorder.models.Recording import java.util.Calendar import java.util.Locale @@ -41,11 +46,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 @@ -65,7 +68,7 @@ fun Context.updateWidgets(isRecording: Boolean) { * @see [trashFolder] */ fun Context.getOrCreateTrashFolder(): Uri? = config.saveRecordingsFolder?.let { - getOrCreateDocument(contentResolver, it,DocumentsContract.Document.MIME_TYPE_DIR, TRASH_FOLDER_NAME) + getOrCreateDocument(contentResolver, it, DocumentsContract.Document.MIME_TYPE_DIR, TRASH_FOLDER_NAME) } fun Context.hasRecordings(): Boolean = config.saveRecordingsFolder?.let { uri -> @@ -79,8 +82,7 @@ fun Context.getAllRecordings(trashed: Boolean = false): ArrayList { if (trashed) { // Return recordings trashed using MediaStore, this won't be needed in the future - @Suppress("DEPRECATION") - recordings.addAll(getMediaStoreTrashedRecordings()) + @Suppress("DEPRECATION") recordings.addAll(getMediaStoreTrashedRecordings()) } return recordings @@ -90,49 +92,29 @@ private fun Context.getRecordings(trashed: Boolean = false): List { val uri = if (trashed) trashFolder else config.saveRecordingsFolder val folder = uri?.let { DocumentFile.fromTreeUri(this, it) } - return folder - ?.listFiles() - ?.filter { it.isAudioRecording() } - ?.map { readRecordingFromFile(it) } - ?.toList() - ?: emptyList() + return folder?.listFiles()?.filter { it.isAudioRecording() }?.map { readRecordingFromFile(it) }?.toList() ?: emptyList() } @Deprecated( - message = "Use getRecordings instead. This method is only here for backward compatibility.", - replaceWith = ReplaceWith("getRecordings(trashed = true)") + message = "Use getRecordings instead. This method is only here for backward compatibility.", replaceWith = ReplaceWith("getRecordings(trashed = true)") ) private fun Context.getMediaStoreTrashedRecordings(): List { val trashedRegex = "^\\.trashed-\\d+-".toRegex() - return config - .saveRecordingsFolder - ?.let { DocumentFile.fromTreeUri(this, it) } - ?.listFiles() - ?.filter { it.isTrashedMediaStoreRecording() } - ?.map { + return config.saveRecordingsFolder?.let { DocumentFile.fromTreeUri(this, it) }?.listFiles()?.filter { it.isTrashedMediaStoreRecording() }?.map { readRecordingFromFile(it).copy(title = trashedRegex.replace(it.name!!, "")) - } - ?.toList() - ?: emptyList() + }?.toList() ?: emptyList() } -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.readRecordingFromFile(file: DocumentFile): Recording = Recording( + id = file.hashCode(), + title = file.name!!, + uri = file.uri, + timestamp = file.lastModified(), + duration = getDurationFromUri(file.uri).toInt(), + size = file.length().toInt() +) + private fun Context.getDurationFromUri(uri: Uri): Long { return try { 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 0642630a..ceb0f182 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/PlayerFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/PlayerFragment.kt @@ -14,7 +14,6 @@ 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 @@ -254,7 +253,7 @@ class PlayerFragment( reset() try { - setDataSource(context, recording.path.toUri()) + setDataSource(context, recording.uri) } catch (e: Exception) { context?.showErrorToast(e) return @@ -434,7 +433,7 @@ class PlayerFragment( try { isReceiverRegistered = false context.unregisterReceiver(becomingNoisyReceiver) - } catch (ignored: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { } } } diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/models/Recording.kt b/app/src/main/kotlin/org/fossify/voicerecorder/models/Recording.kt index da242b3d..654f6685 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/models/Recording.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/models/Recording.kt @@ -1,6 +1,7 @@ package org.fossify.voicerecorder.models import android.content.Context +import android.net.Uri import android.webkit.MimeTypeMap import org.fossify.commons.helpers.isOreoPlus import org.fossify.voicerecorder.R @@ -8,7 +9,7 @@ import org.fossify.voicerecorder.R data class Recording( val id: Int, val title: String, - val path: String, + val uri: Uri, val timestamp: Long, val duration: Int, val size: Int From 530f6e183e08bad69842d4fc300e8966f6c66ee8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Tue, 20 Jan 2026 17:51:45 +0100 Subject: [PATCH 04/28] refactor: extract RecordingStore --- .../activities/SettingsActivity.kt | 56 ++-- .../adapters/RecordingsAdapter.kt | 28 +- .../voicerecorder/adapters/TrashAdapter.kt | 25 +- .../dialogs/MoveRecordingsDialog.kt | 32 +-- .../voicerecorder/extensions/Activity.kt | 107 +------- .../voicerecorder/extensions/Context.kt | 89 +------ .../fragments/MyViewPagerFragment.kt | 8 +- .../fossify/voicerecorder/helpers/Config.kt | 10 +- .../voicerecorder/helpers/RecordingStore.kt | 252 ++++++++++++++++++ .../voicerecorder/services/RecorderService.kt | 6 +- 10 files changed, 318 insertions(+), 295 deletions(-) create mode 100644 app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingStore.kt 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 5763d824..4fb57dd6 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt @@ -4,40 +4,22 @@ import android.content.Intent import android.media.MediaRecorder import android.net.Uri import android.os.Bundle -import android.provider.DocumentsContract 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 -import org.fossify.commons.extensions.addLockedLabelIfNeeded -import org.fossify.commons.extensions.beGone -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.toast -import org.fossify.commons.extensions.updateTextColors -import org.fossify.commons.helpers.IS_CUSTOMIZING_COLORS -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.extensions.* +import org.fossify.commons.helpers.* 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.helpers.BITRATES -import org.fossify.voicerecorder.helpers.DEFAULT_BITRATE -import org.fossify.voicerecorder.helpers.DEFAULT_SAMPLING_RATE -import org.fossify.voicerecorder.helpers.SAMPLING_RATES -import org.fossify.voicerecorder.helpers.SAMPLING_RATE_BITRATE_LIMITS +import org.fossify.voicerecorder.extensions.recordingStore +import org.fossify.voicerecorder.extensions.recordingStoreFor +import org.fossify.voicerecorder.helpers.* import org.fossify.voicerecorder.models.Events import org.fossify.voicerecorder.models.RecordingFormat import org.greenrobot.eventbus.EventBus @@ -61,10 +43,9 @@ class SettingsActivity : SimpleActivity() { ) ensureBackgroundThread { - val hasRecordings = hasRecordings() - + val hasRecordings = recordingStore.isNotEmpty() runOnUiThread { - if (oldUri != null && newUri != oldUri && hasRecordings) { + if (newUri != oldUri && hasRecordings) { MoveRecordingsDialog( activity = this, oldFolder = oldUri, @@ -179,26 +160,25 @@ class SettingsActivity : SimpleActivity() { updateSaveRecordingsFolder(config.saveRecordingsFolder) } - private fun updateSaveRecordingsFolder(uri: Uri?) { - if (uri != null) { - val documentId = DocumentsContract.getTreeDocumentId(uri) - binding.settingsSaveRecordings.text = documentId.substringAfter(":").trimEnd('/') + private fun updateSaveRecordingsFolder(uri: Uri) { + val store = recordingStoreFor(uri) + binding.settingsSaveRecordings.text = store.shortName - val authority = uri.authority ?: return - val providerInfo = packageManager.resolveContentProvider(authority, 0) ?: return + val providerInfo = store.providerInfo + if (providerInfo != null) { val providerIcon = providerInfo.loadIcon(packageManager) val providerLabel = providerInfo.loadLabel(packageManager) binding.settingsSaveRecordingsProviderIcon.apply { - setVisibility(View.VISIBLE) + visibility = View.VISIBLE + contentDescription = providerLabel setImageDrawable(providerIcon) - setContentDescription(providerLabel) } } else { - binding.settingsSaveRecordings.text = "" - binding.settingsSaveRecordingsProviderIcon.setVisibility(View.GONE) + binding.settingsSaveRecordingsProviderIcon.visibility = View.GONE } + } private fun setupFilenamePattern() { @@ -330,7 +310,7 @@ class SettingsActivity : SimpleActivity() { private fun setupEmptyRecycleBin() { ensureBackgroundThread { try { - recycleBinContentSize = getAllRecordings(trashed = true).sumByInt { it.size } + recycleBinContentSize = recordingStore.getAll(trashed = true).sumByInt { it.size } } catch (_: Exception) { } @@ -351,7 +331,7 @@ class SettingsActivity : SimpleActivity() { negative = org.fossify.commons.R.string.no ) { ensureBackgroundThread { - deleteTrashedRecordings() + recordingStore.deleteTrashed() runOnUiThread { recycleBinContentSize = 0 binding.settingsEmptyRecycleBinSize.text = 0.formatSize() 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 c11f2ccc..6f39a49a 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt @@ -1,18 +1,11 @@ package org.fossify.voicerecorder.adapters import android.annotation.SuppressLint -import android.view.Menu -import android.view.View -import android.view.ViewGroup +import android.view.* +import android.widget.PopupMenu import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller import org.fossify.commons.adapters.MyRecyclerViewAdapter -import org.fossify.commons.extensions.formatDate -import org.fossify.commons.extensions.formatSize -import org.fossify.commons.extensions.getFormattedDuration -import org.fossify.commons.extensions.getProperPrimaryColor -import org.fossify.commons.extensions.openPathIntent -import org.fossify.commons.extensions.setupViewBackground -import org.fossify.commons.extensions.sharePathsIntent +import org.fossify.commons.extensions.* import org.fossify.commons.helpers.ensureBackgroundThread import org.fossify.commons.views.MyRecyclerView import org.fossify.voicerecorder.BuildConfig @@ -22,8 +15,7 @@ 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 @@ -179,11 +171,12 @@ class RecordingsAdapter( val oldRecordingIndex = recordings.indexOfFirst { it.id == currRecordingId } val recordingsToRemove = recordings - .filter { selectedKeys.contains(it.id) } as ArrayList + .filter { selectedKeys.contains(it.id) } + .toList() val positions = getSelectedItemPositions() - activity.deleteRecordings(recordingsToRemove) { success -> + activity.recordingStore.delete(recordingsToRemove) { success -> if (success) { doDeleteAnimation(oldRecordingIndex, recordingsToRemove, positions) } @@ -197,11 +190,12 @@ class RecordingsAdapter( val oldRecordingIndex = recordings.indexOfFirst { it.id == currRecordingId } val recordingsToRemove = recordings - .filter { selectedKeys.contains(it.id) } as ArrayList + .filter { selectedKeys.contains(it.id) } + .toList() val positions = getSelectedItemPositions() - activity.trashRecordings(recordingsToRemove) { success -> + activity.recordingStore.trash(recordingsToRemove) { success -> if (success) { doDeleteAnimation(oldRecordingIndex, recordingsToRemove, positions) EventBus.getDefault().post(Events.RecordingTrashUpdated()) @@ -211,7 +205,7 @@ class RecordingsAdapter( private fun doDeleteAnimation( oldRecordingIndex: Int, - recordingsToRemove: ArrayList, + recordingsToRemove: List, positions: ArrayList ) { recordings.removeAll(recordingsToRemove.toSet()) 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..12002882 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/TrashAdapter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/TrashAdapter.kt @@ -1,23 +1,18 @@ package org.fossify.voicerecorder.adapters import android.annotation.SuppressLint -import android.view.Menu -import android.view.View -import android.view.ViewGroup +import android.view.* +import android.widget.PopupMenu import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller import org.fossify.commons.adapters.MyRecyclerViewAdapter import org.fossify.commons.dialogs.ConfirmationDialog -import org.fossify.commons.extensions.formatDate -import org.fossify.commons.extensions.formatSize -import org.fossify.commons.extensions.getFormattedDuration -import org.fossify.commons.extensions.setupViewBackground +import org.fossify.commons.extensions.* import org.fossify.commons.helpers.ensureBackgroundThread 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 @@ -99,11 +94,12 @@ class TrashAdapter( } val recordingsToRestore = recordings - .filter { selectedKeys.contains(it.id) } as ArrayList + .filter { selectedKeys.contains(it.id) } + .toList() val positions = getSelectedItemPositions() - activity.restoreRecordings(recordingsToRestore) { success -> + activity.recordingStore.restore(recordingsToRestore) { success -> if (success) { doDeleteAnimation(recordingsToRestore, positions) EventBus.getDefault().post(Events.RecordingTrashUpdated()) @@ -136,11 +132,12 @@ class TrashAdapter( } val recordingsToRemove = recordings - .filter { selectedKeys.contains(it.id) } as ArrayList + .filter { selectedKeys.contains(it.id) } + .toList() val positions = getSelectedItemPositions() - activity.deleteRecordings(recordingsToRemove) { success -> + activity.recordingStore.delete(recordingsToRemove) { success -> if (success) { doDeleteAnimation(recordingsToRemove, positions) } @@ -148,7 +145,7 @@ class TrashAdapter( } private fun doDeleteAnimation( - recordingsToRemove: ArrayList, + recordingsToRemove: List, positions: ArrayList ) { recordings.removeAll(recordingsToRemove.toSet()) 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 d526211c..e7860649 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/MoveRecordingsDialog.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/MoveRecordingsDialog.kt @@ -10,15 +10,10 @@ import org.fossify.commons.helpers.MEDIUM_ALPHA import org.fossify.commons.helpers.ensureBackgroundThread import org.fossify.voicerecorder.R import org.fossify.voicerecorder.databinding.DialogMoveRecordingsBinding -import org.fossify.voicerecorder.extensions.getAllRecordings -import org.fossify.voicerecorder.extensions.moveRecordings -import org.fossify.voicerecorder.helpers.buildParentDocumentUri +import org.fossify.voicerecorder.extensions.recordingStore class MoveRecordingsDialog( - private val activity: BaseSimpleActivity, - private val oldFolder: Uri, - private val newFolder: Uri, - private val callback: () -> Unit + private val activity: BaseSimpleActivity, private val oldFolder: Uri, private val newFolder: Uri, private val callback: () -> Unit ) { private lateinit var dialog: AlertDialog private val binding = DialogMoveRecordingsBinding.inflate(activity.layoutInflater).apply { @@ -27,14 +22,10 @@ class MoveRecordingsDialog( } init { - activity.getAlertDialogBuilder() - .setPositiveButton(org.fossify.commons.R.string.yes, null) - .setNegativeButton(org.fossify.commons.R.string.no, null) + 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() } @@ -49,9 +40,7 @@ class MoveRecordingsDialog( setCancelable(false) setCanceledOnTouchOutside(false) arrayOf( - binding.message, - getButton(AlertDialog.BUTTON_POSITIVE), - getButton(AlertDialog.BUTTON_NEGATIVE) + binding.message, getButton(AlertDialog.BUTTON_POSITIVE), getButton(AlertDialog.BUTTON_NEGATIVE) ).forEach { button -> button.isEnabled = false button.alpha = MEDIUM_ALPHA @@ -64,13 +53,9 @@ class MoveRecordingsDialog( } } - private fun moveAllRecordings() { - ensureBackgroundThread { - activity.moveRecordings( - recordingsToMove = activity.getAllRecordings(), - sourceParent = buildParentDocumentUri(oldFolder), - targetParent = buildParentDocumentUri(newFolder) - ) { + private fun moveAllRecordings() = ensureBackgroundThread { + activity.recordingStore.let { store -> + store.move(store.getAll(), oldFolder, newFolder) { activity.runOnUiThread { callback() dialog.dismiss() @@ -78,4 +63,5 @@ class MoveRecordingsDialog( } } } + } 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 ab03affb..5c00d6e2 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt @@ -1,16 +1,11 @@ package org.fossify.voicerecorder.extensions import android.app.Activity -import android.net.Uri -import android.provider.DocumentsContract import android.view.WindowManager -import androidx.documentfile.provider.DocumentFile import org.fossify.commons.activities.BaseSimpleActivity import org.fossify.commons.helpers.DAY_SECONDS import org.fossify.commons.helpers.MONTH_SECONDS import org.fossify.commons.helpers.ensureBackgroundThread -import org.fossify.voicerecorder.helpers.buildParentDocumentUri -import org.fossify.voicerecorder.models.Recording fun Activity.setKeepScreenAwake(keepScreenOn: Boolean) { if (keepScreenOn) { @@ -20,103 +15,6 @@ fun Activity.setKeepScreenAwake(keepScreenOn: Boolean) { } } -fun BaseSimpleActivity.deleteRecordings( - recordingsToRemove: Collection, - callback: (success: Boolean) -> Unit -) { - ensureBackgroundThread { - val resolver = contentResolver - recordingsToRemove.forEach { - DocumentsContract.deleteDocument(resolver, it.uri) - } - - callback(true) - } -} - -fun BaseSimpleActivity.trashRecordings( - recordingsToMove: Collection, - callback: (success: Boolean) -> Unit -) = moveRecordings( - recordingsToMove = recordingsToMove, - sourceParent = config.saveRecordingsFolder?.let(::buildParentDocumentUri)!!, - targetParent = getOrCreateTrashFolder()!!, - callback = callback -) - -fun BaseSimpleActivity.restoreRecordings( - recordingsToRestore: Collection, - callback: (success: Boolean) -> Unit -) = moveRecordings( - recordingsToMove = recordingsToRestore, - sourceParent = getOrCreateTrashFolder()!!, - targetParent = config.saveRecordingsFolder?.let(::buildParentDocumentUri)!!, - callback = callback -) - -fun BaseSimpleActivity.moveRecordings( - recordingsToMove: Collection, - sourceParent: Uri, - targetParent: Uri, - callback: (success: Boolean) -> Unit -) { - ensureBackgroundThread { - val contentResolver = contentResolver - - if (sourceParent.authority == targetParent.authority) { - for (recording in recordingsToMove) { - try { - DocumentsContract.moveDocument( - contentResolver, - recording.uri, - sourceParent, - targetParent - ) - } catch (@Suppress("SwallowedException") e: IllegalStateException) { - moveDocumentFallback(recording.uri, sourceParent) - } - } - } else { - for (recording in recordingsToMove) { - moveDocumentFallback(recording.uri, sourceParent) - } - } - - callback(true) - } -} - -// Copy source to target, then delete source. Use as fallback when `DocumentsContract.moveDocument` can't used (e.g., when moving between different authorities) -private fun BaseSimpleActivity.moveDocumentFallback( - sourceUri: Uri, - targetParentUri: Uri, -) { - val sourceFile = DocumentFile.fromSingleUri(this, sourceUri)!! - val sourceName = requireNotNull(sourceFile.name) - val sourceType = requireNotNull(sourceFile.type) - - val targetUri = requireNotNull( - DocumentsContract.createDocument( - contentResolver, - targetParentUri, - sourceType, - sourceName - ) - ) - - contentResolver.openInputStream(sourceUri)?.use { inputStream -> - contentResolver.openOutputStream(targetUri)?.use { outputStream -> - inputStream.copyTo(outputStream) - } - } - - DocumentsContract.deleteDocument(contentResolver, sourceUri) -} - -fun BaseSimpleActivity.deleteTrashedRecordings() { - deleteRecordings(getAllRecordings(trashed = true)) {} -} - fun BaseSimpleActivity.deleteExpiredTrashedRecordings() { if ( config.useRecycleBin && @@ -125,10 +23,11 @@ fun BaseSimpleActivity.deleteExpiredTrashedRecordings() { config.lastRecycleBinCheck = System.currentTimeMillis() ensureBackgroundThread { try { - val recordingsToRemove = getAllRecordings(trashed = true) + val store = recordingStore + val recordingsToRemove = store.getAll(trashed = true) .filter { it.timestamp < System.currentTimeMillis() - MONTH_SECONDS * 1000L } if (recordingsToRemove.isNotEmpty()) { - deleteRecordings(recordingsToRemove) {} + store.delete(recordingsToRemove) } } catch (e: Exception) { e.printStackTrace() 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 9c827bd7..8c8da151 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Context.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Context.kt @@ -7,34 +7,17 @@ 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.provider.DocumentsContract import androidx.core.graphics.createBitmap -import androidx.documentfile.provider.DocumentFile -import org.fossify.voicerecorder.helpers.Config -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.helpers.findChildDocument -import org.fossify.voicerecorder.helpers.getOrCreateDocument -import org.fossify.voicerecorder.models.Recording +import org.fossify.voicerecorder.helpers.* import java.util.Calendar import java.util.Locale -import kotlin.math.roundToLong val Context.config: Config get() = Config.newInstance(applicationContext) -private const val TRASH_FOLDER_NAME = ".trash" +val Context.recordingStore: RecordingStore get() = recordingStoreFor(config.saveRecordingsFolder) -/** - * Returns the URI of the trash folder as a sub-folder of the save recordings folder. The trash folder itself might not yet exists. Returns null if the save - * recordings folder is not defined. - */ -val Context.trashFolder: Uri? - get() = config.saveRecordingsFolder?.let { - findChildDocument(contentResolver, it, TRASH_FOLDER_NAME) - } +fun Context.recordingStoreFor(uri: Uri): RecordingStore = RecordingStore(this, uri) fun Context.drawableToBitmap(drawable: Drawable): Bitmap { val size = (60 * resources.displayMetrics.density).toInt() @@ -61,72 +44,6 @@ fun Context.updateWidgets(isRecording: Boolean) { } } -/** - * Returns the URI of the trash folder. Creates the folder if it doesn't yet exist. Returns null if the save recording folder is not defined or if the trash - * folder creation failed. - * - * @see [trashFolder] - */ -fun Context.getOrCreateTrashFolder(): Uri? = config.saveRecordingsFolder?.let { - getOrCreateDocument(contentResolver, it, DocumentsContract.Document.MIME_TYPE_DIR, TRASH_FOLDER_NAME) -} - -fun Context.hasRecordings(): Boolean = config.saveRecordingsFolder?.let { uri -> - DocumentFile.fromTreeUri(this, uri)?.listFiles()?.any { it.isAudioRecording() } -} == true - -fun Context.getAllRecordings(trashed: Boolean = false): ArrayList { - 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()) - } - - return recordings -} - -private fun Context.getRecordings(trashed: Boolean = false): List { - val uri = if (trashed) trashFolder else config.saveRecordingsFolder - val folder = uri?.let { DocumentFile.fromTreeUri(this, it) } - - return folder?.listFiles()?.filter { it.isAudioRecording() }?.map { readRecordingFromFile(it) }?.toList() ?: emptyList() -} - -@Deprecated( - message = "Use getRecordings instead. This method is only here for backward compatibility.", replaceWith = ReplaceWith("getRecordings(trashed = true)") -) -private fun Context.getMediaStoreTrashedRecordings(): List { - val trashedRegex = "^\\.trashed-\\d+-".toRegex() - - return config.saveRecordingsFolder?.let { DocumentFile.fromTreeUri(this, it) }?.listFiles()?.filter { it.isTrashedMediaStoreRecording() }?.map { - readRecordingFromFile(it).copy(title = trashedRegex.replace(it.name!!, "")) - }?.toList() ?: emptyList() -} - -private fun Context.readRecordingFromFile(file: DocumentFile): Recording = Recording( - id = file.hashCode(), - title = file.name!!, - uri = file.uri, - timestamp = file.lastModified(), - duration = getDurationFromUri(file.uri).toInt(), - size = file.length().toInt() -) - - -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 (_: Exception) { - 0L - } -} - // move to commons in the future fun Context.getFormattedFilename(): String { val pattern = config.filenamePattern 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..448fd03b 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/MyViewPagerFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/MyViewPagerFragment.kt @@ -5,11 +5,10 @@ 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.extensions.recordingStore import org.fossify.voicerecorder.models.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() @@ -21,8 +20,7 @@ abstract class MyViewPagerFragment(context: Context, attributeSet: AttributeSet) open fun loadRecordings(trashed: Boolean = false) { onLoadingStart() ensureBackgroundThread { - val recordings = context.getAllRecordings(trashed) - .apply { sortByDescending { it.timestamp } } + val recordings = context.recordingStore.getAll(trashed).sortedByDescending { it.timestamp }.let { ArrayList(it) } (context as? Activity)?.runOnUiThread { onLoadingEnd(recordings) 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 96473737..93e0b2e4 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Config.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Config.kt @@ -3,7 +3,9 @@ package org.fossify.voicerecorder.helpers import android.content.Context import android.media.MediaRecorder import android.net.Uri +import android.provider.MediaStore 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 @@ -14,17 +16,17 @@ class Config(context: Context) : BaseConfig(context) { fun newInstance(context: Context) = Config(context) } - var saveRecordingsFolder: Uri? + var saveRecordingsFolder: Uri get() = when (val value = prefs.getString(SAVE_RECORDINGS, null)) { - is String if value.startsWith("content:") -> Uri.parse(value) + is String if value.startsWith("content:") -> value.toUri() is String -> context.createFirstParentTreeUri(value) - null -> null /*MediaStore.Audio.Media.EXTERNAL_CONTENT_URI*/ + null -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI } set(uri) = prefs.edit { putString(SAVE_RECORDINGS, uri.toString()) } var recordingFormat: RecordingFormat get() = prefs.getInt(EXTENSION, -1).let(RecordingFormat::fromInt) ?: RecordingFormat.M4A - set(format) = prefs.edit().putInt(EXTENSION, format.value).apply() + set(format) = prefs.edit { putInt(EXTENSION, format.value) } var microphoneMode: Int get() = prefs.getInt(MICROPHONE_MODE, MediaRecorder.AudioSource.DEFAULT) diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingStore.kt b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingStore.kt new file mode 100644 index 00000000..b8faca42 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingStore.kt @@ -0,0 +1,252 @@ +package org.fossify.voicerecorder.helpers + +import android.content.Context +import android.content.pm.ProviderInfo +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.util.Log +import androidx.documentfile.provider.DocumentFile +import org.fossify.commons.helpers.ensureBackgroundThread +import org.fossify.voicerecorder.extensions.isAudioRecording +import org.fossify.voicerecorder.models.Recording +import org.fossify.voicerecorder.models.RecordingFormat +import kotlin.math.roundToLong + +/** + * Utility to manage stored recordings + */ +class RecordingStore(private val context: Context, val uri: Uri) { + companion object { + private const val TAG = "RecordingStore" + } + + enum class Kind { + DOCUMENT, MEDIA; + + companion object { + fun of(uri: Uri): Kind = if (uri.authority == MediaStore.AUTHORITY) { + Kind.MEDIA + } else { + Kind.DOCUMENT + } + } + } + + /** + * Short, human-readable name of this store + */ + val shortName: String + get() = when (kind) { + Kind.DOCUMENT -> { + val documentId = DocumentsContract.getTreeDocumentId(uri) + documentId.substringAfter(":").trimEnd('/') + } + + Kind.MEDIA -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Environment.DIRECTORY_RECORDINGS + } else { + DEFAULT_RECORDINGS_FOLDER + } + } + } + + /** + * Get the [ProviderInfo] for the content provider backing this store + */ + val providerInfo: ProviderInfo? = uri.authority?.let { + context.packageManager.resolveContentProvider(it, 0) + } + + /** + * Are there any recordings in this store? + */ + fun isNotEmpty(): Boolean = when (kind) { + Kind.DOCUMENT -> DocumentFile.fromTreeUri(context, uri)?.listFiles()?.any { it.isAudioRecording() } == true + Kind.MEDIA -> false // TODO + } + + /** + * Returns all recordings in this store. + */ + fun getAll(trashed: Boolean = false): List = when (kind) { + Kind.DOCUMENT -> { + val parentUri = if (trashed) { + trashFolder + } else { + uri + } + + parentUri?.let { DocumentFile.fromTreeUri(context, it) }?.listFiles()?.filter { it.isAudioRecording() }?.map { readRecordingFromFile(it) }?.toList() + ?: emptyList() + } + + Kind.MEDIA -> { + // TODO + emptyList() + } + } + + fun trash( + recordings: Collection, callback: (success: Boolean) -> Unit + ) = move( + recordings, uri, getOrCreateTrashFolder()!!, callback + ) + + fun restore( + recordings: Collection, callback: (success: Boolean) -> Unit + ) { + val sourceParent = trashFolder + if (sourceParent == null) { + callback(true) + return + } + + move( + recordings, sourceParent, uri, callback + ) + } + + fun deleteTrashed( + callback: (success: Boolean) -> Unit = {} + ) = delete(getAll(trashed = true), callback) + + fun move( + recordings: Collection, sourceParent: Uri, targetParent: Uri, callback: (success: Boolean) -> Unit + ) = ensureBackgroundThread { + val contentResolver = context.contentResolver + val sourceParentDocumentUri = ensureParentDocumentUri(context, sourceParent) + val targetParentDocumentUri = ensureParentDocumentUri(context, targetParent) + + Log.d(TAG, "move src: $sourceParent -> $sourceParentDocumentUri, dst: $targetParent -> $targetParentDocumentUri") + + if (sourceParent.authority == targetParent.authority) { + + for (recording in recordings) { + try { + // TODO: convert to document URI only if not already document URI + + DocumentsContract.moveDocument( + contentResolver, recording.uri, sourceParentDocumentUri, targetParentDocumentUri + ) + } catch (@Suppress("SwallowedException") e: IllegalStateException) { + moveFallback(recording.uri, targetParentDocumentUri) + } + } + } else { + for (recording in recordings) { + moveFallback(recording.uri, targetParentDocumentUri) + } + } + + callback(true) + } + + fun delete( + recordings: Collection, callback: (success: Boolean) -> Unit = {} + ) = ensureBackgroundThread { + when (kind) { + Kind.DOCUMENT -> { + val resolver = context.contentResolver + recordings.forEach { + DocumentsContract.deleteDocument(resolver, it.uri) + } + } + + Kind.MEDIA -> { + TODO() + } + } + + callback(true) + } + + fun createWriter(name: String, format: RecordingFormat): RecordingWriter = RecordingWriter.create(context, uri, name, format) + + private val kind: Kind = Kind.of(uri) + + private val trashFolder: Uri? + get() = findChildDocument(context.contentResolver, uri, TRASH_FOLDER_NAME) + + private fun getOrCreateTrashFolder(): Uri? = when (kind) { + Kind.DOCUMENT -> getOrCreateDocument( + context.contentResolver, uri, DocumentsContract.Document.MIME_TYPE_DIR, TRASH_FOLDER_NAME + ) + + Kind.MEDIA -> null + } + + private fun readRecordingFromFile(file: DocumentFile): Recording = Recording( + id = file.hashCode(), + title = file.name!!, + uri = file.uri, + timestamp = file.lastModified(), + duration = getDurationFromUri(file.uri).toInt(), + size = file.length().toInt() + ) + + private fun getDurationFromUri(uri: Uri): Long { + return try { + val retriever = MediaMetadataRetriever() + retriever.setDataSource(context, uri) + val time = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)!! + (time.toLong() / 1000.toDouble()).roundToLong() + } catch (_: Exception) { + 0L + } + } + + // Copy source to target, then delete source. Use as fallback when `DocumentsContract.moveDocument` can't used (e.g., when moving between different authorities) + private fun moveFallback( + sourceUri: Uri, + targetParentUri: Uri, + ) { + Log.d(TAG, "moveFallback: src:$sourceUri dst:$targetParentUri") + + val contentResolver = context.contentResolver + + // TODO: media + + val sourceFile = DocumentFile.fromSingleUri(context, sourceUri)!! + val sourceName = requireNotNull(sourceFile.name) + val sourceType = requireNotNull(sourceFile.type) + + val targetUri = requireNotNull( + DocumentsContract.createDocument( + contentResolver, targetParentUri, sourceType, sourceName + ) + ) + + contentResolver.openInputStream(sourceUri)?.use { inputStream -> + contentResolver.openOutputStream(targetUri)?.use { outputStream -> + inputStream.copyTo(outputStream) + } + } + + DocumentsContract.deleteDocument(contentResolver, sourceUri) + } +} + +private const val TRASH_FOLDER_NAME = ".trash" + +private fun ensureParentDocumentUri(context: Context, uri: Uri): Uri = when { + DocumentsContract.isDocumentUri(context, uri) -> uri + DocumentsContract.isTreeUri(uri) -> buildParentDocumentUri(uri) + else -> error("invalid URI, must be document or tree: $uri") +} + + +//@Deprecated( +// message = "Use getRecordings instead. This method is only here for backward compatibility.", replaceWith = ReplaceWith("getRecordings(trashed = true)") +//) +//private fun Context.getMediaStoreTrashedRecordings(): List { +// val trashedRegex = "^\\.trashed-\\d+-".toRegex() +// +// return config.saveRecordingsFolder?.let { DocumentFile.fromTreeUri(this, it) }?.listFiles()?.filter { it.isTrashedMediaStoreRecording() }?.map { +// readRecordingFromFile(it).copy(title = trashedRegex.replace(it.name!!, "")) +// }?.toList() ?: emptyList() +//} 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 ecc8aac3..14e3be05 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt @@ -7,7 +7,6 @@ import android.net.Uri import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat -import org.fossify.commons.extensions.getCurrentFormattedDateTime import org.fossify.commons.extensions.getLaunchIntent import org.fossify.commons.extensions.showErrorToast import org.fossify.commons.extensions.toast @@ -16,6 +15,7 @@ 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.* import org.fossify.voicerecorder.models.Events @@ -85,9 +85,7 @@ class RecorderService : Service() { RecordingFormat.MP3 -> Mp3Recorder(this) } - val writer = RecordingWriter.create( - this, - recordingFolder, + val writer = recordingStore.createWriter( getFormattedFilename(), recordingFormat ).also { From 550d1856a9755aa80729eab795d6b58abfb829b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Thu, 22 Jan 2026 14:40:07 +0100 Subject: [PATCH 05/28] feat: implement MediaStore backend for RecordingStore + add tests --- app/build.gradle.kts | 9 +- app/src/androidTest/AndroidManifest.xml | 17 ++ app/src/androidTest/assets/sample.ogg | Bin 0 -> 104793 bytes .../voicerecorder/MockDocumentsProvider.kt | 53 ++++ .../voicerecorder/RecordingStoreTest.kt | 182 ++++++++++++++ .../adapters/RecordingsAdapter.kt | 41 +--- .../voicerecorder/adapters/TrashAdapter.kt | 36 +-- .../dialogs/MoveRecordingsDialog.kt | 11 +- .../voicerecorder/helpers/Constants.kt | 9 +- .../voicerecorder/helpers/RecordingStore.kt | 229 +++++++++++++----- .../voicerecorder/helpers/RecordingWriter.kt | 36 ++- .../voicerecorder/services/RecorderService.kt | 2 - gradle/libs.versions.toml | 2 + 13 files changed, 493 insertions(+), 134 deletions(-) create mode 100644 app/src/androidTest/AndroidManifest.xml create mode 100644 app/src/androidTest/assets/sample.ogg create mode 100644 app/src/androidTest/kotlin/org/fossify/voicerecorder/MockDocumentsProvider.kt create mode 100644 app/src/androidTest/kotlin/org/fossify/voicerecorder/RecordingStoreTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 238ea798..9bec1dfa 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.ksp) alias(libs.plugins.detekt) + + } val keystorePropertiesFile: File = rootProject.file("keystore.properties") @@ -37,6 +40,8 @@ android { versionName = project.property("VERSION_NAME").toString() versionCode = project.property("VERSION_CODE").toString().toInt() vectorDrawables.useSupportLibrary = true + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } signingConfigs { @@ -147,4 +152,6 @@ dependencies { implementation(libs.tandroidlame) implementation(libs.autofittextview) detektPlugins(libs.compose.detekt) + + androidTestImplementation(libs.androidx.test.runner) } diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 00000000..802e36ef --- /dev/null +++ b/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/app/src/androidTest/assets/sample.ogg b/app/src/androidTest/assets/sample.ogg new file mode 100644 index 0000000000000000000000000000000000000000..39f957bf5a7bc4fdf09900805b459d3d3d0ec385 GIT binary patch literal 104793 zcmeEucT`i)*62wHn9!tzUZi)B0HLEH^bUd0MS73Y1rZHZdhY_#J4#nT5b3>l1W~$( zA_}NKfAWIfd%t_%duzRKt?%D=)?}EOy=U(;yUgsFGiu=CVhn(Re>^w3n=~$p3skiH zAa+o|Z6A9#--`yYYVE}z03h=IgZvz1aMAN$&qYrVp?1mb1fY>V|KFYzqCcc42pGC{ z0nSJ{F$ozlgt+)c7dpbv>6XK7M<*Yp{{#~M=_QC(F$Doo0vbYBpbtNlj0FHH0N}`$ zrt+w0S+Mj7D-E#psJO|v6LS6k4DLnF~=OHOly zzf3@b)c&2^p3;e+4duX6?r}* zf`c64_j~9ZAfS+2F87^au|4yXck~dA zEC;=LG%!4B7@k2NkxRcNJOmJQio%HWW26Rs6*c@+2mN%lGE9autVdg{DgLR13$+6P zidL#0BQlsOVjzjc8Y^Kci9B^%;5Sq&KDA$<3Uh&%p2m+H zULutKp>6`6MRF>?=sq){q?MRTBDy?BfW~0b1>}|s{)684!t~4N-`(0)(ma%YmzYY@ zkF@@a)J19JkNKbg0PnpZ@US>-@1A#*Pr0C`P&%LA?bMPjx#7Cz*Lk;kgKtZtss zBfepNJ+B$5A(;Q?r6=Te^Zs}gL8%-BD<)xbf)j`mtawDQvNV@g{F~Od(PG*_@%b7TW0b6B-tUmK!Z4FslN_ZoixcP6%;a{Eu zpb0+t+a1&GV-dTB;Z$hBKMMYb=cswZ2UA$JQh9V5MaE|c9Hb1ss!o12nWS@?rpHa5 zcut>M8=ROJ&|!^{H)gRHE;EmH!Uo`0TmL;d{hQ~catWq#$t7~jr9%GAa|B}F$tG@@ zr}Da`3gl$Ex8;7=FA#fQO!=RlV;ddi8lC7GlPeHg{NGFea>9Rkj)o7>q07LKhGt?vm;lN_5bHRYRfmY+c^8k?ET9JmjniH%0)z)O zBZZ<>LeZ-Iss7J$Ry`uAX(B13KNA>m!D!k9G%fWHUfncG-I8i3DZ)zfKYuQG89}5B z{>e+{D`12K8T^wMVMHh?J(P*w|D4nRr||zJ@c)zm!Nft7gdaV+h*KDhmjwY2H@j0g zw5Fu+4q8W2f$XZ(8=CY|mvb-u%R30pcT4~%UM`2#OI8WMT(dHl7N&WFF-{MY#6c*&qx_<$!3FS@k3 zJ3-~J@h^(A5rk>o4QwQ(M-V&lh1jKCwM8fmU5B&*4j!QUt!(CY5Mk;sud04B>Mb(* zg)mF&8Dno`ZJKB9#uN+WmRGlnjm}6-((28cVCVgBxDr^DyH!^ow2MjU!3pcvMI%G* zP7EOetA5}9gnW|rA`-;<-&iItcFnz@D?Hl$MRocD(TORr;5W%9B1D8&!V=cE3@1E+ z8lj^I5g}ZGorCyF6%Z#7>?s4QCmn?}S_x+~FdmNKZxV=K1Pt4`&nF z_{$s#czJ0Uc|zL+ArA5*pp>R#(0iEO8?zv8Rt=ymWuH}4&?fNRxA6{=0T}mnTjU# zVD_l%XS6W(5M5yO_}Io+;y@fc|m^Q*5@{j3F5Vl?f{gg2}5X_7tf(i{SDup5j2&VTNfYxOC7oaRX6*KZLfB zLPYyVd#M-dr$YbPH1b~1=lugP)V`qq55NT@ZNZES+>65Ryoyk|==P`a58#3k0p}lh z1V)7F?;aN;`rTRRPnEE}Bf6OVxP?Uf^d+Atf;He0x~ShiOW2`N5hME-ENh@XBU0oDk4#+EITa|{UIAejuDZIh)3|ZL9pmSM#7XG zMmRK}mY{{gQK6yafGkQg84Qq;k+b!xp-^I+CEbNyy0ZNF(g2_wJw_E1-Oqt2Vipi8 zC&Kp=N8JO4rj|52p5w)ctf%^KTtY+9RAJ_#3_i7Q>Ok6MgiXldprz2I@)zdR$lQM3azTHsjaJTNSNM0 zzhgHP0D?mQY&b9KQ0SjC4%9!+J}%BUE|$fk;%-+fE6a2DbK7&@bMNz8=RxOp&+nfH zoZmjTKDRk{KKD5fJ$EI*?a%$s1J6SUc_abv{<+8b9UE)g^Q7~8gobbeAmKdhJe7ca z>pX%$B=G#sx!1XaHRz!WQ^jn;XCc`+zr2x$wRbkxA6Q?N?4Ju%-Et9@d2GEp@N&7g z&}t6XG3m4u_~1J^JzEK61al$UQ)8{CxHyR* z*!p`^T9twzy+udIdr^HpaA(o_$a^lHG@B^2<$HB3g8233sHfo5&XkX0?*4q|Sep|5 zuI9d6Jb!@vn?}*ctDk=E^OgTKhZ=dueO-`pIQ|TK zQKD2PI=8}_I2xHSL~_Md);lvDkdlcw1dMGRH0@YOiRI+<5d)^!+IFa^VazgL^@k5l zmN-%838PMjVn(grswlU2?NReR7-}7(IK$~_t-yls8s?TGX?i3OH6b;)Dzi(MwnT1! zRB191V6Ss{nWYJ4^vI4|ouet&Niq|uQ{`5JGo~khyA+YWn%x$eNxQDL{=8L*R?I4@8BjiHYo}gA4Q}_9ag!vC9`1U~bb7Oc zeI*~u#}5;$ygnUJYA|1N{MR&;uwu$$NmRHx9Y*y@g4tk0SeuA)>RIZP zJZe~@G?W9M(LseaqmfYNEPGl^ewFyR$$1RuP6^k61J^XRUQ@xtD7xCYb_X}8JdbWx zE)K5r7+ud@aUPeAlNfNaE(QQ{Y(shPoXDxUNR(ca2?g0?A4#v&faiF--P=eka*ot~ z%T&pip&KIG)-QQEKAEmZ9ww))hb2)4k*49SmxpE=Mo75BT4T)W!z5bu+!MkEssb_* z%8V^{`%g9|m|H%Jzb|H^!+q}V;Jc9}#LTLWYk4JIsa(f9VPpKBT0qwcUdH^jb{bP+ zQZ=KsG}k`JO%x!9m&NLv719|1)hdx-sJlRjsp_6jaz7+@nm!h_B-bnI@kVnVCIkbd0NVld)cEe&nQcI6xIP8i~ zOAD1UwE=v{@CvZpM_=zpZs!Dj9=j?6jR6>t2}=-lvbP-Jcm4# zu~b}MTQ}kuiiaeVA5tp__{fztsq35_di_Wb{_g+L$SIAf{Uw7(d4Bt*2@^h7y7Q-h z<550ezm58hlG}OM31+z>oBlNSRFJ&%jbAqA4EAs5nC^Gw)z97-RW6_!YS|9X^wPo& zqyg^?Gu0gHN=4Kx^=Q;;gcLbw{b8y0cunJa*3b*)pa+^obML4xDvb>H zUV;EqCCyeO?{$T=q=tdE7(`q=!2|FDc<9MVt28AaW99yylqXJv1FmRe%23(oeem_2 zM(v7a?0$~pEoPDh1$<7VKBZd0Fbf18Gvrr_3v1;XQ4i&|8-2^c69ECSeK*RXh=S-p zBq@pQBsm;s9-#1l1hYN>lG43+sziwTdNn@^W16P)^eiOB+8?jF>4-MeDv(zw1dO0Rlnes*I8Hq zzgRvR-4*3uMn0s z+edDG0aZ!8vU+g{=do;65MaoJCTRo!FVOuE(3=QU^9;*Nj#2r%vcEA@j}_CyMwg)W z0?X2sYOS2%8TieF$soSl;UnM`^{6k3iwD)-ZT%1ll5S`4C8#3?hy6f9MiYtap#wO- zlC}(_jN5bKeax0^KnX|je3uj>S7KG(fnHdL8?6L(;N7kUXD(^QX>A`)wC0sk>A)cG zky?|b*(qfT3x>xw_dm*uSv1`qGZf5GxFavbpQdL)49_3;F)sfAJbRo}+!?7^T*1s| z`B7vcX`^XBWBDhkFqCY7eBnSE);~k@b$V8)`f@HjUW(6HfBUa_KdbKwvafl0Shw>g zNjAUU6u6qRp1BJJl4CBtccOrA6W=EW?$xxyWI=^u9WG z9Ls#Oi4_^*&iE9)x@l`GX~33_to0y?R~T*cWAjX$ zLU2sxi0#k){J931ILi`#U5S#@MaktRt<>h-*i7b@zd!2y9lzGP`S4jqrgY2V@RLB| z;I*DKWrNhiGj5u*V|i9`xsV^0KO5O(2kP^04!OGV85>M*`I0JKngdvApZ#_6nuTA& z1W3x~XRrOCK~ZVi{cH3JPMkl)M$h@3UnlOMFYWHf2VCD(@Q5WLj#>P-PcfJ;$t{Pf zfc~#DD#mkO6bVVUhVZ$H0|_OrkY4EflXXg=!26=t_xp5bZ&)+FHwB;t8a(1zjl*^Z zbDfG@J=Q&Z59N$PZbxvrtB=IkBQ`8+1Knz3(vy1rpa zm?YND;pSE8L_e19>ZNR@2&`{?=o}oT*Q5rSYv}W&VB6L*f9~g5BEcc@g^R4!j)pWp zQbl$Gg(ib=AT4gsse6MhnVTbE>PW$!SCS(v>Hf@#N(LGpSn$k*q$=0U*+ky5;Xnt$ci=mg*k8Ly!aV9azitowQx7G|y9XAcVdtRkg zIkd~pR|uEgR5Y^5kqlQdVK`W4GuE^x$<_rVI_lr#v!4CkinxV*STQ2a*#0=!-PyV3 zanl|>Moi-_O4A*7nsLk{eUpgeb60^tJSkQ4XAcL$(RUp6i`MhtR>D5}QWfc24uP^{ zfWlYVR*DED2@n?wKGvlw94d|kBW1TkX5FQ^n_*tLlM(kwQ#v%@7#xmbBlzg%552J1 z%K*8J##UuU+?>>!gjC`=NHm$d1q~D4QA)A0)!Z1AU+1n zQC`!4u09WABXnf~S;$gtd7Jv%>sIY6DcZ`^2G|c5;%i?dzcQ4PQ805&HZs*9 zEuSV4O$T99#VV;Xj4aaaf-XgQ+Yz_F7ZuGY|UtMi=VI z2j31EdBkXNzOCYaPL+gi6G%dm2XDo6B&2T#+U2$92~v`&yt&cKY-+POb^1l=Ku{&% zmHc7;N8K@Gt4s9)8=FJ>qxZ$SFPs-q8s<;=H=++mCe<^Lh653c{v9Whs*~c&Dw_)q zORMYpqQR^I!PiCVSLuGa1>Jgt;J=yJJ{(Z!kevLY-h4;t@MVBpzBwj$9r58Wty!#=P?gbVgmJKwe^s*y~7I@WnE<$B7COms|!!qzJ zq!cw98GVyJ9qJ1nL2HxYGeA(_n%NeTVLWy?r!vG>RV{5=gefoPeQ>CNHrxrjq~Tss z7SP%<)SojZDHaP9GCBw}TI#&tgJK_<=jI|-C72MIrnOACe*5}yQH`8Rx2gq=#LzO1 zA*cXGH2nC{!%WSY%%uTJqgi4TG0Ipn0SdXp)T^2~?>UBx67!r~6LJ{;D^*Y&`I#FG^A8B@>N?z*W0Bd5c9_jZy0U zg?7cj3DPfOwmUQ~_d*u;?q>z#lB(@Rp782#m1_81BSxJbpK+`0H~Q zsxJ7}11>(n_T)RIPXhDgu__dSjdff<`aLERC$?x`YMHJRr788Qt|e0hgW6pk=xieE zsZ$|bvPRLldDSUAS51xd-Np1oXS%aI5+mM*zAbn10E zc|1=y{#v*!zU>;@@l8|WZsu(r~sN<?G_#M`v$7H=K-U$lGmRhLLhdeEy;= z095+P?s$j*PR8qzU|N0mq;qv!aSkR|#}A6HZGmg%j>~$njKCY)6z4b3o9=_}$p}~2 zJpKXf!s;=`tIf?P13U=CK!PtAsIScZ{Q2~=9&ogBs60B~u) zt1%M^ThpaHt#^Lv%0$%4{R+_$x!>%G<&1~eIp>)L$s~rNRWmRY$#{1@BLkjjck!a0 zv18Rc6TES}aft7YjOJ0JZDSTkC(ac;6jkHzjg5f4f|ZnGaeFZ>Z67mZCODG_Sf@mv*o5y4_9%6PEP-5QN)}7Dkg@ z$p=a$QEGl0a*TjLfps&-DZOVI_ivJ#hJ>h*y!K<+pQyIo3|0{k8jg;}NDu6SIf zhw~W7>2s7yiva)+HIZU_H46G^DHh6w7$Ilt{Q2hkW=1b2JN~H1Y^J`M_j`Rk+JdRo zuGtYJV#m@Fy&|N0lHBB^q>?Z6FB?@yyp1qgO#wnzRMZDvjn3&(xrUSEo~qI)*T$MH zqbzb5;H@1dRtdu}52@?5UCu2b5L zLoxiG4O9NyC~3&JaZv?0j=o;h_bLBa;bTl=vlkc!)&}`r;+R%?VB%q?F|J$4b5(QL zVEWUDQ1@nMDE+$1{Amy?j7A@Dy`M>5=vFd%_CqpMo{zdqmNo{+?s^tR>J>h#3}%mx zSjx&Gc__I-!p&UW3Sv`KEWF2>Fwm!eSR2feovXbE9R)&VCWd!)ov*p%?DY0RBgJC8tfAPBQV#jNV{* zqx!bRRaS$vlrS-Op}w>jo~U=b+}rx@0ySxQdkwwRRen5G2akU}%38}{$Z8(EBS$;l zaFYMXp{H!6u>4IWfAuTx=j{Pxj<~+j#V4NrA)U|ne?+Y5VLaZHFEDY)+5J6dpz$0Y za31usdu*<~Ln~8~JpGr)=2xGjiq-7e_@liuiI+D<-h!?$fu*SVso;>IWH5OdyBHJP zHp5z&+E73a{OdM!oy@Q3OfYqP5~`DH75&N54b;?q>rq&pI}u-cC=(-45a`J4@|RcB zD;-4%il)9}AClL%0bp5UNyEiumGXAO08>zocN*a;P)c%2Se-+LYgQF`FSDcwiH_h~ z>i#1El&tC_*$=u%I?RataCVPRe1jo32@#g4NaQp9RaEn^VSQ8}IVJW~9Irc7lrT@M ziW!8Hl$KG&^DKzx@3VR}N&-5nkMMAj&wveclFKya<5W{1pT_7+!I1d+bK&E)j4WTD zTXHN*Zi1FhQy9MtTH=I6&`6G1U&b;5xsI+7$_jfzE;O4HduOisR>ep%z8y2h`;=uh zxj)9Gre;A4!+*cTcx^D^vj^R4b{(0Jr*vIJUvaf9c^`13NGDr4@h{A`6&#-3d(3Wd z`%F-LxuV)$WwxSuF5=UMPS2;m-jsW;rk}9R2}It*@stT^bEO(*cxi%})Vr1W=3MsB zDb-ZsXxv#Y5?-BHRE8Gh66Eq$as@`7s)BKv)3<1yExS~TEU9Jv`#y)u8v>ey*|;<( zX}4-MzfFtu&ZQVB?=t>f6QZ{4b77$S+Rg_?9E-66S4H1ASPKqN@oL5xQhh>_eNUH* z=eBcpd)-(6*dk{|+p0+jkM(#j?SM2IZoGxnOB<1#t#VtMBc`JUTu4K*C(|P8KymZw zAdnyY%WCYU4e@0@Mv!f6^owTYo~01-0g*@<@h%0DBWPic(HcmNz#y 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: Cursor { + Log.d(TAG, "queryRoots") + throw NotImplementedError() + } + + override fun queryChildDocuments(parentDocumentId: String, projection: Array, sortOrder: String): Cursor { + Log.d(TAG, "queryChildDocuments") + throw NotImplementedError() + } + + override fun queryDocument(documentId: String, projection: Array): Cursor { + Log.d(TAG, "queryDocument") + throw NotImplementedError() + } + + override fun openDocument(documentId: String?, mode: String?, cancellationSignal: CancellationSignal?): ParcelFileDescriptor { + Log.d(TAG, "openDocument") + throw NotImplementedError() + } + + override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String { + Log.d(TAG, "createDocument($parentDocumentId, $mimeType, $displayName") + throw NotImplementedError() + } +} +*/ diff --git a/app/src/androidTest/kotlin/org/fossify/voicerecorder/RecordingStoreTest.kt b/app/src/androidTest/kotlin/org/fossify/voicerecorder/RecordingStoreTest.kt new file mode 100644 index 00000000..527d7ed3 --- /dev/null +++ b/app/src/androidTest/kotlin/org/fossify/voicerecorder/RecordingStoreTest.kt @@ -0,0 +1,182 @@ +package org.fossify.voicerecorder + +import android.content.ContentResolver +import android.content.Context +import android.database.ContentObserver +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.HandlerThread +import android.provider.MediaStore +import androidx.test.platform.app.InstrumentationRegistry +import org.fossify.voicerecorder.helpers.RecordingStore +import org.fossify.voicerecorder.models.RecordingFormat +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.FileOutputStream +import java.util.concurrent.CountDownLatch + +class RecordingStoreTest { + companion object { + private const val MOCK_PROVIDER_AUTHORITY = "org.fossify.voicerecorder.mockprovider" + private val DEFAULT_MEDIA_URI = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + private const val TAG = "RecordingStoreTest" + } + + @Before + fun setup() { + deleteTestMedia() + } + + @After + fun teardown() { + deleteTestMedia() + } + +// @Test +// fun createRecording_SAF() { +// } + + @Test + fun createRecording_MediaStore() { + val store = RecordingStore(context, DEFAULT_MEDIA_URI) + + val name = makeTestMediaName("sample") + val uri = store.createRecording(name, RecordingFormat.OGG) + + val recordings = store.getAll() + val recording = recordings.find { it.uri == uri } + assertNotNull(recording) + + val size = getSize(uri) + assertTrue(size > 0) + } + + @Test + fun trashRecording_MediaStore() { + val store = RecordingStore(context, DEFAULT_MEDIA_URI) + + val name = makeTestMediaName("sample") + val uri = store.createRecording(name, RecordingFormat.OGG) + + val recording = store.getAll().find { it.uri == uri }!! + + assertFalse(store.getAll(trashed = true).any { it.title == recording.title }) + + store.trash(listOf(recording)) + + assertFalse(store.getAll(trashed = false).any { it.title == recording.title }) + assertTrue(store.getAll(trashed = true).any { it.title == recording.title }) + } + + @Test + fun restoreRecording_MediaStore() { + val store = RecordingStore(context, DEFAULT_MEDIA_URI) + + val uri = store.createRecording(makeTestMediaName("sample"), RecordingFormat.OGG) + val recording = store.getAll(trashed = false).find { it.uri == uri }!! + + store.trash(listOf(recording)) + val trashedRecording = store.getAll(trashed = true).find { it.title == recording.title }!! + + store.restore(listOf(trashedRecording)) + assertTrue(store.getAll(trashed = false).any { it.title == recording.title }) + assertFalse(store.getAll(trashed = true).any { it.title == recording.title }) + } + + private val context: Context + get() = instrumentation.targetContext + + private val instrumentation + get() = InstrumentationRegistry.getInstrumentation() + + private fun makeTestMediaName(name: String): String = "$name$testMediaSuffix.${System.currentTimeMillis()}" + + private fun deleteTestMedia() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val queryArgs = Bundle().apply { + putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE) + putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Audio.Media.DISPLAY_NAME} LIKE ?") + putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf("%$testMediaSuffix%")) + } + + context.contentResolver.delete(DEFAULT_MEDIA_URI, queryArgs) + } else { + context.contentResolver.delete(DEFAULT_MEDIA_URI, "${MediaStore.Audio.Media.DISPLAY_NAME} LIKE ?", arrayOf("%$testMediaSuffix%")) + } + } + + private val testMediaSuffix + get() = ".${context.packageName}.test" + + private val contentObserverHandler = Handler(HandlerThread("contentObserver").apply { start() }.looper) + + private fun RecordingStore.createRecording(name: String, format: RecordingFormat): Uri { + val inputFd = when (format) { + RecordingFormat.M4A -> TODO() + RecordingFormat.MP3 -> TODO() + RecordingFormat.OGG -> instrumentation.context.assets.openFd("sample.ogg") + } + + val inputSize = inputFd.length + val input = inputFd.createInputStream() + + val uri = createWriter(name, format).run { + input.use { input -> + FileOutputStream(fileDescriptor.fileDescriptor).use { output -> + input.copyTo(output) + } + } + + commit() + } + + // HACK: Wait until the recording reaches the expected size. This is because sometimes the recording has not been fully written yet at this point for + // some reason. This prevents some subsequent operations on the recording (e.g., move to trash) to fail. + waitUntilSize(uri, inputSize) + + return uri + } + + // Waits until the document/media at the given URI reaches the expected size + private fun waitUntilSize(uri: Uri, expectedSize: Long) { + val latch = CountDownLatch(1) + val observer = object : ContentObserver(contentObserverHandler) { + override fun onChange(selfChange: Boolean) { + super.onChange(selfChange) + + if (getSize(uri) >= expectedSize) { + latch.countDown() + } + } + } + + context.contentResolver.registerContentObserver(uri, false, observer) + + if (getSize(uri) < expectedSize) { + latch.await() + } + + context.contentResolver.unregisterContentObserver(observer) + } + + private fun getSize(uri: Uri): Long = when (uri.authority) { + MediaStore.AUTHORITY -> { + val projection = arrayOf(MediaStore.Audio.Media.SIZE) + context.contentResolver.query(uri, projection, null, null, null)?.use { cursor -> + val iSize = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE) + if (cursor.moveToNext()) { + cursor.getLong(iSize) + } else { + null + } + } ?: 0 + } + else -> TODO() + } +} 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 6f39a49a..5a094b74 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt @@ -28,8 +28,7 @@ class RecordingsAdapter( private val refreshListener: RefreshRecordingsListener, recyclerView: MyRecyclerView, itemClick: (Any) -> Unit -) : MyRecyclerViewAdapter(activity, recyclerView, itemClick), - RecyclerViewFastScroller.OnPopupTextUpdate { +) : MyRecyclerViewAdapter(activity, recyclerView, itemClick), RecyclerViewFastScroller.OnPopupTextUpdate { var currRecordingId = 0 @@ -85,9 +84,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) } @@ -119,10 +116,7 @@ class RecordingsAdapter( private fun openRecordingWith() { val recording = getItemWithKey(selectedKeys.first()) ?: return activity.openPathIntent( - path = recording.uri.toString(), - forceChooser = true, - applicationId = BuildConfig.APPLICATION_ID, - forceMimeType = "audio/*" + path = recording.uri.toString(), forceChooser = true, applicationId = BuildConfig.APPLICATION_ID, forceMimeType = "audio/*" ) } @@ -149,9 +143,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 @@ -170,14 +162,12 @@ class RecordingsAdapter( } val oldRecordingIndex = recordings.indexOfFirst { it.id == currRecordingId } - val recordingsToRemove = recordings - .filter { selectedKeys.contains(it.id) } - .toList() + val recordingsToRemove = recordings.filter { selectedKeys.contains(it.id) }.toList() val positions = getSelectedItemPositions() - activity.recordingStore.delete(recordingsToRemove) { success -> - if (success) { + ensureBackgroundThread { + if (activity.recordingStore.delete(recordingsToRemove)) { doDeleteAnimation(oldRecordingIndex, recordingsToRemove, positions) } } @@ -189,14 +179,12 @@ class RecordingsAdapter( } val oldRecordingIndex = recordings.indexOfFirst { it.id == currRecordingId } - val recordingsToRemove = recordings - .filter { selectedKeys.contains(it.id) } - .toList() + val recordingsToRemove = recordings.filter { selectedKeys.contains(it.id) }.toList() val positions = getSelectedItemPositions() - activity.recordingStore.trash(recordingsToRemove) { success -> - if (success) { + ensureBackgroundThread { + if (activity.recordingStore.trash(recordingsToRemove)) { doDeleteAnimation(oldRecordingIndex, recordingsToRemove, positions) EventBus.getDefault().post(Events.RecordingTrashUpdated()) } @@ -204,9 +192,7 @@ class RecordingsAdapter( } private fun doDeleteAnimation( - oldRecordingIndex: Int, - recordingsToRemove: List, - positions: ArrayList + oldRecordingIndex: Int, recordingsToRemove: List, positions: ArrayList ) { recordings.removeAll(recordingsToRemove.toSet()) activity.runOnUiThread { @@ -242,10 +228,7 @@ class RecordingsAdapter( recordingFrame.isSelected = selectedKeys.contains(recording.id) arrayListOf( - recordingTitle, - recordingDate, - recordingDuration, - recordingSize + recordingTitle, recordingDate, recordingDuration, recordingSize ).forEach { it.setTextColor(textColor) } diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/TrashAdapter.kt b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/TrashAdapter.kt index 12002882..7254fc03 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/TrashAdapter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/TrashAdapter.kt @@ -19,12 +19,8 @@ import org.fossify.voicerecorder.models.Recording import org.greenrobot.eventbus.EventBus class TrashAdapter( - activity: SimpleActivity, - var recordings: ArrayList, - private val refreshListener: RefreshRecordingsListener, - recyclerView: MyRecyclerView -) : - MyRecyclerViewAdapter(activity, recyclerView, {}), RecyclerViewFastScroller.OnPopupTextUpdate { + activity: SimpleActivity, var recordings: ArrayList, private val refreshListener: RefreshRecordingsListener, recyclerView: MyRecyclerView +) : MyRecyclerViewAdapter(activity, recyclerView, {}), RecyclerViewFastScroller.OnPopupTextUpdate { init { setupDragListener(true) @@ -67,9 +63,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) } @@ -93,14 +87,12 @@ class TrashAdapter( return } - val recordingsToRestore = recordings - .filter { selectedKeys.contains(it.id) } - .toList() + val recordingsToRestore = recordings.filter { selectedKeys.contains(it.id) }.toList() val positions = getSelectedItemPositions() - activity.recordingStore.restore(recordingsToRestore) { success -> - if (success) { + ensureBackgroundThread { + if (activity.recordingStore.restore(recordingsToRestore)) { doDeleteAnimation(recordingsToRestore, positions) EventBus.getDefault().post(Events.RecordingTrashUpdated()) } @@ -131,22 +123,19 @@ class TrashAdapter( return } - val recordingsToRemove = recordings - .filter { selectedKeys.contains(it.id) } - .toList() + val recordingsToRemove = recordings.filter { selectedKeys.contains(it.id) }.toList() val positions = getSelectedItemPositions() - activity.recordingStore.delete(recordingsToRemove) { success -> - if (success) { + ensureBackgroundThread { + if (activity.recordingStore.delete(recordingsToRemove)) { doDeleteAnimation(recordingsToRemove, positions) } } } private fun doDeleteAnimation( - recordingsToRemove: List, - positions: ArrayList + recordingsToRemove: List, positions: ArrayList ) { recordings.removeAll(recordingsToRemove.toSet()) activity.runOnUiThread { @@ -170,10 +159,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 e7860649..7e09e837 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/MoveRecordingsDialog.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/MoveRecordingsDialog.kt @@ -55,13 +55,12 @@ class MoveRecordingsDialog( private fun moveAllRecordings() = ensureBackgroundThread { activity.recordingStore.let { store -> - store.move(store.getAll(), oldFolder, newFolder) { - activity.runOnUiThread { - callback() - dialog.dismiss() - } + store.move(store.getAll(), oldFolder, newFolder) + + activity.runOnUiThread { + callback() + dialog.dismiss() } } } - } 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 0b413dbf..7a78d441 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 android.os.Build +import android.os.Environment import org.fossify.voicerecorder.models.RecordingFormat const val REPOSITORY_NAME = "Voice-Recorder" @@ -102,5 +104,10 @@ 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" +val DEFAULT_RECORDINGS_FOLDER = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Environment.DIRECTORY_RECORDINGS +} else { + "Recordings" +} + const val DEFAULT_FILENAME_PATTERN = "%Y%M%D_%h%m%s" diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingStore.kt b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingStore.kt index b8faca42..cb8b577a 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingStore.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingStore.kt @@ -1,11 +1,14 @@ package org.fossify.voicerecorder.helpers +import android.content.ContentResolver +import android.content.ContentUris +import android.content.ContentValues import android.content.Context import android.content.pm.ProviderInfo import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build -import android.os.Environment +import android.os.Bundle import android.provider.DocumentsContract import android.provider.MediaStore import android.util.Log @@ -24,14 +27,18 @@ class RecordingStore(private val context: Context, val uri: Uri) { private const val TAG = "RecordingStore" } + init { + require(uri.scheme == ContentResolver.SCHEME_CONTENT) { "Invalid URI '$uri' - must have 'content' scheme" } + } + enum class Kind { DOCUMENT, MEDIA; companion object { fun of(uri: Uri): Kind = if (uri.authority == MediaStore.AUTHORITY) { - Kind.MEDIA + MEDIA } else { - Kind.DOCUMENT + DOCUMENT } } } @@ -46,13 +53,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { documentId.substringAfter(":").trimEnd('/') } - Kind.MEDIA -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - Environment.DIRECTORY_RECORDINGS - } else { - DEFAULT_RECORDINGS_FOLDER - } - } + Kind.MEDIA -> DEFAULT_RECORDINGS_FOLDER } /** @@ -63,67 +64,182 @@ class RecordingStore(private val context: Context, val uri: Uri) { } /** - * Are there any recordings in this store? + * Are there no recordings in this store? */ - fun isNotEmpty(): Boolean = when (kind) { - Kind.DOCUMENT -> DocumentFile.fromTreeUri(context, uri)?.listFiles()?.any { it.isAudioRecording() } == true - Kind.MEDIA -> false // TODO + fun isEmpty(): Boolean = when (kind) { + Kind.DOCUMENT -> DocumentFile.fromTreeUri(context, uri)?.listFiles()?.any { it.isAudioRecording() } != true + Kind.MEDIA -> true } + /** + * Are there any recordings in this store? + */ + fun isNotEmpty(): Boolean = !isEmpty() + /** * Returns all recordings in this store. */ fun getAll(trashed: Boolean = false): List = when (kind) { - Kind.DOCUMENT -> { - val parentUri = if (trashed) { - trashFolder + Kind.DOCUMENT -> getAllDocuments(trashed) + Kind.MEDIA -> getAllMedia(trashed) + } + + private fun getAllDocuments(trashed: Boolean): List { + val parentUri = if (trashed) { + trashFolder + } else { + uri + } + + return parentUri?.let { DocumentFile.fromTreeUri(context, it) }?.listFiles()?.filter { it.isAudioRecording() }?.map { readRecordingFromFile(it) } + ?.toList() ?: emptyList() + } + + private fun getAllMedia(trashed: Boolean): List { + val projection = arrayOf( + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.DATE_MODIFIED, + MediaStore.Audio.Media.DISPLAY_NAME, + MediaStore.Audio.Media.DURATION, + MediaStore.Audio.Media.SIZE, + ) + + + val cursor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val queryArgs = if (trashed) { + Bundle().apply { + putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY) + } + } else { + null + } + + context.contentResolver.query(uri, projection, queryArgs, null) + } else { + val selection: String + val selectionArgs: Array + + if (trashed) { + selection = "${MediaStore.Audio.Media.DISPLAY_NAME} LIKE ?" + selectionArgs = arrayOf("$TRASHED_PREFIX%") } else { - uri + selection = "${MediaStore.Audio.Media.DISPLAY_NAME} NOT LIKE ?" + selectionArgs = arrayOf("$TRASHED_PREFIX%") } - parentUri?.let { DocumentFile.fromTreeUri(context, it) }?.listFiles()?.filter { it.isAudioRecording() }?.map { readRecordingFromFile(it) }?.toList() - ?: emptyList() + context.contentResolver.query(uri, projection, selection, selectionArgs, null) } - Kind.MEDIA -> { - // TODO - emptyList() + val result = mutableListOf() + + cursor?.use { cursor -> + val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) + val timestampIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_MODIFIED) + val nameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) + val durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) + val sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE) + + while (cursor.moveToNext()) { + val id = cursor.getLong(idIndex) + val name = cursor.getString(nameIndex) + val size = cursor.getInt(sizeIndex) + val timestamp = cursor.getLong(timestampIndex) + val duration = cursor.getInt(durationIndex) + + val rowUri = ContentUris.withAppendedId(uri, id) + + result.add( + Recording( + id = id.toInt(), + title = name, + uri = rowUri, + timestamp = timestamp, + duration = duration, + size = size, + ) + ) + } } + + return result } - fun trash( - recordings: Collection, callback: (success: Boolean) -> Unit - ) = move( - recordings, uri, getOrCreateTrashFolder()!!, callback - ) + fun trash(recordings: Collection): Boolean { + val (documents, media) = recordings.partition { Kind.of(it.uri) == Kind.DOCUMENT } + var success = true - fun restore( - recordings: Collection, callback: (success: Boolean) -> Unit - ) { - val sourceParent = trashFolder - if (sourceParent == null) { - callback(true) - return + if (documents.isNotEmpty()) { + success = success and moveDocuments(documents, uri, getOrCreateTrashFolder()!!) } - move( - recordings, sourceParent, uri, callback - ) + if (media.isNotEmpty()) { + success = success and updateMediaTrashed(media, trash = true) + } + + return success + } + + fun restore(recordings: Collection): Boolean { + val (documents, media) = recordings.partition { Kind.of(it.uri) == Kind.DOCUMENT } + var success = true + + if (documents.isNotEmpty()) { + trashFolder?.let { sourceParent -> + success = success and move(recordings, sourceParent, uri) + } + } + + if (media.isNotEmpty()) { + success = success and updateMediaTrashed(media, trash = false) + } + + return success + } + + private fun updateMediaTrashed(recordings: Collection, trash: Boolean): Boolean { + val contentResolver = context.contentResolver + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val values = ContentValues().apply { + put(MediaStore.Audio.Media.IS_TRASHED, if (trash) 1 else 0) + } + + for (recording in recordings) { + contentResolver.update(recording.uri, values, null, null) + } + } else { + for (recording in recordings) { + val newName = if (trash) { + "${TRASHED_PREFIX}${recording.title}" + } else { + recording.title.removePrefix(TRASHED_PREFIX) + } + + val values = ContentValues().apply { + put(MediaStore.Audio.Media.DISPLAY_NAME, newName) + } + + contentResolver.update(recording.uri, values, null, null) + } + } + + return true } fun deleteTrashed( callback: (success: Boolean) -> Unit = {} - ) = delete(getAll(trashed = true), callback) + ) = ensureBackgroundThread { callback(delete(getAll(trashed = true))) } + + fun move(recordings: Collection, sourceParent: Uri, targetParent: Uri):Boolean { + // TODO: handle media + return moveDocuments(recordings, sourceParent, targetParent) + } - fun move( - recordings: Collection, sourceParent: Uri, targetParent: Uri, callback: (success: Boolean) -> Unit - ) = ensureBackgroundThread { + private fun moveDocuments(recordings: Collection, sourceParent: Uri, targetParent: Uri): Boolean { val contentResolver = context.contentResolver val sourceParentDocumentUri = ensureParentDocumentUri(context, sourceParent) val targetParentDocumentUri = ensureParentDocumentUri(context, targetParent) - Log.d(TAG, "move src: $sourceParent -> $sourceParentDocumentUri, dst: $targetParent -> $targetParentDocumentUri") - if (sourceParent.authority == targetParent.authority) { for (recording in recordings) { @@ -133,7 +249,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { DocumentsContract.moveDocument( contentResolver, recording.uri, sourceParentDocumentUri, targetParentDocumentUri ) - } catch (@Suppress("SwallowedException") e: IllegalStateException) { + } catch (@Suppress("SwallowedException") _: IllegalStateException) { moveFallback(recording.uri, targetParentDocumentUri) } } @@ -143,26 +259,20 @@ class RecordingStore(private val context: Context, val uri: Uri) { } } - callback(true) + return true } - fun delete( - recordings: Collection, callback: (success: Boolean) -> Unit = {} - ) = ensureBackgroundThread { - when (kind) { - Kind.DOCUMENT -> { - val resolver = context.contentResolver - recordings.forEach { - DocumentsContract.deleteDocument(resolver, it.uri) - } - } + fun delete(recordings: Collection): Boolean { + val resolver = context.contentResolver - Kind.MEDIA -> { - TODO() + recordings.forEach { + when (Kind.of(it.uri)) { + Kind.DOCUMENT -> DocumentsContract.deleteDocument(resolver, it.uri) + Kind.MEDIA -> resolver.delete(it.uri, null, null) } } - callback(true) + return true } fun createWriter(name: String, format: RecordingFormat): RecordingWriter = RecordingWriter.create(context, uri, name, format) @@ -232,6 +342,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { } private const val TRASH_FOLDER_NAME = ".trash" +private const val TRASHED_PREFIX = ".trashed-" private fun ensureParentDocumentUri(context: Context, uri: Uri): Uri = when { DocumentsContract.isDocumentUri(context, uri) -> uri diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingWriter.kt b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingWriter.kt index 0fdf96f3..ed173fdc 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingWriter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingWriter.kt @@ -1,10 +1,13 @@ package org.fossify.voicerecorder.helpers import android.content.ContentResolver +import android.content.ContentValues import android.content.Context import android.net.Uri +import android.os.Build import android.os.ParcelFileDescriptor import android.provider.DocumentsContract +import android.provider.MediaStore import org.fossify.voicerecorder.models.RecordingFormat import java.io.File import java.io.FileInputStream @@ -17,18 +20,18 @@ import java.io.FileInputStream */ sealed class RecordingWriter { companion object { - fun create(context: Context, parentTreeUri: Uri, name: String, format: RecordingFormat): RecordingWriter { - val direct = DIRECT_FORMATS.contains(format) or DIRECT_AUTHORITIES.contains(parentTreeUri.authority) + fun create(context: Context, parentUri: Uri, name: String, format: RecordingFormat): RecordingWriter { + val direct = DIRECT_FORMATS.contains(format) or DIRECT_AUTHORITIES.contains(parentUri.authority) if (direct) { - val uri = createDocument(context, parentTreeUri, name, format) + val uri = createDocument(context, parentUri, name, format) val fileDescriptor = requireNotNull(context.contentResolver.openFileDescriptor(uri, "w")) { "failed to open file descriptor at $uri" } return Direct(context.contentResolver, uri, fileDescriptor) } else { - return Workaround(context, parentTreeUri, name, format) + return Workaround(context, parentUri, name, format) } } @@ -37,7 +40,7 @@ sealed class RecordingWriter { private val DIRECT_FORMATS = arrayOf(RecordingFormat.MP3) // Document providers not affected by the MediaStore bug - private val DIRECT_AUTHORITIES = arrayOf("com.android.externalstorage.documents") + private val DIRECT_AUTHORITIES = arrayOf("com.android.externalstorage.documents", MediaStore.AUTHORITY) } /** @@ -103,19 +106,30 @@ sealed class RecordingWriter { } } -private fun createDocument(context: Context, parentTreeUri: Uri, name: String, format: RecordingFormat): Uri { - val parentDocumentUri = buildParentDocumentUri(parentTreeUri) +private fun createDocument(context: Context, parentUri: Uri, name: String, format: RecordingFormat): Uri { val displayName = "$name.${format.getExtension(context)}" - val uri = requireNotNull( + + val uri = if (parentUri.authority == MediaStore.AUTHORITY) { + val values = ContentValues().apply { + put(MediaStore.Audio.Media.DISPLAY_NAME, displayName) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.Audio.Media.RELATIVE_PATH, DEFAULT_RECORDINGS_FOLDER) + } + } + + context.contentResolver.insert(parentUri, values) + } else { + val parentDocumentUri = buildParentDocumentUri(parentUri) DocumentsContract.createDocument( context.contentResolver, parentDocumentUri, format.getMimeType(context), displayName, ) - ) { - "failed to create document '$displayName' in $parentDocumentUri" } - return uri + return requireNotNull(uri) { + "failed to create document '$displayName' in $parentUri" + } } 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 14e3be05..d3b449ac 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt @@ -75,10 +75,8 @@ class RecorderService : Service() { return } - val recordingFolder = config.saveRecordingsFolder ?: return val recordingFormat = config.recordingFormat - try { recorder = when (recordingFormat) { RecordingFormat.M4A, RecordingFormat.OGG -> MediaRecorderWrapper(this) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c1e8ad7c..0767ce8b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ detektCompose = "0.4.28" androidx-constraintlayout = "2.2.1" androidx-documentfile = "1.1.0" androidx-swiperefreshlayout = "1.2.0" +androidx-test-runner = "1.7.0" #Eventbus eventbus = "3.3.1" #Fossify @@ -34,6 +35,7 @@ app-build-kotlinJVMTarget = "17" androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" } androidx-documentfile = { module = "androidx.documentfile:documentfile", version.ref = "androidx-documentfile" } androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "androidx-swiperefreshlayout" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } #Compose compose-detekt = { module = "io.nlopez.compose.rules:detekt", version.ref = "detektCompose" } #Fossify From 3700e612d3f66e6e404cce254543ceb8aa5c982e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Mon, 26 Jan 2026 12:11:48 +0100 Subject: [PATCH 06/28] refactor: extract RecordingStore & co. into separate module Main reason is to be able to test `RecordingStore` against mock documents provider. Doing this in the app module was throwing `SecurityException`s due to permission issues for some reason. --- app/build.gradle.kts | 6 +- .../activities/SettingsActivity.kt | 2 +- .../adapters/RecordingsAdapter.kt | 2 +- .../voicerecorder/adapters/TrashAdapter.kt | 2 +- .../dialogs/RenameRecordingDialog.kt | 2 +- .../voicerecorder/extensions/Context.kt | 3 +- .../voicerecorder/extensions/DocumentFile.kt | 11 ---- .../voicerecorder/extensions/String.kt | 5 -- .../fragments/MyViewPagerFragment.kt | 2 +- .../voicerecorder/fragments/PlayerFragment.kt | 15 +---- .../voicerecorder/fragments/TrashFragment.kt | 3 +- .../fossify/voicerecorder/helpers/Config.kt | 2 +- .../voicerecorder/helpers/Constants.kt | 10 +--- .../interfaces/RefreshRecordingsListener.kt | 3 +- .../recorder/MediaRecorderWrapper.kt | 2 +- .../voicerecorder/services/RecorderService.kt | 3 +- app/src/main/res/values/donottranslate.xml | 5 -- build.gradle.kts | 3 +- gradle/libs.versions.toml | 7 ++- settings.gradle.kts | 1 + store/.gitignore | 1 + store/build.gradle.kts | 29 +++++++++ .../src/androidTest/AndroidManifest.xml | 7 +-- .../src/androidTest/assets/sample.ogg | Bin .../store}/MockDocumentsProvider.kt | 10 +--- .../store}/RecordingStoreTest.kt | 55 +++++++++++++----- store/src/main/AndroidManifest.xml | 5 ++ .../fossify/voicerecorder/store/Constants.kt | 10 ++++ .../voicerecorder/store}/DocumentsUtils.kt | 2 +- .../fossify/voicerecorder/store}/Recording.kt | 7 +-- .../voicerecorder/store}/RecordingStore.kt | 15 +++-- .../voicerecorder/store}/RecordingWriter.kt | 5 +- store/src/main/res/values/donottranslate.xml | 7 +++ 33 files changed, 134 insertions(+), 108 deletions(-) delete mode 100644 app/src/main/kotlin/org/fossify/voicerecorder/extensions/DocumentFile.kt delete mode 100644 app/src/main/kotlin/org/fossify/voicerecorder/extensions/String.kt create mode 100644 store/.gitignore create mode 100644 store/build.gradle.kts rename {app => store}/src/androidTest/AndroidManifest.xml (68%) rename {app => store}/src/androidTest/assets/sample.ogg (100%) rename {app/src/androidTest/kotlin/org/fossify/voicerecorder => store/src/androidTest/kotlin/org/fossify/voicerecorder/store}/MockDocumentsProvider.kt (87%) rename {app/src/androidTest/kotlin/org/fossify/voicerecorder => store/src/androidTest/kotlin/org/fossify/voicerecorder/store}/RecordingStoreTest.kt (79%) create mode 100644 store/src/main/AndroidManifest.xml create mode 100644 store/src/main/kotlin/org/fossify/voicerecorder/store/Constants.kt rename {app/src/main/kotlin/org/fossify/voicerecorder/helpers => store/src/main/kotlin/org/fossify/voicerecorder/store}/DocumentsUtils.kt (97%) rename {app/src/main/kotlin/org/fossify/voicerecorder/models => store/src/main/kotlin/org/fossify/voicerecorder/store}/Recording.kt (89%) rename {app/src/main/kotlin/org/fossify/voicerecorder/helpers => store/src/main/kotlin/org/fossify/voicerecorder/store}/RecordingStore.kt (96%) rename {app/src/main/kotlin/org/fossify/voicerecorder/helpers => store/src/main/kotlin/org/fossify/voicerecorder/store}/RecordingWriter.kt (96%) create mode 100644 store/src/main/res/values/donottranslate.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9bec1dfa..f70f8d98 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,7 +5,7 @@ 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) @@ -40,8 +40,6 @@ android { versionName = project.property("VERSION_NAME").toString() versionCode = project.property("VERSION_CODE").toString().toInt() vectorDrawables.useSupportLibrary = true - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } signingConfigs { @@ -153,5 +151,5 @@ dependencies { implementation(libs.autofittextview) detektPlugins(libs.compose.detekt) - androidTestImplementation(libs.androidx.test.runner) + implementation(project(":store")) } 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 4fb57dd6..cff6175d 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt @@ -21,7 +21,7 @@ import org.fossify.voicerecorder.extensions.recordingStore import org.fossify.voicerecorder.extensions.recordingStoreFor import org.fossify.voicerecorder.helpers.* import org.fossify.voicerecorder.models.Events -import org.fossify.voicerecorder.models.RecordingFormat +import org.fossify.voicerecorder.store.RecordingFormat import org.greenrobot.eventbus.EventBus import java.util.Locale import kotlin.math.abs 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 5a094b74..634a7196 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt @@ -18,7 +18,7 @@ import org.fossify.voicerecorder.extensions.config 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 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 7254fc03..e9700bd4 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/TrashAdapter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/TrashAdapter.kt @@ -15,7 +15,7 @@ import org.fossify.voicerecorder.databinding.ItemRecordingBinding 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( 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 e0a480c1..54870da5 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/RenameRecordingDialog.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/RenameRecordingDialog.kt @@ -14,7 +14,7 @@ import org.fossify.commons.extensions.value import org.fossify.commons.helpers.ensureBackgroundThread import org.fossify.voicerecorder.databinding.DialogRenameRecordingBinding import org.fossify.voicerecorder.models.Events -import org.fossify.voicerecorder.models.Recording +import org.fossify.voicerecorder.store.Recording import org.greenrobot.eventbus.EventBus class RenameRecordingDialog( 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 8c8da151..c4f3d6ae 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Context.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Context.kt @@ -10,8 +10,7 @@ import android.graphics.drawable.Drawable import android.net.Uri import androidx.core.graphics.createBitmap import org.fossify.voicerecorder.helpers.* -import java.util.Calendar -import java.util.Locale +import org.fossify.voicerecorder.store.RecordingStore val Context.config: Config get() = Config.newInstance(applicationContext) 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 448fd03b..fec72f82 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/MyViewPagerFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/MyViewPagerFragment.kt @@ -6,7 +6,7 @@ import android.util.AttributeSet import androidx.constraintlayout.widget.ConstraintLayout import org.fossify.commons.helpers.ensureBackgroundThread import org.fossify.voicerecorder.extensions.recordingStore -import org.fossify.voicerecorder.models.Recording +import org.fossify.voicerecorder.store.Recording abstract class MyViewPagerFragment(context: Context, attributeSet: AttributeSet) : ConstraintLayout(context, attributeSet) { abstract fun onResume() 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 ceb0f182..3c95c23a 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/PlayerFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/PlayerFragment.kt @@ -14,18 +14,7 @@ import android.os.Looper import android.os.PowerManager import android.util.AttributeSet import android.widget.SeekBar -import org.fossify.commons.extensions.applyColorFilter -import org.fossify.commons.extensions.areSystemAnimationsEnabled -import org.fossify.commons.extensions.beVisibleIf -import org.fossify.commons.extensions.copyToClipboard -import org.fossify.commons.extensions.getColoredDrawableWithColor -import org.fossify.commons.extensions.getContrastColor -import org.fossify.commons.extensions.getFormattedDuration -import org.fossify.commons.extensions.getProperPrimaryColor -import org.fossify.commons.extensions.getProperTextColor -import org.fossify.commons.extensions.showErrorToast -import org.fossify.commons.extensions.updateTextColors -import org.fossify.commons.extensions.value +import org.fossify.commons.extensions.* import org.fossify.commons.helpers.isQPlus import org.fossify.commons.helpers.isTiramisuPlus import org.fossify.voicerecorder.R @@ -35,8 +24,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 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 de7f682c..accd9c6a 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/TrashFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/TrashFragment.kt @@ -14,10 +14,11 @@ 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 +import kotlin.collections.isNotEmpty class TrashFragment( context: Context, 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 93e0b2e4..010a50bb 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Config.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Config.kt @@ -9,7 +9,7 @@ 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.models.RecordingFormat +import org.fossify.voicerecorder.store.RecordingFormat class Config(context: Context) : BaseConfig(context) { companion object { 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 7a78d441..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,9 +2,7 @@ package org.fossify.voicerecorder.helpers -import android.os.Build -import android.os.Environment -import org.fossify.voicerecorder.models.RecordingFormat +import org.fossify.voicerecorder.store.RecordingFormat const val REPOSITORY_NAME = "Voice-Recorder" @@ -104,10 +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" -val DEFAULT_RECORDINGS_FOLDER = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - Environment.DIRECTORY_RECORDINGS -} else { - "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/recorder/MediaRecorderWrapper.kt b/app/src/main/kotlin/org/fossify/voicerecorder/recorder/MediaRecorderWrapper.kt index 9771ceef..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,7 +5,7 @@ import android.content.Context import android.media.MediaRecorder import android.os.ParcelFileDescriptor import org.fossify.voicerecorder.extensions.config -import org.fossify.voicerecorder.models.RecordingFormat +import org.fossify.voicerecorder.store.RecordingFormat class MediaRecorderWrapper(val context: Context) : Recorder { 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 d3b449ac..9de8a00b 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt @@ -19,10 +19,11 @@ import org.fossify.voicerecorder.extensions.recordingStore import org.fossify.voicerecorder.extensions.updateWidgets import org.fossify.voicerecorder.helpers.* import org.fossify.voicerecorder.models.Events -import org.fossify.voicerecorder.models.RecordingFormat 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.RecordingWriter import org.greenrobot.eventbus.EventBus import java.util.Timer import java.util.TimerTask 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/build.gradle.kts b/build.gradle.kts index 82ece143..aaf6a674 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ plugins { - alias(libs.plugins.android).apply(false) + alias(libs.plugins.androidApplication).apply(false) + alias(libs.plugins.androidLibrary).apply(false) alias(libs.plugins.ksp).apply(false) alias(libs.plugins.detekt).apply(false) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0767ce8b..112fe711 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] #jetbrains -kotlin = "2.3.10" +kotlin = "2.3.0" #KSP ksp = "2.3.5" #Detekt @@ -15,7 +15,7 @@ androidx-test-runner = "1.7.0" eventbus = "3.3.1" #Fossify #noinspection GradleDependency -commons = "6.1.3" +commons = "6.1.0" #AudioRecordView audiorecordview = "1.0.5" #TAndroidLame @@ -49,6 +49,7 @@ tandroidlame = { module = "com.github.naman14:TAndroidLame", version.ref = "tand #AutofitTextView autofittextview = { module = "me.grantland:autofittextview", version.ref = "autofittextview" } [plugins] -android = { id = "com.android.application", version.ref = "gradlePlugins-agp" } +androidApplication = { id = "com.android.application", version.ref = "gradlePlugins-agp" } +androidLibrary = { id = "com.android.library", version.ref = "gradlePlugins-agp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 323f1ed9..4df1d6a8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,3 +16,4 @@ dependencyResolutionManagement { } rootProject.name = "Voice-Recorder" include(":app") +include(":store") diff --git a/store/.gitignore b/store/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/store/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/store/build.gradle.kts b/store/build.gradle.kts new file mode 100644 index 00000000..dd5e6992 --- /dev/null +++ b/store/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + alias(libs.plugins.androidLibrary) +} + +android { + namespace = "org.fossify.voicerecorder.store" + compileSdk = project.libs.versions.app.build.compileSDKVersion.get().toInt() + + defaultConfig { + minSdk = project.libs.versions.app.build.minimumSDK.get().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + val currentJavaVersionFromLibs = JavaVersion.valueOf(libs.versions.app.build.javaVersion.get()) + sourceCompatibility = currentJavaVersionFromLibs + targetCompatibility = currentJavaVersionFromLibs + } + + kotlin { + jvmToolchain(project.libs.versions.app.build.kotlinJVMTarget.get().toInt()) + } +} + +dependencies { + implementation(libs.fossify.commons) + implementation(libs.androidx.documentfile) + androidTestImplementation(libs.androidx.test.runner) +} diff --git a/app/src/androidTest/AndroidManifest.xml b/store/src/androidTest/AndroidManifest.xml similarity index 68% rename from app/src/androidTest/AndroidManifest.xml rename to store/src/androidTest/AndroidManifest.xml index 802e36ef..dac1b56e 100644 --- a/app/src/androidTest/AndroidManifest.xml +++ b/store/src/androidTest/AndroidManifest.xml @@ -1,10 +1,8 @@ - - diff --git a/app/src/androidTest/assets/sample.ogg b/store/src/androidTest/assets/sample.ogg similarity index 100% rename from app/src/androidTest/assets/sample.ogg rename to store/src/androidTest/assets/sample.ogg diff --git a/app/src/androidTest/kotlin/org/fossify/voicerecorder/MockDocumentsProvider.kt b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt similarity index 87% rename from app/src/androidTest/kotlin/org/fossify/voicerecorder/MockDocumentsProvider.kt rename to store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt index 879c625d..0b797e5a 100644 --- a/app/src/androidTest/kotlin/org/fossify/voicerecorder/MockDocumentsProvider.kt +++ b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt @@ -1,6 +1,5 @@ -package org.fossify.voicerecorder +package org.fossify.voicerecorder.store -/* import android.database.Cursor import android.os.CancellationSignal import android.os.ParcelFileDescriptor @@ -14,14 +13,10 @@ class MockDocumentsProvider(): DocumentsProvider() { private const val TAG = "MockDocumentsProvider" } - private lateinit var root: File + private var root: File? = null override fun onCreate(): Boolean { Log.d(TAG, "onCreate") - - root = File(requireNotNull(context).cacheDir, "mock-provider-${System.currentTimeMillis()}") - root.mkdirs() - return true } @@ -50,4 +45,3 @@ class MockDocumentsProvider(): DocumentsProvider() { throw NotImplementedError() } } -*/ diff --git a/app/src/androidTest/kotlin/org/fossify/voicerecorder/RecordingStoreTest.kt b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt similarity index 79% rename from app/src/androidTest/kotlin/org/fossify/voicerecorder/RecordingStoreTest.kt rename to store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt index 527d7ed3..910e5c8f 100644 --- a/app/src/androidTest/kotlin/org/fossify/voicerecorder/RecordingStoreTest.kt +++ b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt @@ -1,4 +1,4 @@ -package org.fossify.voicerecorder +package org.fossify.voicerecorder.store import android.content.ContentResolver import android.content.Context @@ -8,45 +8,53 @@ import android.os.Build import android.os.Bundle import android.os.Handler import android.os.HandlerThread +import android.provider.DocumentsContract import android.provider.MediaStore import androidx.test.platform.app.InstrumentationRegistry -import org.fossify.voicerecorder.helpers.RecordingStore -import org.fossify.voicerecorder.models.RecordingFormat import org.junit.After import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import java.io.File import java.io.FileOutputStream import java.util.concurrent.CountDownLatch class RecordingStoreTest { companion object { - private const val MOCK_PROVIDER_AUTHORITY = "org.fossify.voicerecorder.mockprovider" + // TODO + private const val MOCK_PROVIDER_AUTHORITY = "org.fossify.voicerecorder.store.mock.provider" private val DEFAULT_MEDIA_URI = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + private val DEFAULT_DOCUMENTS_URI = DocumentsContract.buildTreeDocumentUri(MOCK_PROVIDER_AUTHORITY, "Recordings") private const val TAG = "RecordingStoreTest" } + private lateinit var tempDir: File + @Before fun setup() { - deleteTestMedia() + tempDir = File(instrumentation.context.cacheDir, "temp-${System.currentTimeMillis()}") + tempDir.mkdirs() } @After fun teardown() { - deleteTestMedia() + deleteTestFiles() + + tempDir.deleteRecursively() } -// @Test -// fun createRecording_SAF() { -// } + @Test + fun createRecording_MediaStore() = createRecording(DEFAULT_MEDIA_URI) @Test - fun createRecording_MediaStore() { - val store = RecordingStore(context, DEFAULT_MEDIA_URI) + fun createRecording_SAF() = createRecording(DEFAULT_DOCUMENTS_URI) + + private fun createRecording(uri: Uri) { + val store = RecordingStore(context, uri) - val name = makeTestMediaName("sample") + val name = makeTestName("sample") val uri = store.createRecording(name, RecordingFormat.OGG) val recordings = store.getAll() @@ -61,7 +69,7 @@ class RecordingStoreTest { fun trashRecording_MediaStore() { val store = RecordingStore(context, DEFAULT_MEDIA_URI) - val name = makeTestMediaName("sample") + val name = makeTestName("sample") val uri = store.createRecording(name, RecordingFormat.OGG) val recording = store.getAll().find { it.uri == uri }!! @@ -78,7 +86,7 @@ class RecordingStoreTest { fun restoreRecording_MediaStore() { val store = RecordingStore(context, DEFAULT_MEDIA_URI) - val uri = store.createRecording(makeTestMediaName("sample"), RecordingFormat.OGG) + val uri = store.createRecording(makeTestName("sample"), RecordingFormat.OGG) val recording = store.getAll(trashed = false).find { it.uri == uri }!! store.trash(listOf(recording)) @@ -89,15 +97,29 @@ class RecordingStoreTest { assertFalse(store.getAll(trashed = true).any { it.title == recording.title }) } + @Test + fun deleteRecording_MediaStore() { + val store = RecordingStore(context, DEFAULT_MEDIA_URI) + + val name = makeTestName("sample") + val uri = store.createRecording(name, RecordingFormat.OGG) + + val recording = store.getAll().find { it.uri == uri }!! + store.delete(listOf(recording)) + + assertFalse(store.getAll(trashed = false).any { it.title == recording.title }) + assertFalse(store.getAll(trashed = true).any { it.title == recording.title }) + } + private val context: Context get() = instrumentation.targetContext private val instrumentation get() = InstrumentationRegistry.getInstrumentation() - private fun makeTestMediaName(name: String): String = "$name$testMediaSuffix.${System.currentTimeMillis()}" + private fun makeTestName(name: String): String = "$name$testMediaSuffix.${System.currentTimeMillis()}" - private fun deleteTestMedia() { + private fun deleteTestFiles() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val queryArgs = Bundle().apply { putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE) @@ -177,6 +199,7 @@ class RecordingStoreTest { } } ?: 0 } + else -> TODO() } } diff --git a/store/src/main/AndroidManifest.xml b/store/src/main/AndroidManifest.xml new file mode 100644 index 00000000..d9c6c4a9 --- /dev/null +++ b/store/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/Constants.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/Constants.kt new file mode 100644 index 00000000..0de24a69 --- /dev/null +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/Constants.kt @@ -0,0 +1,10 @@ +package org.fossify.voicerecorder.store + +import android.os.Build +import android.os.Environment + +val DEFAULT_RECORDINGS_FOLDER = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Environment.DIRECTORY_RECORDINGS +} else { + "Recordings" +} diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/DocumentsUtils.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/DocumentsUtils.kt similarity index 97% rename from app/src/main/kotlin/org/fossify/voicerecorder/helpers/DocumentsUtils.kt rename to store/src/main/kotlin/org/fossify/voicerecorder/store/DocumentsUtils.kt index ebe03294..4cbf4d4e 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/DocumentsUtils.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/DocumentsUtils.kt @@ -1,4 +1,4 @@ -package org.fossify.voicerecorder.helpers +package org.fossify.voicerecorder.store import android.content.ContentResolver import android.net.Uri diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/models/Recording.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/Recording.kt similarity index 89% rename from app/src/main/kotlin/org/fossify/voicerecorder/models/Recording.kt rename to store/src/main/kotlin/org/fossify/voicerecorder/store/Recording.kt index 654f6685..e2b13bae 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/models/Recording.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/Recording.kt @@ -1,10 +1,9 @@ -package org.fossify.voicerecorder.models +package org.fossify.voicerecorder.store import android.content.Context import android.net.Uri +import android.os.Build import android.webkit.MimeTypeMap -import org.fossify.commons.helpers.isOreoPlus -import org.fossify.voicerecorder.R data class Recording( val id: Int, @@ -32,7 +31,7 @@ enum class RecordingFormat(val value: Int) { * Return formats that are available on the current platform */ val available: List = arrayListOf(M4A, MP3).apply { - if (isOreoPlus()) add(OGG) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) add(OGG) } } diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingStore.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt similarity index 96% rename from app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingStore.kt rename to store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt index cb8b577a..b9acb802 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingStore.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt @@ -1,4 +1,4 @@ -package org.fossify.voicerecorder.helpers +package org.fossify.voicerecorder.store import android.content.ContentResolver import android.content.ContentUris @@ -14,9 +14,6 @@ import android.provider.MediaStore import android.util.Log import androidx.documentfile.provider.DocumentFile import org.fossify.commons.helpers.ensureBackgroundThread -import org.fossify.voicerecorder.extensions.isAudioRecording -import org.fossify.voicerecorder.models.Recording -import org.fossify.voicerecorder.models.RecordingFormat import kotlin.math.roundToLong /** @@ -266,10 +263,11 @@ class RecordingStore(private val context: Context, val uri: Uri) { val resolver = context.contentResolver recordings.forEach { - when (Kind.of(it.uri)) { - Kind.DOCUMENT -> DocumentsContract.deleteDocument(resolver, it.uri) - Kind.MEDIA -> resolver.delete(it.uri, null, null) - } + resolver.delete(it.uri, null, null) +// when (Kind.of(it.uri)) { +// Kind.DOCUMENT -> DocumentsContract.deleteDocument(resolver, it.uri) +// Kind.MEDIA -> resolver.delete(it.uri, null, null) +// } } return true @@ -350,6 +348,7 @@ private fun ensureParentDocumentUri(context: Context, uri: Uri): Uri = when { else -> error("invalid URI, must be document or tree: $uri") } +internal fun DocumentFile.isAudioRecording() = type.let { it != null && it.startsWith("audio") } && name.let { it != null && !it.startsWith(".") } //@Deprecated( // message = "Use getRecordings instead. This method is only here for backward compatibility.", replaceWith = ReplaceWith("getRecordings(trashed = true)") diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingWriter.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt similarity index 96% rename from app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingWriter.kt rename to store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt index ed173fdc..9f700024 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/RecordingWriter.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt @@ -1,4 +1,4 @@ -package org.fossify.voicerecorder.helpers +package org.fossify.voicerecorder.store import android.content.ContentResolver import android.content.ContentValues @@ -8,14 +8,13 @@ import android.os.Build import android.os.ParcelFileDescriptor import android.provider.DocumentsContract import android.provider.MediaStore -import org.fossify.voicerecorder.models.RecordingFormat import java.io.File import java.io.FileInputStream /** * Helper class to write recordings to the device. * - * Note: Why not use [DocumentsContract.createDocument] directly? Because there is currently a bug in [android.provider.MediaStore] (TODO: link to the + * Note: Why not use [DocumentsContract.createDocument] directly? Because there is currently a bug in [MediaStore] (TODO: link to the * bugreport) which causes crash when writing to some [android.provider.DocumentsProvider]s. Using this class works around the bug. */ sealed class RecordingWriter { diff --git a/store/src/main/res/values/donottranslate.xml b/store/src/main/res/values/donottranslate.xml new file mode 100644 index 00000000..c4ba79c8 --- /dev/null +++ b/store/src/main/res/values/donottranslate.xml @@ -0,0 +1,7 @@ + + m4a + mp3 + mp3 (Experimental) + ogg + ogg (Opus) + From 334356379e3e130bd93ab032cf5c88661b3222b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Mon, 26 Jan 2026 14:14:48 +0100 Subject: [PATCH 07/28] chore: write tests for RecordingStore's SAF backend --- store/src/androidTest/AndroidManifest.xml | 4 + .../store/MockDocumentsProvider.kt | 94 +++++++++++++++---- .../voicerecorder/store/RecordingStoreTest.kt | 35 ++++--- store/src/main/AndroidManifest.xml | 3 - .../voicerecorder/store/RecordingStore.kt | 2 +- .../voicerecorder/store/RecordingWriter.kt | 17 ++-- 6 files changed, 112 insertions(+), 43 deletions(-) diff --git a/store/src/androidTest/AndroidManifest.xml b/store/src/androidTest/AndroidManifest.xml index dac1b56e..00522454 100644 --- a/store/src/androidTest/AndroidManifest.xml +++ b/store/src/androidTest/AndroidManifest.xml @@ -1,4 +1,8 @@ + + ): Cursor { Log.d(TAG, "queryRoots") throw NotImplementedError() } - override fun queryChildDocuments(parentDocumentId: String, projection: Array, sortOrder: String): Cursor { - Log.d(TAG, "queryChildDocuments") - throw NotImplementedError() + override fun queryChildDocuments(parentDocumentId: String, projection: Array?, sortOrder: String?): Cursor { + val root = requireNotNull(root) + val parent = File(root, parentDocumentId) + + val projection = projection ?: arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val result = MatrixCursor(projection) + + for (file in parent.listFiles() ?: emptyArray()) { + val row = result.newRow() + val documentId = file.relativeTo(root).path + + if (projection.contains(DocumentsContract.Document.COLUMN_DOCUMENT_ID)) { + row.add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, documentId) + } + + if (projection.contains(DocumentsContract.Document.COLUMN_DISPLAY_NAME)) { + row.add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, file.name) + } + } + + return result } - override fun queryDocument(documentId: String, projection: Array): Cursor { - Log.d(TAG, "queryDocument") - throw NotImplementedError() + override fun queryDocument(documentId: String, projection: Array?): Cursor { + val root = requireNotNull(root) + val file = File(root, documentId) + + val projection = projection ?: arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val result = MatrixCursor(projection) + val row = result.newRow() + + if (projection.contains(DocumentsContract.Document.COLUMN_DOCUMENT_ID)) { + row.add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, documentId) + } + + if (projection.contains(DocumentsContract.Document.COLUMN_DISPLAY_NAME)) { + row.add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, file.name) + } + + if (projection.contains(DocumentsContract.Document.COLUMN_MIME_TYPE)) { + row.add(DocumentsContract.Document.COLUMN_MIME_TYPE, if (file.isDirectory()) { + DocumentsContract.Document.MIME_TYPE_DIR + } else { + URLConnection.guessContentTypeFromName(file.name) + }) + } + + if (projection.contains(DocumentsContract.Document.COLUMN_SIZE)) { + row.add(DocumentsContract.Document.COLUMN_SIZE, file.length()) + } + + return result } - override fun openDocument(documentId: String?, mode: String?, cancellationSignal: CancellationSignal?): ParcelFileDescriptor { - Log.d(TAG, "openDocument") - throw NotImplementedError() + override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean = File(documentId).parent == parentDocumentId + + override fun openDocument(documentId: String, mode: String, cancellationSignal: CancellationSignal?): ParcelFileDescriptor { + val root = requireNotNull(root) + val path = File(root, documentId) + + return ParcelFileDescriptor.open(path, ParcelFileDescriptor.parseMode(mode)) } override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String { - Log.d(TAG, "createDocument($parentDocumentId, $mimeType, $displayName") - throw NotImplementedError() + val root = requireNotNull(root) + val documentId = "$parentDocumentId/$displayName" + val file = File(root, documentId) + + file.parentFile?.mkdirs() + + if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { + file.mkdir() + } else { + file.createNewFile() + } + + return documentId } } diff --git a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt index 910e5c8f..296e8985 100644 --- a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt +++ b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt @@ -36,12 +36,15 @@ class RecordingStoreTest { fun setup() { tempDir = File(instrumentation.context.cacheDir, "temp-${System.currentTimeMillis()}") tempDir.mkdirs() + + val mockDocumentsProvider = context.contentResolver.acquireContentProviderClient(MOCK_PROVIDER_AUTHORITY)?.localContentProvider as + MockDocumentsProvider + mockDocumentsProvider.root = tempDir } @After fun teardown() { deleteTestFiles() - tempDir.deleteRecursively() } @@ -134,7 +137,7 @@ class RecordingStoreTest { } private val testMediaSuffix - get() = ".${context.packageName}.test" + get() = ".${context.packageName}" private val contentObserverHandler = Handler(HandlerThread("contentObserver").apply { start() }.looper) @@ -187,19 +190,23 @@ class RecordingStoreTest { context.contentResolver.unregisterContentObserver(observer) } - private fun getSize(uri: Uri): Long = when (uri.authority) { - MediaStore.AUTHORITY -> { - val projection = arrayOf(MediaStore.Audio.Media.SIZE) - context.contentResolver.query(uri, projection, null, null, null)?.use { cursor -> - val iSize = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE) - if (cursor.moveToNext()) { - cursor.getLong(iSize) - } else { - null - } - } ?: 0 + private fun getSize(uri: Uri): Long { + val column = when (uri.authority) { + MediaStore.AUTHORITY -> MediaStore.Audio.Media.SIZE + else -> DocumentsContract.Document.COLUMN_SIZE } - else -> TODO() + val projection = arrayOf(column) + + val size = context.contentResolver.query(uri, projection, null, null, null)?.use { cursor -> + val iSize = cursor.getColumnIndexOrThrow(column) + if (cursor.moveToNext()) { + cursor.getLong(iSize) + } else { + null + } + } ?: 0 + + return size } } diff --git a/store/src/main/AndroidManifest.xml b/store/src/main/AndroidManifest.xml index d9c6c4a9..a2f47b60 100644 --- a/store/src/main/AndroidManifest.xml +++ b/store/src/main/AndroidManifest.xml @@ -1,5 +1,2 @@ - diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt index b9acb802..e205616a 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt @@ -227,7 +227,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { callback: (success: Boolean) -> Unit = {} ) = ensureBackgroundThread { callback(delete(getAll(trashed = true))) } - fun move(recordings: Collection, sourceParent: Uri, targetParent: Uri):Boolean { + fun move(recordings: Collection, sourceParent: Uri, targetParent: Uri): Boolean { // TODO: handle media return moveDocuments(recordings, sourceParent, targetParent) } diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt index 9f700024..b99e7c52 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt @@ -23,7 +23,7 @@ sealed class RecordingWriter { val direct = DIRECT_FORMATS.contains(format) or DIRECT_AUTHORITIES.contains(parentUri.authority) if (direct) { - val uri = createDocument(context, parentUri, name, format) + val uri = createFile(context, parentUri, name, format) val fileDescriptor = requireNotNull(context.contentResolver.openFileDescriptor(uri, "w")) { "failed to open file descriptor at $uri" } @@ -61,7 +61,7 @@ sealed class RecordingWriter { override fun cancel() { fileDescriptor.close() - DocumentsContract.deleteDocument(contentResolver, uri) + contentResolver.delete(uri, null, null) } } @@ -81,7 +81,7 @@ sealed class RecordingWriter { ) override fun commit(): Uri { - val dstUri = createDocument(context, parentTreeUri, name, format) + val dstUri = createFile(context, parentTreeUri, name, format) val dst = requireNotNull(context.contentResolver.openOutputStream(dstUri)) { "failed to open output stream at $dstUri" } @@ -105,12 +105,11 @@ sealed class RecordingWriter { } } -private fun createDocument(context: Context, parentUri: Uri, name: String, format: RecordingFormat): Uri { - val displayName = "$name.${format.getExtension(context)}" - +private fun createFile(context: Context, parentUri: Uri, name: String, format: RecordingFormat): Uri { val uri = if (parentUri.authority == MediaStore.AUTHORITY) { val values = ContentValues().apply { - put(MediaStore.Audio.Media.DISPLAY_NAME, displayName) + put(MediaStore.Audio.Media.DISPLAY_NAME, name) + put(MediaStore.Audio.Media.MIME_TYPE, format.getMimeType(context)) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { put(MediaStore.Audio.Media.RELATIVE_PATH, DEFAULT_RECORDINGS_FOLDER) @@ -120,6 +119,8 @@ private fun createDocument(context: Context, parentUri: Uri, name: String, forma context.contentResolver.insert(parentUri, values) } else { val parentDocumentUri = buildParentDocumentUri(parentUri) + val displayName = "$name.${format.getExtension(context)}" + DocumentsContract.createDocument( context.contentResolver, parentDocumentUri, @@ -129,6 +130,6 @@ private fun createDocument(context: Context, parentUri: Uri, name: String, forma } return requireNotNull(uri) { - "failed to create document '$displayName' in $parentUri" + "failed to create file '$name' in $parentUri" } } From e508488fbad7f89c01b277c9193fe4ebc4a422d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Mon, 26 Jan 2026 14:58:28 +0100 Subject: [PATCH 08/28] chore: write more tests for RecordingStore's SAF backend --- .../store/MockDocumentsProvider.kt | 33 +++++++-- .../voicerecorder/store/RecordingStoreTest.kt | 41 +++++++++-- .../voicerecorder/store/RecordingStore.kt | 68 +++++++++++++------ .../voicerecorder/store/RecordingWriter.kt | 32 +-------- 4 files changed, 112 insertions(+), 62 deletions(-) diff --git a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt index c8c984bf..24486d21 100644 --- a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt +++ b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt @@ -66,11 +66,13 @@ class MockDocumentsProvider() : DocumentsProvider() { } if (projection.contains(DocumentsContract.Document.COLUMN_MIME_TYPE)) { - row.add(DocumentsContract.Document.COLUMN_MIME_TYPE, if (file.isDirectory()) { - DocumentsContract.Document.MIME_TYPE_DIR - } else { - URLConnection.guessContentTypeFromName(file.name) - }) + row.add( + DocumentsContract.Document.COLUMN_MIME_TYPE, if (file.isDirectory()) { + DocumentsContract.Document.MIME_TYPE_DIR + } else { + URLConnection.guessContentTypeFromName(file.name) + } + ) } if (projection.contains(DocumentsContract.Document.COLUMN_SIZE)) { @@ -80,7 +82,8 @@ class MockDocumentsProvider() : DocumentsProvider() { return result } - override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean = File(documentId).parent == parentDocumentId + override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean = + documentId.startsWith("$parentDocumentId/") override fun openDocument(documentId: String, mode: String, cancellationSignal: CancellationSignal?): ParcelFileDescriptor { val root = requireNotNull(root) @@ -104,4 +107,22 @@ class MockDocumentsProvider() : DocumentsProvider() { return documentId } + + override fun moveDocument( + sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String + ): String { + val root = requireNotNull(root) + val srcFile = File(root, sourceDocumentId) + val dstFile = File(root, "$targetParentDocumentId/${srcFile.name}") + + srcFile.renameTo(dstFile) + + return dstFile.relativeTo(root).path + } + + override fun deleteDocument(documentId: String) { + val root = requireNotNull(root) + val file = File(root, documentId) + file.deleteRecursively() + } } diff --git a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt index 296e8985..1768a9e2 100644 --- a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt +++ b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt @@ -69,8 +69,13 @@ class RecordingStoreTest { } @Test - fun trashRecording_MediaStore() { - val store = RecordingStore(context, DEFAULT_MEDIA_URI) + fun trashRecording_MediaStore() = trashRecording(DEFAULT_MEDIA_URI) + + @Test + fun trashRecording_SAF() = trashRecording(DEFAULT_DOCUMENTS_URI) + + private fun trashRecording(uri: Uri) { + val store = RecordingStore(context, uri) val name = makeTestName("sample") val uri = store.createRecording(name, RecordingFormat.OGG) @@ -86,8 +91,13 @@ class RecordingStoreTest { } @Test - fun restoreRecording_MediaStore() { - val store = RecordingStore(context, DEFAULT_MEDIA_URI) + fun restoreRecording_MediaStore() = restoreRecording(DEFAULT_MEDIA_URI) + + @Test + fun restoreRecording_SAF() = restoreRecording(DEFAULT_DOCUMENTS_URI) + + private fun restoreRecording(uri: Uri) { + val store = RecordingStore(context, uri) val uri = store.createRecording(makeTestName("sample"), RecordingFormat.OGG) val recording = store.getAll(trashed = false).find { it.uri == uri }!! @@ -101,13 +111,30 @@ class RecordingStoreTest { } @Test - fun deleteRecording_MediaStore() { - val store = RecordingStore(context, DEFAULT_MEDIA_URI) + fun deleteNormalRecording_MediaStore() = deleteRecording(DEFAULT_MEDIA_URI, trashed = false) + + @Test + fun deleteNormalRecording_SAF() = deleteRecording(DEFAULT_DOCUMENTS_URI, trashed = false) + + @Test + fun deleteTrashedRecording_MediaStore() = deleteRecording(DEFAULT_MEDIA_URI, trashed = true) + + @Test + fun deleteTrashedRecording_SAF() = deleteRecording(DEFAULT_DOCUMENTS_URI, trashed = true) + + private fun deleteRecording(uri: Uri, trashed: Boolean) { + val store = RecordingStore(context, uri) val name = makeTestName("sample") val uri = store.createRecording(name, RecordingFormat.OGG) - val recording = store.getAll().find { it.uri == uri }!! + var recording = store.getAll().find { it.uri == uri }!! + + if (trashed) { + store.trash(listOf(recording)) + recording = store.getAll(trashed = true).find { it.title == recording.title }!! + } + store.delete(listOf(recording)) assertFalse(store.getAll(trashed = false).any { it.title == recording.title }) diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt index e205616a..6ce285a9 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt @@ -28,18 +28,6 @@ class RecordingStore(private val context: Context, val uri: Uri) { require(uri.scheme == ContentResolver.SCHEME_CONTENT) { "Invalid URI '$uri' - must have 'content' scheme" } } - enum class Kind { - DOCUMENT, MEDIA; - - companion object { - fun of(uri: Uri): Kind = if (uri.authority == MediaStore.AUTHORITY) { - MEDIA - } else { - DOCUMENT - } - } - } - /** * Short, human-readable name of this store */ @@ -88,6 +76,8 @@ class RecordingStore(private val context: Context, val uri: Uri) { uri } + Log.d(TAG, "getAllDocuments($parentUri)") + return parentUri?.let { DocumentFile.fromTreeUri(context, it) }?.listFiles()?.filter { it.isAudioRecording() }?.map { readRecordingFromFile(it) } ?.toList() ?: emptyList() } @@ -241,8 +231,6 @@ class RecordingStore(private val context: Context, val uri: Uri) { for (recording in recordings) { try { - // TODO: convert to document URI only if not already document URI - DocumentsContract.moveDocument( contentResolver, recording.uri, sourceParentDocumentUri, targetParentDocumentUri ) @@ -263,11 +251,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { val resolver = context.contentResolver recordings.forEach { - resolver.delete(it.uri, null, null) -// when (Kind.of(it.uri)) { -// Kind.DOCUMENT -> DocumentsContract.deleteDocument(resolver, it.uri) -// Kind.MEDIA -> resolver.delete(it.uri, null, null) -// } + deleteFile(resolver, it.uri) } return true @@ -342,6 +326,52 @@ class RecordingStore(private val context: Context, val uri: Uri) { private const val TRASH_FOLDER_NAME = ".trash" private const val TRASHED_PREFIX = ".trashed-" +private enum class Kind { + DOCUMENT, MEDIA; + + companion object { + fun of(uri: Uri): Kind = if (uri.authority == MediaStore.AUTHORITY) { + MEDIA + } else { + DOCUMENT + } + } +} + +internal fun createFile(context: Context, parentUri: Uri, name: String, format: RecordingFormat): Uri { + val uri = if (parentUri.authority == MediaStore.AUTHORITY) { + val values = ContentValues().apply { + put(MediaStore.Audio.Media.DISPLAY_NAME, name) + put(MediaStore.Audio.Media.MIME_TYPE, format.getMimeType(context)) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.Audio.Media.RELATIVE_PATH, DEFAULT_RECORDINGS_FOLDER) + } + } + + context.contentResolver.insert(parentUri, values) + } else { + val parentDocumentUri = buildParentDocumentUri(parentUri) + val displayName = "$name.${format.getExtension(context)}" + + DocumentsContract.createDocument( + context.contentResolver, + parentDocumentUri, + format.getMimeType(context), + displayName, + ) + } + + return requireNotNull(uri) { + "failed to create file '$name' in $parentUri" + } +} + +internal fun deleteFile(contentResolver: ContentResolver, uri: Uri) = when (Kind.of(uri)) { + Kind.MEDIA -> contentResolver.delete(uri, null, null) + Kind.DOCUMENT -> DocumentsContract.deleteDocument(contentResolver, uri) +} + private fun ensureParentDocumentUri(context: Context, uri: Uri): Uri = when { DocumentsContract.isDocumentUri(context, uri) -> uri DocumentsContract.isTreeUri(uri) -> buildParentDocumentUri(uri) diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt index b99e7c52..8852bc83 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt @@ -10,6 +10,7 @@ import android.provider.DocumentsContract import android.provider.MediaStore import java.io.File import java.io.FileInputStream +import java.nio.file.Files.createFile /** * Helper class to write recordings to the device. @@ -61,7 +62,7 @@ sealed class RecordingWriter { override fun cancel() { fileDescriptor.close() - contentResolver.delete(uri, null, null) + deleteFile(contentResolver, uri) } } @@ -104,32 +105,3 @@ sealed class RecordingWriter { } } } - -private fun createFile(context: Context, parentUri: Uri, name: String, format: RecordingFormat): Uri { - val uri = if (parentUri.authority == MediaStore.AUTHORITY) { - val values = ContentValues().apply { - put(MediaStore.Audio.Media.DISPLAY_NAME, name) - put(MediaStore.Audio.Media.MIME_TYPE, format.getMimeType(context)) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - put(MediaStore.Audio.Media.RELATIVE_PATH, DEFAULT_RECORDINGS_FOLDER) - } - } - - context.contentResolver.insert(parentUri, values) - } else { - val parentDocumentUri = buildParentDocumentUri(parentUri) - val displayName = "$name.${format.getExtension(context)}" - - DocumentsContract.createDocument( - context.contentResolver, - parentDocumentUri, - format.getMimeType(context), - displayName, - ) - } - - return requireNotNull(uri) { - "failed to create file '$name' in $parentUri" - } -} From f880774f2821e047127579b8f45b503759ba93bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Mon, 26 Jan 2026 17:28:50 +0100 Subject: [PATCH 09/28] refactor: return Sequence instead of List --- .../store/MockDocumentsProvider.kt | 48 +++--- .../voicerecorder/store/RecordingStoreTest.kt | 68 ++++++-- .../voicerecorder/store/RecordingStore.kt | 151 ++++++++++-------- .../voicerecorder/store/RecordingWriter.kt | 5 +- 4 files changed, 156 insertions(+), 116 deletions(-) diff --git a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt index 24486d21..7c43e8ef 100644 --- a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt +++ b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt @@ -6,13 +6,15 @@ import android.os.CancellationSignal import android.os.ParcelFileDescriptor import android.provider.DocumentsContract import android.provider.DocumentsProvider -import android.util.Log import java.io.File import java.net.URLConnection class MockDocumentsProvider() : DocumentsProvider() { companion object { - const val ROOT = "root" + val DEFAULT_PROJECTION = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Document.COLUMN_MIME_TYPE + ) + private const val TAG = "MockDocumentsProvider" } @@ -22,7 +24,6 @@ class MockDocumentsProvider() : DocumentsProvider() { override fun onCreate(): Boolean = true override fun queryRoots(projection: Array): Cursor { - Log.d(TAG, "queryRoots") throw NotImplementedError() } @@ -30,42 +31,35 @@ class MockDocumentsProvider() : DocumentsProvider() { val root = requireNotNull(root) val parent = File(root, parentDocumentId) - val projection = projection ?: arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME) - val result = MatrixCursor(projection) - - for (file in parent.listFiles() ?: emptyArray()) { - val row = result.newRow() - val documentId = file.relativeTo(root).path - - if (projection.contains(DocumentsContract.Document.COLUMN_DOCUMENT_ID)) { - row.add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, documentId) - } - - if (projection.contains(DocumentsContract.Document.COLUMN_DISPLAY_NAME)) { - row.add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, file.name) + return MatrixCursor(projection ?: DEFAULT_PROJECTION).apply { + for (file in parent.listFiles() ?: emptyArray()) { + val documentId = file.relativeTo(root).path + addFile(documentId, file) } } - - return result } override fun queryDocument(documentId: String, projection: Array?): Cursor { val root = requireNotNull(root) val file = File(root, documentId) - val projection = projection ?: arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME) - val result = MatrixCursor(projection) - val row = result.newRow() + return MatrixCursor(projection ?: DEFAULT_PROJECTION).apply { + addFile(documentId, file) + } + } + + private fun MatrixCursor.addFile(documentId: String, file: File) { + val row = newRow() - if (projection.contains(DocumentsContract.Document.COLUMN_DOCUMENT_ID)) { + if (columnNames.contains(DocumentsContract.Document.COLUMN_DOCUMENT_ID)) { row.add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, documentId) } - if (projection.contains(DocumentsContract.Document.COLUMN_DISPLAY_NAME)) { + if (columnNames.contains(DocumentsContract.Document.COLUMN_DISPLAY_NAME)) { row.add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, file.name) } - if (projection.contains(DocumentsContract.Document.COLUMN_MIME_TYPE)) { + if (columnNames.contains(DocumentsContract.Document.COLUMN_MIME_TYPE)) { row.add( DocumentsContract.Document.COLUMN_MIME_TYPE, if (file.isDirectory()) { DocumentsContract.Document.MIME_TYPE_DIR @@ -75,15 +69,13 @@ class MockDocumentsProvider() : DocumentsProvider() { ) } - if (projection.contains(DocumentsContract.Document.COLUMN_SIZE)) { + if (columnNames.contains(DocumentsContract.Document.COLUMN_SIZE)) { row.add(DocumentsContract.Document.COLUMN_SIZE, file.length()) } - return result } - override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean = - documentId.startsWith("$parentDocumentId/") + override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean = documentId.startsWith("$parentDocumentId/") override fun openDocument(documentId: String, mode: String, cancellationSignal: CancellationSignal?): ParcelFileDescriptor { val root = requireNotNull(root) diff --git a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt index 1768a9e2..41b2a857 100644 --- a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt +++ b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt @@ -37,8 +37,7 @@ class RecordingStoreTest { tempDir = File(instrumentation.context.cacheDir, "temp-${System.currentTimeMillis()}") tempDir.mkdirs() - val mockDocumentsProvider = context.contentResolver.acquireContentProviderClient(MOCK_PROVIDER_AUTHORITY)?.localContentProvider as - MockDocumentsProvider + val mockDocumentsProvider = context.contentResolver.acquireContentProviderClient(MOCK_PROVIDER_AUTHORITY)?.localContentProvider as MockDocumentsProvider mockDocumentsProvider.root = tempDir } @@ -60,8 +59,7 @@ class RecordingStoreTest { val name = makeTestName("sample") val uri = store.createRecording(name, RecordingFormat.OGG) - val recordings = store.getAll() - val recording = recordings.find { it.uri == uri } + val recording = store.all().find { it.uri == uri } assertNotNull(recording) val size = getSize(uri) @@ -80,14 +78,14 @@ class RecordingStoreTest { val name = makeTestName("sample") val uri = store.createRecording(name, RecordingFormat.OGG) - val recording = store.getAll().find { it.uri == uri }!! + val recording = store.all().find { it.uri == uri }!! - assertFalse(store.getAll(trashed = true).any { it.title == recording.title }) + assertFalse(store.all(trashed = true).any { it.title == recording.title }) store.trash(listOf(recording)) - assertFalse(store.getAll(trashed = false).any { it.title == recording.title }) - assertTrue(store.getAll(trashed = true).any { it.title == recording.title }) + assertFalse(store.all(trashed = false).any { it.title == recording.title }) + assertTrue(store.all(trashed = true).any { it.title == recording.title }) } @Test @@ -100,14 +98,14 @@ class RecordingStoreTest { val store = RecordingStore(context, uri) val uri = store.createRecording(makeTestName("sample"), RecordingFormat.OGG) - val recording = store.getAll(trashed = false).find { it.uri == uri }!! + val recording = store.all(trashed = false).find { it.uri == uri }!! store.trash(listOf(recording)) - val trashedRecording = store.getAll(trashed = true).find { it.title == recording.title }!! + val trashedRecording = store.all(trashed = true).find { it.title == recording.title }!! store.restore(listOf(trashedRecording)) - assertTrue(store.getAll(trashed = false).any { it.title == recording.title }) - assertFalse(store.getAll(trashed = true).any { it.title == recording.title }) + assertTrue(store.all(trashed = false).any { it.title == recording.title }) + assertFalse(store.all(trashed = true).any { it.title == recording.title }) } @Test @@ -128,17 +126,55 @@ class RecordingStoreTest { val name = makeTestName("sample") val uri = store.createRecording(name, RecordingFormat.OGG) - var recording = store.getAll().find { it.uri == uri }!! + var recording = store.all().find { it.uri == uri }!! if (trashed) { store.trash(listOf(recording)) - recording = store.getAll(trashed = true).find { it.title == recording.title }!! + recording = store.all(trashed = true).find { it.title == recording.title }!! } store.delete(listOf(recording)) - assertFalse(store.getAll(trashed = false).any { it.title == recording.title }) - assertFalse(store.getAll(trashed = true).any { it.title == recording.title }) + assertFalse(store.all(trashed = false).any { it.title == recording.title }) + assertFalse(store.all(trashed = true).any { it.title == recording.title }) + } + + @Test + fun moveRecordings_SAF_to_SAF() = moveRecordings( + DocumentsContract.buildTreeDocumentUri(MOCK_PROVIDER_AUTHORITY, "Old audio"), + DocumentsContract.buildTreeDocumentUri(MOCK_PROVIDER_AUTHORITY, "New audio") + ) + + @Test + fun moveRecordings_SAF_to_MediaStore() = moveRecordings(DEFAULT_DOCUMENTS_URI, DEFAULT_MEDIA_URI) + + @Test + fun moveRecordings_MediaStore_to_SAF() = moveRecordings(DEFAULT_MEDIA_URI, DEFAULT_DOCUMENTS_URI) + + private fun moveRecordings(srcUri: Uri, dstUri: Uri) { + val srcStore = RecordingStore(context, srcUri) + + val normalRecording = srcStore.createRecording(makeTestName("recording-1"), RecordingFormat.OGG).let { uri -> + srcStore.all().find { it.uri == uri }!! + } + + val trashedRecording = srcStore.createRecording(makeTestName("recording-2"), RecordingFormat.OGG).let { uri -> + val recording = srcStore.all().find { it.uri == uri }!! + srcStore.trash(listOf(recording)) + srcStore.all(trashed = true).find { it.title == recording.title }!! + } + + srcStore.move(listOf(normalRecording, trashedRecording), srcUri, dstUri) + + assertFalse(srcStore.all(trashed = false).any { it.title == normalRecording.title }) + assertFalse(srcStore.all(trashed = true).any { it.title == normalRecording.title }) + assertFalse(srcStore.all(trashed = false).any { it.title == trashedRecording.title }) + assertFalse(srcStore.all(trashed = true).any { it.title == trashedRecording.title }) + + val dstStore = RecordingStore(context, dstUri) + + assertTrue(dstStore.all(trashed = false).any { it.title == normalRecording.title }) + assertTrue(dstStore.all(trashed = true).any { it.title == trashedRecording.title }) } private val context: Context diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt index 6ce285a9..7efbb7c8 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt @@ -51,38 +51,77 @@ class RecordingStore(private val context: Context, val uri: Uri) { /** * Are there no recordings in this store? */ - fun isEmpty(): Boolean = when (kind) { - Kind.DOCUMENT -> DocumentFile.fromTreeUri(context, uri)?.listFiles()?.any { it.isAudioRecording() } != true - Kind.MEDIA -> true - } + fun isEmpty(): Boolean = !isNotEmpty() /** * Are there any recordings in this store? */ - fun isNotEmpty(): Boolean = !isEmpty() + fun isNotEmpty(): Boolean = all().any() /** - * Returns all recordings in this store. + * Returns all recordings in this store as sequence. */ - fun getAll(trashed: Boolean = false): List = when (kind) { - Kind.DOCUMENT -> getAllDocuments(trashed) - Kind.MEDIA -> getAllMedia(trashed) + fun all(trashed: Boolean = false): Sequence = when (kind) { + Kind.DOCUMENT -> allDocuments(trashed) + Kind.MEDIA -> allMedia(trashed) } - private fun getAllDocuments(trashed: Boolean): List { - val parentUri = if (trashed) { + private fun allDocuments(trashed: Boolean): Sequence { + val treeUri = if (trashed) { trashFolder } else { uri } - Log.d(TAG, "getAllDocuments($parentUri)") + val projection = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + DocumentsContract.Document.COLUMN_SIZE + ) + + val treeDocumentId = DocumentsContract.getTreeDocumentId(treeUri) + val childDocumentsUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, treeDocumentId) - return parentUri?.let { DocumentFile.fromTreeUri(context, it) }?.listFiles()?.filter { it.isAudioRecording() }?.map { readRecordingFromFile(it) } - ?.toList() ?: emptyList() + val contentResolver = context.contentResolver + + return sequence { + contentResolver.query(childDocumentsUri, projection, null, null, null)?.use { cursor -> + val iDocumentId = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + val iDisplayName = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val iMimeType = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE) + val iLastModified = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED) + val iSize = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE) + + while (cursor.moveToNext()) { + val documentId = cursor.getString(iDocumentId) + val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId) + val mimeType = cursor.getString(iMimeType) + val displayName = cursor.getString(iDisplayName) + + if (mimeType?.startsWith("audio") != true || displayName.startsWith(".")) { + continue + } + + val duration = getDurationFromUri(uri).toInt() + + yield( + Recording( + id = documentId.hashCode(), + title = displayName, + uri = uri, + timestamp = cursor.getLong(iLastModified), + duration = duration, + size = cursor.getInt(iSize), + ) + ) + } + } + } } - private fun getAllMedia(trashed: Boolean): List { + private fun allMedia(trashed: Boolean): Sequence { val projection = arrayOf( MediaStore.Audio.Media._ID, MediaStore.Audio.Media.DATE_MODIFIED, @@ -91,7 +130,6 @@ class RecordingStore(private val context: Context, val uri: Uri) { MediaStore.Audio.Media.SIZE, ) - val cursor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val queryArgs = if (trashed) { Bundle().apply { @@ -117,38 +155,36 @@ class RecordingStore(private val context: Context, val uri: Uri) { context.contentResolver.query(uri, projection, selection, selectionArgs, null) } - val result = mutableListOf() - - cursor?.use { cursor -> - val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) - val timestampIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_MODIFIED) - val nameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) - val durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) - val sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE) - - while (cursor.moveToNext()) { - val id = cursor.getLong(idIndex) - val name = cursor.getString(nameIndex) - val size = cursor.getInt(sizeIndex) - val timestamp = cursor.getLong(timestampIndex) - val duration = cursor.getInt(durationIndex) - - val rowUri = ContentUris.withAppendedId(uri, id) - - result.add( - Recording( - id = id.toInt(), - title = name, - uri = rowUri, - timestamp = timestamp, - duration = duration, - size = size, + return sequence { + cursor?.use { cursor -> + val iId = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) + val iDateModified = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_MODIFIED) + val iDisplayName = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) + val iDuration = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) + val iSize = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE) + + while (cursor.moveToNext()) { + val id = cursor.getLong(iId) + val name = cursor.getString(iDisplayName) + val size = cursor.getInt(iSize) + val timestamp = cursor.getLong(iDateModified) + val duration = cursor.getInt(iDuration) + + val rowUri = ContentUris.withAppendedId(uri, id) + + yield( + Recording( + id = id.toInt(), + title = name, + uri = rowUri, + timestamp = timestamp, + duration = duration, + size = size, + ) ) - ) + } } } - - return result } fun trash(recordings: Collection): Boolean { @@ -215,7 +251,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { fun deleteTrashed( callback: (success: Boolean) -> Unit = {} - ) = ensureBackgroundThread { callback(delete(getAll(trashed = true))) } + ) = ensureBackgroundThread { callback(delete(all(trashed = true).toList())) } fun move(recordings: Collection, sourceParent: Uri, targetParent: Uri): Boolean { // TODO: handle media @@ -228,7 +264,6 @@ class RecordingStore(private val context: Context, val uri: Uri) { val targetParentDocumentUri = ensureParentDocumentUri(context, targetParent) if (sourceParent.authority == targetParent.authority) { - for (recording in recordings) { try { DocumentsContract.moveDocument( @@ -268,19 +303,9 @@ class RecordingStore(private val context: Context, val uri: Uri) { Kind.DOCUMENT -> getOrCreateDocument( context.contentResolver, uri, DocumentsContract.Document.MIME_TYPE_DIR, TRASH_FOLDER_NAME ) - Kind.MEDIA -> null } - private fun readRecordingFromFile(file: DocumentFile): Recording = Recording( - id = file.hashCode(), - title = file.name!!, - uri = file.uri, - timestamp = file.lastModified(), - duration = getDurationFromUri(file.uri).toInt(), - size = file.length().toInt() - ) - private fun getDurationFromUri(uri: Uri): Long { return try { val retriever = MediaMetadataRetriever() @@ -378,15 +403,3 @@ private fun ensureParentDocumentUri(context: Context, uri: Uri): Uri = when { else -> error("invalid URI, must be document or tree: $uri") } -internal fun DocumentFile.isAudioRecording() = type.let { it != null && it.startsWith("audio") } && name.let { it != null && !it.startsWith(".") } - -//@Deprecated( -// message = "Use getRecordings instead. This method is only here for backward compatibility.", replaceWith = ReplaceWith("getRecordings(trashed = true)") -//) -//private fun Context.getMediaStoreTrashedRecordings(): List { -// val trashedRegex = "^\\.trashed-\\d+-".toRegex() -// -// return config.saveRecordingsFolder?.let { DocumentFile.fromTreeUri(this, it) }?.listFiles()?.filter { it.isTrashedMediaStoreRecording() }?.map { -// readRecordingFromFile(it).copy(title = trashedRegex.replace(it.name!!, "")) -// }?.toList() ?: emptyList() -//} diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt index 8852bc83..47ddd23a 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt @@ -1,16 +1,13 @@ package org.fossify.voicerecorder.store import android.content.ContentResolver -import android.content.ContentValues import android.content.Context import android.net.Uri -import android.os.Build import android.os.ParcelFileDescriptor import android.provider.DocumentsContract import android.provider.MediaStore import java.io.File import java.io.FileInputStream -import java.nio.file.Files.createFile /** * Helper class to write recordings to the device. @@ -41,6 +38,8 @@ sealed class RecordingWriter { // Document providers not affected by the MediaStore bug private val DIRECT_AUTHORITIES = arrayOf("com.android.externalstorage.documents", MediaStore.AUTHORITY) + + private const val TAG = "RecordingWriter" } /** From 1699e06309f65b77564746c120b45c294517cf72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Tue, 27 Jan 2026 12:47:13 +0100 Subject: [PATCH 10/28] fix: handle moving recordings between all backends combinations --- .../activities/SettingsActivity.kt | 47 +-- .../adapters/RecordingsAdapter.kt | 8 +- .../voicerecorder/adapters/TrashAdapter.kt | 8 +- .../dialogs/MoveRecordingsDialog.kt | 7 +- .../voicerecorder/extensions/Activity.kt | 8 +- .../voicerecorder/extensions/Context.kt | 7 +- .../fragments/MyViewPagerFragment.kt | 2 +- .../voicerecorder/services/RecorderService.kt | 4 +- .../store/MockDocumentsProvider.kt | 1 + .../voicerecorder/store/RecordingStoreTest.kt | 71 +++-- .../fossify/voicerecorder/store/Recording.kt | 30 +- .../voicerecorder/store/RecordingStore.kt | 301 +++++++++--------- .../voicerecorder/store/RecordingWriter.kt | 36 ++- 13 files changed, 273 insertions(+), 257 deletions(-) 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 cff6175d..8acb5688 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt @@ -38,18 +38,15 @@ class SettingsActivity : SimpleActivity() { val oldUri = config.saveRecordingsFolder contentResolver.takePersistableUriPermission( - newUri, - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + newUri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION ) ensureBackgroundThread { - val hasRecordings = recordingStore.isNotEmpty() + val hasRecordings = !recordingStore.isEmpty() runOnUiThread { if (newUri != oldUri && hasRecordings) { MoveRecordingsDialog( - activity = this, - oldFolder = oldUri, - newFolder = newUri + activity = this, oldFolder = oldUri, newFolder = newUri ) { config.saveRecordingsFolder = newUri updateSaveRecordingsFolder(newUri) @@ -121,8 +118,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 { @@ -151,8 +147,7 @@ class SettingsActivity : SimpleActivity() { } private fun setupSaveRecordingsFolder() { - binding.settingsSaveRecordingsLabel.text = - addLockedLabelIfNeeded(R.string.save_recordings_in) + binding.settingsSaveRecordingsLabel.text = addLockedLabelIfNeeded(R.string.save_recordings_in) binding.settingsSaveRecordingsHolder.setOnClickListener { saveRecordingsFolderPicker.launch(config.saveRecordingsFolder) } @@ -193,10 +188,7 @@ class SettingsActivity : SimpleActivity() { private fun setupExtension() { binding.settingsExtension.text = config.recordingFormat.getDescription(this) binding.settingsExtensionHolder.setOnClickListener { - val items = RecordingFormat - .available - .map { RadioItem(it.value, it.getDescription(this), it) } - .let { ArrayList(it) } + val items = RecordingFormat.entries.map { RadioItem(it.value, it.getDescription(this), it) }.let { ArrayList(it) } RadioGroupDialog(this@SettingsActivity, items, config.recordingFormat.value) { val checked = it as RecordingFormat @@ -212,8 +204,7 @@ class SettingsActivity : SimpleActivity() { private fun setupBitrate() { binding.settingsBitrate.text = getBitrateText(config.bitrate) binding.settingsBitrateHolder.setOnClickListener { - val items = BITRATES[config.recordingFormat]!! - .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 @@ -231,8 +222,7 @@ class SettingsActivity : SimpleActivity() { 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) @@ -242,8 +232,7 @@ 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) { config.samplingRate = it as Int @@ -310,7 +299,7 @@ class SettingsActivity : SimpleActivity() { private fun setupEmptyRecycleBin() { ensureBackgroundThread { try { - recycleBinContentSize = recordingStore.getAll(trashed = true).sumByInt { it.size } + recycleBinContentSize = recordingStore.all(trashed = true).map { it.size }.sum() } catch (_: Exception) { } @@ -364,18 +353,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/adapters/RecordingsAdapter.kt b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt index 634a7196..22637461 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt @@ -184,10 +184,10 @@ class RecordingsAdapter( val positions = getSelectedItemPositions() ensureBackgroundThread { - if (activity.recordingStore.trash(recordingsToRemove)) { - doDeleteAnimation(oldRecordingIndex, recordingsToRemove, positions) - EventBus.getDefault().post(Events.RecordingTrashUpdated()) - } + activity.recordingStore.trash(recordingsToRemove) + + doDeleteAnimation(oldRecordingIndex, recordingsToRemove, positions) + EventBus.getDefault().post(Events.RecordingTrashUpdated()) } } 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 e9700bd4..82a49cda 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/TrashAdapter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/TrashAdapter.kt @@ -92,10 +92,10 @@ class TrashAdapter( val positions = getSelectedItemPositions() ensureBackgroundThread { - if (activity.recordingStore.restore(recordingsToRestore)) { - doDeleteAnimation(recordingsToRestore, positions) - EventBus.getDefault().post(Events.RecordingTrashUpdated()) - } + activity.recordingStore.restore(recordingsToRestore) + + doDeleteAnimation(recordingsToRestore, positions) + EventBus.getDefault().post(Events.RecordingTrashUpdated()) } } 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 7e09e837..a9427dce 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/MoveRecordingsDialog.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/MoveRecordingsDialog.kt @@ -10,7 +10,7 @@ import org.fossify.commons.helpers.MEDIUM_ALPHA import org.fossify.commons.helpers.ensureBackgroundThread import org.fossify.voicerecorder.R import org.fossify.voicerecorder.databinding.DialogMoveRecordingsBinding -import org.fossify.voicerecorder.extensions.recordingStore +import org.fossify.voicerecorder.store.RecordingStore class MoveRecordingsDialog( private val activity: BaseSimpleActivity, private val oldFolder: Uri, private val newFolder: Uri, private val callback: () -> Unit @@ -54,8 +54,9 @@ class MoveRecordingsDialog( } private fun moveAllRecordings() = ensureBackgroundThread { - activity.recordingStore.let { store -> - store.move(store.getAll(), oldFolder, newFolder) + RecordingStore(activity, oldFolder).let { store -> + // TODO: move also trash + store.move(store.all().toList(), newFolder) activity.runOnUiThread { callback() 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 5c00d6e2..08db142c 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt @@ -16,16 +16,12 @@ fun Activity.setKeepScreenAwake(keepScreenOn: Boolean) { } 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 store = recordingStore - val recordingsToRemove = store.getAll(trashed = true) - .filter { it.timestamp < System.currentTimeMillis() - MONTH_SECONDS * 1000L } + val recordingsToRemove = store.all(trashed = true).filter { it.timestamp < System.currentTimeMillis() - MONTH_SECONDS * 1000L }.toList() if (recordingsToRemove.isNotEmpty()) { store.delete(recordingsToRemove) } 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 c4f3d6ae..99d4c92e 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Context.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Context.kt @@ -9,8 +9,13 @@ import android.graphics.Canvas import android.graphics.drawable.Drawable import android.net.Uri import androidx.core.graphics.createBitmap -import org.fossify.voicerecorder.helpers.* +import org.fossify.voicerecorder.helpers.Config +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.store.RecordingStore +import java.util.Calendar +import java.util.Locale val Context.config: Config get() = Config.newInstance(applicationContext) 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 fec72f82..309c7932 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/MyViewPagerFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/MyViewPagerFragment.kt @@ -20,7 +20,7 @@ abstract class MyViewPagerFragment(context: Context, attributeSet: AttributeSet) open fun loadRecordings(trashed: Boolean = false) { onLoadingStart() ensureBackgroundThread { - val recordings = context.recordingStore.getAll(trashed).sortedByDescending { it.timestamp }.let { ArrayList(it) } + val recordings = context.recordingStore.all(trashed).sortedByDescending { it.timestamp }.toCollection(ArrayList()) (context as? Activity)?.runOnUiThread { onLoadingEnd(recordings) 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 9de8a00b..d9ac9438 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt @@ -6,6 +6,7 @@ import android.content.Intent import android.net.Uri import android.os.IBinder import android.util.Log +import android.util.Log.e import androidx.core.app.NotificationCompat import org.fossify.commons.extensions.getLaunchIntent import org.fossify.commons.extensions.showErrorToast @@ -85,8 +86,7 @@ class RecorderService : Service() { } val writer = recordingStore.createWriter( - getFormattedFilename(), - recordingFormat + "${getFormattedFilename()}.${recordingFormat.getExtension(this)}", ).also { this.writer = it } diff --git a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt index 7c43e8ef..d02b78ad 100644 --- a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt +++ b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt @@ -107,6 +107,7 @@ class MockDocumentsProvider() : DocumentsProvider() { val srcFile = File(root, sourceDocumentId) val dstFile = File(root, "$targetParentDocumentId/${srcFile.name}") + dstFile.parentFile?.mkdirs() srcFile.renameTo(dstFile) return dstFile.relativeTo(root).path diff --git a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt index 41b2a857..c0ae0a69 100644 --- a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt +++ b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt @@ -10,6 +10,7 @@ import android.os.Handler import android.os.HandlerThread import android.provider.DocumentsContract import android.provider.MediaStore +import android.webkit.MimeTypeMap import androidx.test.platform.app.InstrumentationRegistry import org.junit.After import org.junit.Assert.assertFalse @@ -56,8 +57,8 @@ class RecordingStoreTest { private fun createRecording(uri: Uri) { val store = RecordingStore(context, uri) - val name = makeTestName("sample") - val uri = store.createRecording(name, RecordingFormat.OGG) + val name = makeTestName("sample.ogg") + val uri = store.createRecording(name) val recording = store.all().find { it.uri == uri } assertNotNull(recording) @@ -75,8 +76,8 @@ class RecordingStoreTest { private fun trashRecording(uri: Uri) { val store = RecordingStore(context, uri) - val name = makeTestName("sample") - val uri = store.createRecording(name, RecordingFormat.OGG) + val name = makeTestName("sample.ogg") + val uri = store.createRecording(name) val recording = store.all().find { it.uri == uri }!! @@ -97,7 +98,7 @@ class RecordingStoreTest { private fun restoreRecording(uri: Uri) { val store = RecordingStore(context, uri) - val uri = store.createRecording(makeTestName("sample"), RecordingFormat.OGG) + val uri = store.createRecording(makeTestName("sample.ogg")) val recording = store.all(trashed = false).find { it.uri == uri }!! store.trash(listOf(recording)) @@ -123,8 +124,8 @@ class RecordingStoreTest { private fun deleteRecording(uri: Uri, trashed: Boolean) { val store = RecordingStore(context, uri) - val name = makeTestName("sample") - val uri = store.createRecording(name, RecordingFormat.OGG) + val name = makeTestName("sample.ogg") + val uri = store.createRecording(name) var recording = store.all().find { it.uri == uri }!! @@ -142,39 +143,45 @@ class RecordingStoreTest { @Test fun moveRecordings_SAF_to_SAF() = moveRecordings( DocumentsContract.buildTreeDocumentUri(MOCK_PROVIDER_AUTHORITY, "Old audio"), - DocumentsContract.buildTreeDocumentUri(MOCK_PROVIDER_AUTHORITY, "New audio") + DocumentsContract.buildTreeDocumentUri(MOCK_PROVIDER_AUTHORITY, "New audio"), + trash = false, ) @Test - fun moveRecordings_SAF_to_MediaStore() = moveRecordings(DEFAULT_DOCUMENTS_URI, DEFAULT_MEDIA_URI) + fun moveRecordings_SAF_to_MediaStore() = moveRecordings(DEFAULT_DOCUMENTS_URI, DEFAULT_MEDIA_URI, trash = false) @Test - fun moveRecordings_MediaStore_to_SAF() = moveRecordings(DEFAULT_MEDIA_URI, DEFAULT_DOCUMENTS_URI) + fun moveRecordings_MediaStore_to_SAF() = moveRecordings(DEFAULT_MEDIA_URI, DEFAULT_DOCUMENTS_URI, trash = false) - private fun moveRecordings(srcUri: Uri, dstUri: Uri) { + private fun moveRecordings(srcUri: Uri, dstUri: Uri, trash: Boolean) { val srcStore = RecordingStore(context, srcUri) + val dstStore = RecordingStore(context, dstUri) - val normalRecording = srcStore.createRecording(makeTestName("recording-1"), RecordingFormat.OGG).let { uri -> + val normalRecording = srcStore.createRecording(makeTestName("recording-1.ogg")).let { uri -> srcStore.all().find { it.uri == uri }!! } - val trashedRecording = srcStore.createRecording(makeTestName("recording-2"), RecordingFormat.OGG).let { uri -> + val trashedRecording = srcStore.createRecording(makeTestName("recording-2.ogg")).let { uri -> val recording = srcStore.all().find { it.uri == uri }!! srcStore.trash(listOf(recording)) srcStore.all(trashed = true).find { it.title == recording.title }!! } - srcStore.move(listOf(normalRecording, trashedRecording), srcUri, dstUri) + srcStore.move(srcStore.all(trashed = trash).toList(), dstUri, fromTrash = trash, toTrash = trash) - assertFalse(srcStore.all(trashed = false).any { it.title == normalRecording.title }) - assertFalse(srcStore.all(trashed = true).any { it.title == normalRecording.title }) - assertFalse(srcStore.all(trashed = false).any { it.title == trashedRecording.title }) - assertFalse(srcStore.all(trashed = true).any { it.title == trashedRecording.title }) + if (trash) { + assertFalse(srcStore.all(trashed = true).any { it.title == trashedRecording.title }) + assertTrue(dstStore.all(trashed = true).any { it.title == trashedRecording.title }) - val dstStore = RecordingStore(context, dstUri) + assertTrue(srcStore.all(trashed = false).any { it.title == normalRecording.title }) + assertFalse(dstStore.all(trashed = false).any { it.title == normalRecording.title }) + } else { + assertFalse(srcStore.all(trashed = false).any { it.title == normalRecording.title }) + assertTrue(dstStore.all(trashed = false).any { it.title == normalRecording.title }) - assertTrue(dstStore.all(trashed = false).any { it.title == normalRecording.title }) - assertTrue(dstStore.all(trashed = true).any { it.title == trashedRecording.title }) + assertTrue(srcStore.all(trashed = true).any { it.title == trashedRecording.title }) + assertFalse(dstStore.all(trashed = true).any { it.title == trashedRecording.title }) + } } private val context: Context @@ -183,7 +190,16 @@ class RecordingStoreTest { private val instrumentation get() = InstrumentationRegistry.getInstrumentation() - private fun makeTestName(name: String): String = "$name$testMediaSuffix.${System.currentTimeMillis()}" + private fun makeTestName(name: String): String { + val suffix = "$testMediaSuffix.${System.currentTimeMillis()}" + val lastDot = name.lastIndexOf('.') + + return if (lastDot >= 0) { + "${name.take(lastDot)}$suffix.${name.substring(lastDot + 1)}" + } else { + "$name$suffix" + } + } private fun deleteTestFiles() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { @@ -204,17 +220,16 @@ class RecordingStoreTest { private val contentObserverHandler = Handler(HandlerThread("contentObserver").apply { start() }.looper) - private fun RecordingStore.createRecording(name: String, format: RecordingFormat): Uri { - val inputFd = when (format) { - RecordingFormat.M4A -> TODO() - RecordingFormat.MP3 -> TODO() - RecordingFormat.OGG -> instrumentation.context.assets.openFd("sample.ogg") + private fun RecordingStore.createRecording(name: String): Uri { + val inputFd = when (MimeTypeMap.getFileExtensionFromUrl(name)) { + "ogg", "oga" -> instrumentation.context.assets.openFd("sample.ogg") + else -> throw NotImplementedError() } val inputSize = inputFd.length val input = inputFd.createInputStream() - val uri = createWriter(name, format).run { + val uri = createWriter(name).run { input.use { input -> FileOutputStream(fileDescriptor.fileDescriptor).use { output -> input.copyTo(output) diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/Recording.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/Recording.kt index e2b13bae..bef646bc 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/Recording.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/Recording.kt @@ -2,22 +2,13 @@ package org.fossify.voicerecorder.store import android.content.Context import android.net.Uri -import android.os.Build -import android.webkit.MimeTypeMap data class Recording( - val id: Int, - val title: String, - val uri: Uri, - val timestamp: Long, - val duration: Int, - val size: Int + val id: Int, val title: String, val uri: Uri, val timestamp: Long, val duration: Int, val size: Int, val mimeType: String ) enum class RecordingFormat(val value: Int) { - M4A(0), - MP3(1), - OGG(2); + M4A(0), MP3(1), OGG(2); companion object { fun fromInt(value: Int): RecordingFormat? = when (value) { @@ -26,30 +17,21 @@ enum class RecordingFormat(val value: Int) { OGG.value -> OGG else -> null } - - /** - * Return formats that are available on the current platform - */ - val available: List = arrayListOf(M4A, MP3).apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) add(OGG) - } } fun getDescription(context: Context): String = context.getString( when (this) { - RecordingFormat.M4A -> R.string.m4a - RecordingFormat.MP3 -> R.string.mp3_experimental + M4A -> R.string.m4a + MP3 -> R.string.mp3_experimental OGG -> R.string.ogg_opus } ) fun getExtension(context: Context): String = context.getString( when (this) { - RecordingFormat.M4A -> R.string.m4a - RecordingFormat.MP3 -> R.string.mp3 + M4A -> R.string.m4a + MP3 -> R.string.mp3 OGG -> R.string.ogg } ) - - fun getMimeType(context: Context): String = MimeTypeMap.getSingleton().getMimeTypeFromExtension(getExtension(context))!! } diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt index 7efbb7c8..8301850f 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt @@ -11,9 +11,6 @@ import android.os.Build import android.os.Bundle import android.provider.DocumentsContract import android.provider.MediaStore -import android.util.Log -import androidx.documentfile.provider.DocumentFile -import org.fossify.commons.helpers.ensureBackgroundThread import kotlin.math.roundToLong /** @@ -51,12 +48,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { /** * Are there no recordings in this store? */ - fun isEmpty(): Boolean = !isNotEmpty() - - /** - * Are there any recordings in this store? - */ - fun isNotEmpty(): Boolean = all().any() + fun isEmpty(): Boolean = all().none() /** * Returns all recordings in this store as sequence. @@ -68,7 +60,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { private fun allDocuments(trashed: Boolean): Sequence { val treeUri = if (trashed) { - trashFolder + trashFolder ?: return emptySequence() } else { uri } @@ -81,8 +73,12 @@ class RecordingStore(private val context: Context, val uri: Uri) { DocumentsContract.Document.COLUMN_SIZE ) - val treeDocumentId = DocumentsContract.getTreeDocumentId(treeUri) - val childDocumentsUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, treeDocumentId) + val parentDocumentId = if (DocumentsContract.isDocumentUri(context, treeUri)) { + DocumentsContract.getDocumentId(treeUri) + } else { + DocumentsContract.getTreeDocumentId(treeUri) + } + val childDocumentsUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocumentId) val contentResolver = context.contentResolver @@ -97,10 +93,12 @@ class RecordingStore(private val context: Context, val uri: Uri) { while (cursor.moveToNext()) { val documentId = cursor.getString(iDocumentId) val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId) - val mimeType = cursor.getString(iMimeType) + + + val mimeType = cursor.getString(iMimeType) ?: continue val displayName = cursor.getString(iDisplayName) - if (mimeType?.startsWith("audio") != true || displayName.startsWith(".")) { + if (!mimeType.startsWith("audio") || displayName.startsWith(".")) { continue } @@ -114,6 +112,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { timestamp = cursor.getLong(iLastModified), duration = duration, size = cursor.getInt(iSize), + mimeType = mimeType, ) ) } @@ -128,6 +127,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { MediaStore.Audio.Media.DISPLAY_NAME, MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.SIZE, + MediaStore.Audio.Media.MIME_TYPE ) val cursor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { @@ -162,24 +162,23 @@ class RecordingStore(private val context: Context, val uri: Uri) { val iDisplayName = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) val iDuration = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) val iSize = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE) + val iMimeType = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.MIME_TYPE) while (cursor.moveToNext()) { val id = cursor.getLong(iId) - val name = cursor.getString(iDisplayName) - val size = cursor.getInt(iSize) - val timestamp = cursor.getLong(iDateModified) - val duration = cursor.getInt(iDuration) - val rowUri = ContentUris.withAppendedId(uri, id) + val mimeType = cursor.getString(iMimeType) yield( Recording( id = id.toInt(), - title = name, +// title = removeExtension(cursor.getString(iDisplayName), mimeType), + title = cursor.getString(iDisplayName), uri = rowUri, - timestamp = timestamp, - duration = duration, - size = size, + timestamp = cursor.getLong(iDateModified), + duration = cursor.getInt(iDuration), + size = cursor.getInt(iSize), + mimeType = mimeType ) ) } @@ -187,101 +186,148 @@ class RecordingStore(private val context: Context, val uri: Uri) { } } - fun trash(recordings: Collection): Boolean { - val (documents, media) = recordings.partition { Kind.of(it.uri) == Kind.DOCUMENT } - var success = true + fun trash(recordings: Collection) = move(recordings, toTrash = true) - if (documents.isNotEmpty()) { - success = success and moveDocuments(documents, uri, getOrCreateTrashFolder()!!) - } + fun restore(recordings: Collection) = move(recordings, fromTrash = true) - if (media.isNotEmpty()) { - success = success and updateMediaTrashed(media, trash = true) - } + fun deleteTrashed(): Boolean = delete(all(trashed = true).toList()) - return success - } + fun move(recordings: Collection, dstUri: Uri? = null, fromTrash: Boolean = false, toTrash: Boolean = false) { + if (recordings.isEmpty()) { + return + } - fun restore(recordings: Collection): Boolean { - val (documents, media) = recordings.partition { Kind.of(it.uri) == Kind.DOCUMENT } - var success = true + val dstUri = dstUri ?: uri - if (documents.isNotEmpty()) { - trashFolder?.let { sourceParent -> - success = success and move(recordings, sourceParent, uri) + when (kind) { + Kind.DOCUMENT -> when (Kind.of(dstUri)) { + Kind.DOCUMENT -> moveDocumentsToDocuments(recordings, dstUri, fromTrash, toTrash) + Kind.MEDIA -> moveDocumentsToMedia(recordings, dstUri, toTrash) } - } - if (media.isNotEmpty()) { - success = success and updateMediaTrashed(media, trash = false) + Kind.MEDIA -> when (Kind.of(dstUri)) { + Kind.DOCUMENT -> moveMediaToDocuments(recordings, dstUri, toTrash) + Kind.MEDIA -> moveMediaToMedia(recordings, dstUri, toTrash) + } } - - return success } - private fun updateMediaTrashed(recordings: Collection, trash: Boolean): Boolean { + private fun moveDocumentsToDocuments(recordings: Collection, dstUri: Uri, fromTrash: Boolean, toTrash: Boolean) { val contentResolver = context.contentResolver - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val values = ContentValues().apply { - put(MediaStore.Audio.Media.IS_TRASHED, if (trash) 1 else 0) + val srcParentUri = ensureParentDocumentUri( + context, if (fromTrash) { + requireNotNull(trashFolder) + } else { + uri + } + ) + + val dstParentUri = ensureParentDocumentUri( + context, if (toTrash) { + getOrCreateTrashFolder(contentResolver, dstUri)!! + } else { + dstUri } + ) + if (srcParentUri.authority == dstParentUri.authority) { for (recording in recordings) { - contentResolver.update(recording.uri, values, null, null) + try { + DocumentsContract.moveDocument( + contentResolver, recording.uri, srcParentUri, dstParentUri + ) + } catch (@Suppress("SwallowedException") _: IllegalStateException) { + moveDocumentFallback(recording, dstParentUri) + } } } else { for (recording in recordings) { - val newName = if (trash) { - "${TRASHED_PREFIX}${recording.title}" - } else { - recording.title.removePrefix(TRASHED_PREFIX) - } - - val values = ContentValues().apply { - put(MediaStore.Audio.Media.DISPLAY_NAME, newName) - } - - contentResolver.update(recording.uri, values, null, null) + moveDocumentFallback(recording, dstParentUri) } } + } - return true + // Copy source to target, then delete source. Use as fallback when `DocumentsContract.moveDocument` can't used (e.g., when moving between different authorities) + private fun moveDocumentFallback( + src: Recording, + dstParentUri: Uri, + ) { + val contentResolver = context.contentResolver + val dstUri = DocumentsContract.createDocument( + contentResolver, dstParentUri, src.mimeType, src.title + )!! + + copyFile(contentResolver, src.uri, dstUri) + + DocumentsContract.deleteDocument(contentResolver, src.uri) + } + + private fun moveDocumentsToMedia(recordings: Collection, dstUri: Uri, toTrash: Boolean) { + for (recording in recordings) { + moveDocumentToMedia(recording, dstUri, toTrash) + } } - fun deleteTrashed( - callback: (success: Boolean) -> Unit = {} - ) = ensureBackgroundThread { callback(delete(all(trashed = true).toList())) } + private fun moveDocumentToMedia(recording: Recording, dstParentUri: Uri, toTrash: Boolean) { + val contentResolver = context.contentResolver + val dstUri = createMedia(contentResolver, dstParentUri, recording.title, recording.mimeType)!! + + copyFile(contentResolver, recording.uri, dstUri) - fun move(recordings: Collection, sourceParent: Uri, targetParent: Uri): Boolean { - // TODO: handle media - return moveDocuments(recordings, sourceParent, targetParent) + DocumentsContract.deleteDocument(contentResolver, recording.uri) + + if (toTrash) { + updateMediaTrashed(dstUri, recording.title, trash = true) + } } - private fun moveDocuments(recordings: Collection, sourceParent: Uri, targetParent: Uri): Boolean { + private fun moveMediaToDocuments(recordings: Collection, dstUri: Uri, toTrash: Boolean) { val contentResolver = context.contentResolver - val sourceParentDocumentUri = ensureParentDocumentUri(context, sourceParent) - val targetParentDocumentUri = ensureParentDocumentUri(context, targetParent) + val dstParentUri = if (toTrash) { + getOrCreateTrashFolder(contentResolver, dstUri)!! + } else { + dstUri + } - if (sourceParent.authority == targetParent.authority) { - for (recording in recordings) { - try { - DocumentsContract.moveDocument( - contentResolver, recording.uri, sourceParentDocumentUri, targetParentDocumentUri - ) - } catch (@Suppress("SwallowedException") _: IllegalStateException) { - moveFallback(recording.uri, targetParentDocumentUri) - } + for (recording in recordings) { + val dstUri = createDocument(context, dstParentUri, recording.title, recording.mimeType)!! + copyFile(contentResolver, recording.uri, dstUri) + contentResolver.delete(recording.uri, null, null) + } + } + + private fun moveMediaToMedia(recordings: Collection, dstUri: Uri, toTrash: Boolean) { + if (dstUri != uri) { + throw UnsupportedOperationException("moving recordings between different media stores not supported") + } + + for (recording in recordings) { + updateMediaTrashed(recording.uri, recording.title, trash = toTrash) + } + } + + private fun updateMediaTrashed(uri: Uri, title: String, trash: Boolean) { + val values = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + ContentValues().apply { + put(MediaStore.Audio.Media.IS_TRASHED, if (trash) 1 else 0) } } else { - for (recording in recordings) { - moveFallback(recording.uri, targetParentDocumentUri) + val newName = if (trash) { + "${TRASHED_PREFIX}$title" + } else { + title.removePrefix(TRASHED_PREFIX) + } + + ContentValues().apply { + put(MediaStore.Audio.Media.DISPLAY_NAME, newName) } } - return true + context.contentResolver.update(uri, values, null, null) } + fun delete(recordings: Collection): Boolean { val resolver = context.contentResolver @@ -292,20 +338,13 @@ class RecordingStore(private val context: Context, val uri: Uri) { return true } - fun createWriter(name: String, format: RecordingFormat): RecordingWriter = RecordingWriter.create(context, uri, name, format) + fun createWriter(name: String): RecordingWriter = RecordingWriter.create(context, uri, name) private val kind: Kind = Kind.of(uri) private val trashFolder: Uri? get() = findChildDocument(context.contentResolver, uri, TRASH_FOLDER_NAME) - private fun getOrCreateTrashFolder(): Uri? = when (kind) { - Kind.DOCUMENT -> getOrCreateDocument( - context.contentResolver, uri, DocumentsContract.Document.MIME_TYPE_DIR, TRASH_FOLDER_NAME - ) - Kind.MEDIA -> null - } - private fun getDurationFromUri(uri: Uri): Long { return try { val retriever = MediaMetadataRetriever() @@ -316,36 +355,6 @@ class RecordingStore(private val context: Context, val uri: Uri) { 0L } } - - // Copy source to target, then delete source. Use as fallback when `DocumentsContract.moveDocument` can't used (e.g., when moving between different authorities) - private fun moveFallback( - sourceUri: Uri, - targetParentUri: Uri, - ) { - Log.d(TAG, "moveFallback: src:$sourceUri dst:$targetParentUri") - - val contentResolver = context.contentResolver - - // TODO: media - - val sourceFile = DocumentFile.fromSingleUri(context, sourceUri)!! - val sourceName = requireNotNull(sourceFile.name) - val sourceType = requireNotNull(sourceFile.type) - - val targetUri = requireNotNull( - DocumentsContract.createDocument( - contentResolver, targetParentUri, sourceType, sourceName - ) - ) - - contentResolver.openInputStream(sourceUri)?.use { inputStream -> - contentResolver.openOutputStream(targetUri)?.use { outputStream -> - inputStream.copyTo(outputStream) - } - } - - DocumentsContract.deleteDocument(contentResolver, sourceUri) - } } private const val TRASH_FOLDER_NAME = ".trash" @@ -363,33 +372,28 @@ private enum class Kind { } } -internal fun createFile(context: Context, parentUri: Uri, name: String, format: RecordingFormat): Uri { - val uri = if (parentUri.authority == MediaStore.AUTHORITY) { - val values = ContentValues().apply { - put(MediaStore.Audio.Media.DISPLAY_NAME, name) - put(MediaStore.Audio.Media.MIME_TYPE, format.getMimeType(context)) +internal fun createDocument(context: Context, parentUri: Uri, name: String, mimeType: String): Uri? { + val parentDocumentUri = ensureParentDocumentUri(context, parentUri) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - put(MediaStore.Audio.Media.RELATIVE_PATH, DEFAULT_RECORDINGS_FOLDER) - } - } + return DocumentsContract.createDocument( + context.contentResolver, + parentDocumentUri, + mimeType, + name, + ) +} - context.contentResolver.insert(parentUri, values) - } else { - val parentDocumentUri = buildParentDocumentUri(parentUri) - val displayName = "$name.${format.getExtension(context)}" +internal fun createMedia(contentResolver: ContentResolver, parentUri: Uri, name: String, mimeType: String): Uri? { + val values = ContentValues().apply { + put(MediaStore.Audio.Media.DISPLAY_NAME, name) + put(MediaStore.Audio.Media.MIME_TYPE, mimeType) - DocumentsContract.createDocument( - context.contentResolver, - parentDocumentUri, - format.getMimeType(context), - displayName, - ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.Audio.Media.RELATIVE_PATH, DEFAULT_RECORDINGS_FOLDER) + } } - return requireNotNull(uri) { - "failed to create file '$name' in $parentUri" - } + return contentResolver.insert(parentUri, values) } internal fun deleteFile(contentResolver: ContentResolver, uri: Uri) = when (Kind.of(uri)) { @@ -397,9 +401,20 @@ internal fun deleteFile(contentResolver: ContentResolver, uri: Uri) = when (Kind Kind.DOCUMENT -> DocumentsContract.deleteDocument(contentResolver, uri) } +private fun copyFile(contentResolver: ContentResolver, srcUri: Uri, dstUri: Uri) = contentResolver.openInputStream(srcUri)?.use { inputStream -> + contentResolver.openOutputStream(dstUri)?.use { outputStream -> + inputStream.copyTo(outputStream) + } +} + + private fun ensureParentDocumentUri(context: Context, uri: Uri): Uri = when { DocumentsContract.isDocumentUri(context, uri) -> uri DocumentsContract.isTreeUri(uri) -> buildParentDocumentUri(uri) else -> error("invalid URI, must be document or tree: $uri") } +private fun getOrCreateTrashFolder(contentResolver: ContentResolver, parentUri: Uri): Uri? = getOrCreateDocument( + contentResolver, parentUri, DocumentsContract.Document.MIME_TYPE_DIR, TRASH_FOLDER_NAME +) + diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt index 47ddd23a..b12b0b3f 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt @@ -6,6 +6,7 @@ import android.net.Uri import android.os.ParcelFileDescriptor import android.provider.DocumentsContract import android.provider.MediaStore +import android.webkit.MimeTypeMap import java.io.File import java.io.FileInputStream @@ -17,24 +18,27 @@ import java.io.FileInputStream */ sealed class RecordingWriter { companion object { - fun create(context: Context, parentUri: Uri, name: String, format: RecordingFormat): RecordingWriter { - val direct = DIRECT_FORMATS.contains(format) or DIRECT_AUTHORITIES.contains(parentUri.authority) + fun create(context: Context, parentUri: Uri, name: String): RecordingWriter { + val extension = MimeTypeMap.getFileExtensionFromUrl(name) + val mimeType = extension?.let { MimeTypeMap.getSingleton().getMimeTypeFromExtension(it) } ?: "application/octet-stream" + + val direct = DIRECT_EXTENSIONS.contains(extension) or DIRECT_AUTHORITIES.contains(parentUri.authority) if (direct) { - val uri = createFile(context, parentUri, name, format) - val fileDescriptor = requireNotNull(context.contentResolver.openFileDescriptor(uri, "w")) { + val uri = createFile(context, parentUri, name, mimeType) + val fileDescriptor = checkNotNull(context.contentResolver.openFileDescriptor(uri, "w")) { "failed to open file descriptor at $uri" } return Direct(context.contentResolver, uri, fileDescriptor) } else { - return Workaround(context, parentUri, name, format) + return Workaround(context, parentUri, name, mimeType) } } - // Formats not affected by the MediaStore bug - private val DIRECT_FORMATS = arrayOf(RecordingFormat.MP3) + // Mime types not affected by the MediaStore bug + private val DIRECT_EXTENSIONS = arrayOf("mp3") // Document providers not affected by the MediaStore bug private val DIRECT_AUTHORITIES = arrayOf("com.android.externalstorage.documents", MediaStore.AUTHORITY) @@ -70,9 +74,9 @@ sealed class RecordingWriter { private val context: Context, private val parentTreeUri: Uri, private val name: String, - private val format: RecordingFormat + private val mimeType: String ) : RecordingWriter() { - private val tempFile: File = File(context.cacheDir, "$name.${format.getExtension(context)}.tmp") + private val tempFile: File = File(context.cacheDir, "$name.tmp") override val fileDescriptor: ParcelFileDescriptor get() = ParcelFileDescriptor.open( @@ -81,7 +85,7 @@ sealed class RecordingWriter { ) override fun commit(): Uri { - val dstUri = createFile(context, parentTreeUri, name, format) + val dstUri = createFile(context, parentTreeUri, name, mimeType) val dst = requireNotNull(context.contentResolver.openOutputStream(dstUri)) { "failed to open output stream at $dstUri" } @@ -104,3 +108,15 @@ sealed class RecordingWriter { } } } + +private fun createFile(context: Context, parentUri: Uri, name: String, mimeType: String): Uri { + val uri = if (parentUri.authority == MediaStore.AUTHORITY) { + createMedia(context.contentResolver, parentUri, name, mimeType) + } else { + createDocument(context, parentUri, name, mimeType) + } + + return requireNotNull(uri) { + "failed to create file '$name' in $parentUri" + } +} From 418759b92b8f20e959bb8580bed274b85be82ac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Tue, 27 Jan 2026 13:13:45 +0100 Subject: [PATCH 11/28] refactor: minor code cleanup --- .../voicerecorder/store/DocumentsUtils.kt | 12 +++-- .../voicerecorder/store/RecordingStore.kt | 48 +++++++------------ .../voicerecorder/store/RecordingWriter.kt | 2 +- 3 files changed, 27 insertions(+), 35 deletions(-) diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/DocumentsUtils.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/DocumentsUtils.kt index 4cbf4d4e..e7ad6894 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/DocumentsUtils.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/DocumentsUtils.kt @@ -6,14 +6,17 @@ import android.provider.DocumentsContract /** * Given a tree URI of some directory (such as obtained with `ACTION_OPEN_DOCUMENT_TREE` intent), - * returns a corresponding parent URI to create child documents in that directory. + * returns a corresponding parent URI that can be used to create child documents in it. */ -fun buildParentDocumentUri(treeUri: Uri): Uri { +internal fun buildParentDocumentUri(treeUri: Uri): Uri { val parentDocumentId = DocumentsContract.getTreeDocumentId(treeUri) return DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocumentId) } -fun findChildDocument(contentResolver: ContentResolver, treeUri: Uri, displayName: String): Uri? { +/** + * Finds the child document with the given name + */ +internal fun findChildDocument(contentResolver: ContentResolver, treeUri: Uri, displayName: String): Uri? { val parentDocumentId = DocumentsContract.getTreeDocumentId(treeUri) val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocumentId) @@ -39,6 +42,9 @@ fun findChildDocument(contentResolver: ContentResolver, treeUri: Uri, displayNam return null } +/** + * Returns the child document with the given name or creates it if it doesn't exists. + */ fun getOrCreateDocument(contentResolver: ContentResolver, treeUri: Uri, mimeType: String, displayName: String): Uri? { val uri = findChildDocument(contentResolver, treeUri, displayName) if (uri != null) return uri diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt index 8301850f..1faf3fcb 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt @@ -215,21 +215,17 @@ class RecordingStore(private val context: Context, val uri: Uri) { private fun moveDocumentsToDocuments(recordings: Collection, dstUri: Uri, fromTrash: Boolean, toTrash: Boolean) { val contentResolver = context.contentResolver - val srcParentUri = ensureParentDocumentUri( - context, if (fromTrash) { - requireNotNull(trashFolder) - } else { - uri - } - ) + val srcParentUri = if (fromTrash) { + requireNotNull(trashFolder) + } else { + buildParentDocumentUri(uri) + } - val dstParentUri = ensureParentDocumentUri( - context, if (toTrash) { - getOrCreateTrashFolder(contentResolver, dstUri)!! - } else { - dstUri - } - ) + val dstParentUri = if (toTrash) { + getOrCreateTrashFolder(contentResolver, dstUri)!! + } else { + buildParentDocumentUri(dstUri) + } if (srcParentUri.authority == dstParentUri.authority) { for (recording in recordings) { @@ -287,7 +283,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { val dstParentUri = if (toTrash) { getOrCreateTrashFolder(contentResolver, dstUri)!! } else { - dstUri + buildParentDocumentUri(dstUri) } for (recording in recordings) { @@ -372,16 +368,13 @@ private enum class Kind { } } -internal fun createDocument(context: Context, parentUri: Uri, name: String, mimeType: String): Uri? { - val parentDocumentUri = ensureParentDocumentUri(context, parentUri) +internal fun createDocument(context: Context, parentUri: Uri, name: String, mimeType: String): Uri? = DocumentsContract.createDocument( + context.contentResolver, + parentUri, + mimeType, + name, +) - return DocumentsContract.createDocument( - context.contentResolver, - parentDocumentUri, - mimeType, - name, - ) -} internal fun createMedia(contentResolver: ContentResolver, parentUri: Uri, name: String, mimeType: String): Uri? { val values = ContentValues().apply { @@ -407,13 +400,6 @@ private fun copyFile(contentResolver: ContentResolver, srcUri: Uri, dstUri: Uri) } } - -private fun ensureParentDocumentUri(context: Context, uri: Uri): Uri = when { - DocumentsContract.isDocumentUri(context, uri) -> uri - DocumentsContract.isTreeUri(uri) -> buildParentDocumentUri(uri) - else -> error("invalid URI, must be document or tree: $uri") -} - private fun getOrCreateTrashFolder(contentResolver: ContentResolver, parentUri: Uri): Uri? = getOrCreateDocument( contentResolver, parentUri, DocumentsContract.Document.MIME_TYPE_DIR, TRASH_FOLDER_NAME ) diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt index b12b0b3f..05af1207 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt @@ -113,7 +113,7 @@ private fun createFile(context: Context, parentUri: Uri, name: String, mimeType: val uri = if (parentUri.authority == MediaStore.AUTHORITY) { createMedia(context.contentResolver, parentUri, name, mimeType) } else { - createDocument(context, parentUri, name, mimeType) + createDocument(context, buildParentDocumentUri(parentUri), name, mimeType) } return requireNotNull(uri) { From 7f814e0c36c21e1272f5789d6405b21ac4258386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Tue, 27 Jan 2026 14:24:34 +0100 Subject: [PATCH 12/28] feat: implement RecordingStore migration --- .../dialogs/MoveRecordingsDialog.kt | 3 +- .../voicerecorder/store/RecordingStoreTest.kt | 84 ++++++++----------- .../voicerecorder/store/RecordingStore.kt | 47 ++++++++--- .../voicerecorder/store/RecordingWriter.kt | 9 ++ 4 files changed, 81 insertions(+), 62 deletions(-) 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 a9427dce..effcfbe1 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/MoveRecordingsDialog.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/MoveRecordingsDialog.kt @@ -55,8 +55,7 @@ class MoveRecordingsDialog( private fun moveAllRecordings() = ensureBackgroundThread { RecordingStore(activity, oldFolder).let { store -> - // TODO: move also trash - store.move(store.all().toList(), newFolder) + store.migrate(newFolder) activity.runOnUiThread { callback() diff --git a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt index c0ae0a69..72096ff9 100644 --- a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt +++ b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt @@ -2,7 +2,6 @@ package org.fossify.voicerecorder.store import android.content.ContentResolver import android.content.Context -import android.database.ContentObserver import android.net.Uri import android.os.Build import android.os.Bundle @@ -20,7 +19,6 @@ import org.junit.Before import org.junit.Test import java.io.File import java.io.FileOutputStream -import java.util.concurrent.CountDownLatch class RecordingStoreTest { companion object { @@ -141,47 +139,38 @@ class RecordingStoreTest { } @Test - fun moveRecordings_SAF_to_SAF() = moveRecordings( + fun migrate_SAF_to_SAF() = migrate( DocumentsContract.buildTreeDocumentUri(MOCK_PROVIDER_AUTHORITY, "Old audio"), DocumentsContract.buildTreeDocumentUri(MOCK_PROVIDER_AUTHORITY, "New audio"), - trash = false, ) @Test - fun moveRecordings_SAF_to_MediaStore() = moveRecordings(DEFAULT_DOCUMENTS_URI, DEFAULT_MEDIA_URI, trash = false) + fun migrate_SAF_to_MediaStore() = migrate(DEFAULT_DOCUMENTS_URI, DEFAULT_MEDIA_URI) @Test - fun moveRecordings_MediaStore_to_SAF() = moveRecordings(DEFAULT_MEDIA_URI, DEFAULT_DOCUMENTS_URI, trash = false) + fun migrate_MediaStore_to_SAF() = migrate(DEFAULT_MEDIA_URI, DEFAULT_DOCUMENTS_URI) - private fun moveRecordings(srcUri: Uri, dstUri: Uri, trash: Boolean) { + private fun migrate(srcUri: Uri, dstUri: Uri) { val srcStore = RecordingStore(context, srcUri) val dstStore = RecordingStore(context, dstUri) - val normalRecording = srcStore.createRecording(makeTestName("recording-1.ogg")).let { uri -> + val normalRecording = srcStore.createRecording(makeTestName("one.ogg")).let { uri -> srcStore.all().find { it.uri == uri }!! } - val trashedRecording = srcStore.createRecording(makeTestName("recording-2.ogg")).let { uri -> + val trashedRecording = srcStore.createRecording(makeTestName("two.ogg")).let { uri -> val recording = srcStore.all().find { it.uri == uri }!! srcStore.trash(listOf(recording)) srcStore.all(trashed = true).find { it.title == recording.title }!! } - srcStore.move(srcStore.all(trashed = trash).toList(), dstUri, fromTrash = trash, toTrash = trash) + srcStore.migrate(dstUri) - if (trash) { - assertFalse(srcStore.all(trashed = true).any { it.title == trashedRecording.title }) - assertTrue(dstStore.all(trashed = true).any { it.title == trashedRecording.title }) + assertFalse(srcStore.all(trashed = false).any { it.title == normalRecording.title }) + assertFalse(srcStore.all(trashed = true).any { it.title == trashedRecording.title }) - assertTrue(srcStore.all(trashed = false).any { it.title == normalRecording.title }) - assertFalse(dstStore.all(trashed = false).any { it.title == normalRecording.title }) - } else { - assertFalse(srcStore.all(trashed = false).any { it.title == normalRecording.title }) - assertTrue(dstStore.all(trashed = false).any { it.title == normalRecording.title }) - - assertTrue(srcStore.all(trashed = true).any { it.title == trashedRecording.title }) - assertFalse(dstStore.all(trashed = true).any { it.title == trashedRecording.title }) - } + assertTrue(dstStore.all(trashed = false).any { it.title == normalRecording.title }) + assertTrue(dstStore.all(trashed = true).any { it.title == trashedRecording.title }) } private val context: Context @@ -226,7 +215,6 @@ class RecordingStoreTest { else -> throw NotImplementedError() } - val inputSize = inputFd.length val input = inputFd.createInputStream() val uri = createWriter(name).run { @@ -239,35 +227,35 @@ class RecordingStoreTest { commit() } - // HACK: Wait until the recording reaches the expected size. This is because sometimes the recording has not been fully written yet at this point for - // some reason. This prevents some subsequent operations on the recording (e.g., move to trash) to fail. - waitUntilSize(uri, inputSize) +// // HACK: Wait until the recording reaches the expected size. This is because sometimes the recording has not been fully written yet at this point for +// // some reason. This prevents some subsequent operations on the recording (e.g., move to trash) to fail. +// waitUntilSize(uri, inputFd.length) return uri } - // Waits until the document/media at the given URI reaches the expected size - private fun waitUntilSize(uri: Uri, expectedSize: Long) { - val latch = CountDownLatch(1) - val observer = object : ContentObserver(contentObserverHandler) { - override fun onChange(selfChange: Boolean) { - super.onChange(selfChange) - - if (getSize(uri) >= expectedSize) { - latch.countDown() - } - } - } - - context.contentResolver.registerContentObserver(uri, false, observer) - - if (getSize(uri) < expectedSize) { - latch.await() - } - - context.contentResolver.unregisterContentObserver(observer) - } - +// // Waits until the document/media at the given URI reaches the expected size +// private fun waitUntilSize(uri: Uri, expectedSize: Long) { +// val latch = CountDownLatch(1) +// val observer = object : ContentObserver(contentObserverHandler) { +// override fun onChange(selfChange: Boolean) { +// super.onChange(selfChange) +// +// if (getSize(uri) >= expectedSize) { +// latch.countDown() +// } +// } +// } +// +// context.contentResolver.registerContentObserver(uri, false, observer) +// +// if (getSize(uri) < expectedSize) { +// latch.await() +// } +// +// context.contentResolver.unregisterContentObserver(observer) +// } +// private fun getSize(uri: Uri): Long { val column = when (uri.authority) { MediaStore.AUTHORITY -> MediaStore.Audio.Media.SIZE diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt index 1faf3fcb..97ac35d2 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt @@ -192,7 +192,19 @@ class RecordingStore(private val context: Context, val uri: Uri) { fun deleteTrashed(): Boolean = delete(all(trashed = true).toList()) - fun move(recordings: Collection, dstUri: Uri? = null, fromTrash: Boolean = false, toTrash: Boolean = false) { + /** + * Move all recordings in this store (including the trashed ones) into the new store. + */ + fun migrate(dstUri: Uri) { + if (dstUri == uri) { + return + } + + move(all(trashed = false).toList(), dstUri, fromTrash = false, toTrash = false) + move(all(trashed = true).toList(), dstUri, fromTrash = true, toTrash = true) + } + + private fun move(recordings: Collection, dstUri: Uri? = null, fromTrash: Boolean = false, toTrash: Boolean = false) { if (recordings.isEmpty()) { return } @@ -260,21 +272,19 @@ class RecordingStore(private val context: Context, val uri: Uri) { } private fun moveDocumentsToMedia(recordings: Collection, dstUri: Uri, toTrash: Boolean) { + val contentResolver = context.contentResolver for (recording in recordings) { - moveDocumentToMedia(recording, dstUri, toTrash) - } - } + val dstUri = createMedia(contentResolver, dstUri, recording.title, recording.mimeType)!! - private fun moveDocumentToMedia(recording: Recording, dstParentUri: Uri, toTrash: Boolean) { - val contentResolver = context.contentResolver - val dstUri = createMedia(contentResolver, dstParentUri, recording.title, recording.mimeType)!! + copyFile(contentResolver, recording.uri, dstUri) - copyFile(contentResolver, recording.uri, dstUri) + DocumentsContract.deleteDocument(contentResolver, recording.uri) - DocumentsContract.deleteDocument(contentResolver, recording.uri) + completeMedia(contentResolver, dstUri) - if (toTrash) { - updateMediaTrashed(dstUri, recording.title, trash = true) + if (toTrash) { + updateMediaTrashed(dstUri, recording.title, trash = true) + } } } @@ -295,7 +305,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { private fun moveMediaToMedia(recordings: Collection, dstUri: Uri, toTrash: Boolean) { if (dstUri != uri) { - throw UnsupportedOperationException("moving recordings between different media stores not supported") + throw UnsupportedOperationException("moving recordings between different media stores is not supported") } for (recording in recordings) { @@ -383,12 +393,25 @@ internal fun createMedia(contentResolver: ContentResolver, parentUri: Uri, name: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { put(MediaStore.Audio.Media.RELATIVE_PATH, DEFAULT_RECORDINGS_FOLDER) + put(MediaStore.Audio.Media.IS_PENDING, 1) } } return contentResolver.insert(parentUri, values) } +internal fun completeMedia(contentResolver: ContentResolver, uri: Uri) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return + } + + val values = ContentValues().apply { + put(MediaStore.Audio.Media.IS_PENDING, 0) + } + + contentResolver.update(uri, values, null, null) +} + internal fun deleteFile(contentResolver: ContentResolver, uri: Uri) = when (Kind.of(uri)) { Kind.MEDIA -> contentResolver.delete(uri, null, null) Kind.DOCUMENT -> DocumentsContract.deleteDocument(contentResolver, uri) diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt index 05af1207..6422adf3 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt @@ -60,6 +60,11 @@ sealed class RecordingWriter { RecordingWriter() { override fun commit(): Uri { fileDescriptor.close() + + if (uri.authority == MediaStore.AUTHORITY) { + completeMedia(contentResolver, uri) + } + return uri } @@ -100,6 +105,10 @@ sealed class RecordingWriter { tempFile.delete() + if (dstUri.authority == MediaStore.AUTHORITY) { + completeMedia(context.contentResolver, dstUri) + } + return dstUri } From e0655dfe56d02202422e1f11827efdffd7826b0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Tue, 27 Jan 2026 15:49:14 +0100 Subject: [PATCH 13/28] fix: some test failures on older android SDKs --- .../org/fossify/voicerecorder/helpers/Config.kt | 3 ++- .../store/MockDocumentsProvider.kt | 17 ++++++++++++----- .../voicerecorder/store/RecordingStoreTest.kt | 7 +++++-- .../fossify/voicerecorder/store/Constants.kt | 10 ---------- .../voicerecorder/store/RecordingStore.kt | 14 ++++++++++++-- 5 files changed, 31 insertions(+), 20 deletions(-) delete mode 100644 store/src/main/kotlin/org/fossify/voicerecorder/store/Constants.kt 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 010a50bb..b5be750c 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Config.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Config.kt @@ -9,6 +9,7 @@ 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.store.DEFAULT_MEDIA_URI import org.fossify.voicerecorder.store.RecordingFormat class Config(context: Context) : BaseConfig(context) { @@ -20,7 +21,7 @@ class Config(context: Context) : BaseConfig(context) { get() = when (val value = prefs.getString(SAVE_RECORDINGS, null)) { is String if value.startsWith("content:") -> value.toUri() is String -> context.createFirstParentTreeUri(value) - null -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + null -> DEFAULT_MEDIA_URI } set(uri) = prefs.edit { putString(SAVE_RECORDINGS, uri.toString()) } diff --git a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt index d02b78ad..2def8167 100644 --- a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt +++ b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt @@ -11,10 +11,14 @@ import java.net.URLConnection class MockDocumentsProvider() : DocumentsProvider() { companion object { - val DEFAULT_PROJECTION = arrayOf( + val DEFAULT_DOCUMENT_PROJECTION = arrayOf( DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Document.COLUMN_MIME_TYPE ) + val DEFAULT_ROOT_PROJECTION = arrayOf(DocumentsContract.Root.COLUMN_ROOT_ID, DocumentsContract.Root.COLUMN_DOCUMENT_ID) + + const val ROOT_ID = "main" + private const val TAG = "MockDocumentsProvider" } @@ -23,15 +27,18 @@ class MockDocumentsProvider() : DocumentsProvider() { override fun onCreate(): Boolean = true - override fun queryRoots(projection: Array): Cursor { - throw NotImplementedError() + override fun queryRoots(projection: Array?): Cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION).apply { + newRow().apply { + add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID) + add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, "") + } } override fun queryChildDocuments(parentDocumentId: String, projection: Array?, sortOrder: String?): Cursor { val root = requireNotNull(root) val parent = File(root, parentDocumentId) - return MatrixCursor(projection ?: DEFAULT_PROJECTION).apply { + return MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION).apply { for (file in parent.listFiles() ?: emptyArray()) { val documentId = file.relativeTo(root).path addFile(documentId, file) @@ -43,7 +50,7 @@ class MockDocumentsProvider() : DocumentsProvider() { val root = requireNotNull(root) val file = File(root, documentId) - return MatrixCursor(projection ?: DEFAULT_PROJECTION).apply { + return MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION).apply { addFile(documentId, file) } } diff --git a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt index 72096ff9..729456e4 100644 --- a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt +++ b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt @@ -1,5 +1,6 @@ package org.fossify.voicerecorder.store +import android.Manifest import android.content.ContentResolver import android.content.Context import android.net.Uri @@ -22,9 +23,7 @@ import java.io.FileOutputStream class RecordingStoreTest { companion object { - // TODO private const val MOCK_PROVIDER_AUTHORITY = "org.fossify.voicerecorder.store.mock.provider" - private val DEFAULT_MEDIA_URI = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI private val DEFAULT_DOCUMENTS_URI = DocumentsContract.buildTreeDocumentUri(MOCK_PROVIDER_AUTHORITY, "Recordings") private const val TAG = "RecordingStoreTest" } @@ -33,6 +32,10 @@ class RecordingStoreTest { @Before fun setup() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + instrumentation.uiAutomation.grantRuntimePermission(context.packageName, Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + tempDir = File(instrumentation.context.cacheDir, "temp-${System.currentTimeMillis()}") tempDir.mkdirs() diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/Constants.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/Constants.kt deleted file mode 100644 index 0de24a69..00000000 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/Constants.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.fossify.voicerecorder.store - -import android.os.Build -import android.os.Environment - -val DEFAULT_RECORDINGS_FOLDER = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - Environment.DIRECTORY_RECORDINGS -} else { - "Recordings" -} diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt index 97ac35d2..a42c78ea 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt @@ -9,15 +9,25 @@ import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.Environment import android.provider.DocumentsContract import android.provider.MediaStore import kotlin.math.roundToLong +val DEFAULT_MEDIA_URI = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + +val DEFAULT_MEDIA_DIRECTORY = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Environment.DIRECTORY_RECORDINGS +} else { + Environment.DIRECTORY_MUSIC +} + /** * Utility to manage stored recordings */ class RecordingStore(private val context: Context, val uri: Uri) { companion object { + private const val TAG = "RecordingStore" } @@ -35,7 +45,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { documentId.substringAfter(":").trimEnd('/') } - Kind.MEDIA -> DEFAULT_RECORDINGS_FOLDER + Kind.MEDIA -> DEFAULT_MEDIA_DIRECTORY } /** @@ -392,7 +402,7 @@ internal fun createMedia(contentResolver: ContentResolver, parentUri: Uri, name: put(MediaStore.Audio.Media.MIME_TYPE, mimeType) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - put(MediaStore.Audio.Media.RELATIVE_PATH, DEFAULT_RECORDINGS_FOLDER) + put(MediaStore.Audio.Media.RELATIVE_PATH, DEFAULT_MEDIA_DIRECTORY) put(MediaStore.Audio.Media.IS_PENDING, 1) } } From 34cb350a30b6daf66280f8c2890fed45314e3738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Tue, 27 Jan 2026 16:01:05 +0100 Subject: [PATCH 14/28] fix: all test failures on SDK 29 --- .../org/fossify/voicerecorder/store/RecordingStore.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt index a42c78ea..e4ae7c0e 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt @@ -179,11 +179,18 @@ class RecordingStore(private val context: Context, val uri: Uri) { val rowUri = ContentUris.withAppendedId(uri, id) val mimeType = cursor.getString(iMimeType) + val title = cursor.getString(iDisplayName).let { + if (trashed) { + it.removePrefix(TRASHED_PREFIX) + } else { + it + } + } + yield( Recording( id = id.toInt(), -// title = removeExtension(cursor.getString(iDisplayName), mimeType), - title = cursor.getString(iDisplayName), + title = title, uri = rowUri, timestamp = cursor.getLong(iDateModified), duration = cursor.getInt(iDuration), From 3f4513282781888cbaa70a234213e6726292dd7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Tue, 27 Jan 2026 17:33:17 +0100 Subject: [PATCH 15/28] fix: storage permissions on SDK 28 --- app/src/main/AndroidManifest.xml | 6 +- .../voicerecorder/activities/MainActivity.kt | 91 +++++++++---------- .../voicerecorder/extensions/Activity.kt | 23 ++++- .../fragments/MyViewPagerFragment.kt | 22 ++++- .../fragments/RecorderFragment.kt | 54 +++++------ 5 files changed, 104 insertions(+), 92 deletions(-) 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" /> - binding.mainTabsHolder.newTab() - .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 - ) - ) - - customView - ?.findViewById(org.fossify.commons.R.id.tab_item_label) - ?.setText(tabLabels[i]) - - AutofitHelper.create( - customView?.findViewById(org.fossify.commons.R.id.tab_item_label) + binding.mainTabsHolder.newTab().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 ) + ) - binding.mainTabsHolder.addTab(this) - } + customView?.findViewById(org.fossify.commons.R.id.tab_item_label)?.setText(tabLabels[i]) + + AutofitHelper.create( + customView?.findViewById(org.fossify.commons.R.id.tab_item_label) + ) + + binding.mainTabsHolder.addTab(this) + } } - 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 @@ -239,43 +242,31 @@ 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 = org.fossify.commons.R.string.faq_9_title_commons, - text = org.fossify.commons.R.string.faq_9_text_commons + 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 ) ) if (!resources.getBoolean(org.fossify.commons.R.bool.hide_google_relations)) { faqItems.add( FAQItem( - title = org.fossify.commons.R.string.faq_2_title_commons, - text = org.fossify.commons.R.string.faq_2_text_commons + title = org.fossify.commons.R.string.faq_2_title_commons, text = org.fossify.commons.R.string.faq_2_text_commons ) ) faqItems.add( FAQItem( - title = org.fossify.commons.R.string.faq_6_title_commons, - text = org.fossify.commons.R.string.faq_6_text_commons + title = org.fossify.commons.R.string.faq_6_title_commons, text = org.fossify.commons.R.string.faq_6_text_commons ) ) } startAboutActivity( - appNameId = R.string.app_name, - licenseMask = licenses, - versionName = BuildConfig.VERSION_NAME, - faqItems = faqItems, - showFAQBeforeMail = true + appNameId = R.string.app_name, licenseMask = licenses, versionName = BuildConfig.VERSION_NAME, faqItems = faqItems, showFAQBeforeMail = true ) } 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 08db142c..3eb9b3ea 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt @@ -1,11 +1,10 @@ package org.fossify.voicerecorder.extensions import android.app.Activity +import android.os.Build import android.view.WindowManager import org.fossify.commons.activities.BaseSimpleActivity -import org.fossify.commons.helpers.DAY_SECONDS -import org.fossify.commons.helpers.MONTH_SECONDS -import org.fossify.commons.helpers.ensureBackgroundThread +import org.fossify.commons.helpers.* fun Activity.setKeepScreenAwake(keepScreenOn: Boolean) { if (keepScreenOn) { @@ -31,3 +30,21 @@ fun BaseSimpleActivity.deleteExpiredTrashedRecordings() { } } } + +fun BaseSimpleActivity.handleStoragePermission(permission: StoragePermission, callback: (Boolean) -> Unit) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + handlePermission(permission.id, callback) + } else { + callback(true) + } +} + +enum class StoragePermission { + READ, WRITE; + + val id: Int + get() = when (this) { + READ -> PERMISSION_READ_STORAGE + WRITE -> PERMISSION_WRITE_STORAGE + } +} 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 309c7932..5e2e0117 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/MyViewPagerFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/MyViewPagerFragment.kt @@ -1,10 +1,12 @@ 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.activities.BaseSimpleActivity import org.fossify.commons.helpers.ensureBackgroundThread +import org.fossify.voicerecorder.extensions.StoragePermission +import org.fossify.voicerecorder.extensions.handleStoragePermission import org.fossify.voicerecorder.extensions.recordingStore import org.fossify.voicerecorder.store.Recording @@ -19,12 +21,22 @@ abstract class MyViewPagerFragment(context: Context, attributeSet: AttributeSet) open fun loadRecordings(trashed: Boolean = false) { onLoadingStart() - ensureBackgroundThread { - val recordings = context.recordingStore.all(trashed).sortedByDescending { it.timestamp }.toCollection(ArrayList()) - (context as? Activity)?.runOnUiThread { - onLoadingEnd(recordings) + (context as? BaseSimpleActivity)?.apply { + handleStoragePermission(StoragePermission.READ) { granted -> + if (granted) { + ensureBackgroundThread { + val recordings = recordingStore.all(trashed).sortedByDescending { it.timestamp }.toCollection(ArrayList()) + + runOnUiThread { + onLoadingEnd(recordings) + } + } + } else { + onLoadingEnd(ArrayList()) + } } } } } + 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 a34a9e31..47a2fde1 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt @@ -11,25 +11,14 @@ import org.fossify.commons.activities.BaseSimpleActivity import org.fossify.commons.compose.extensions.getActivity import org.fossify.commons.dialogs.ConfirmationDialog import org.fossify.commons.dialogs.PermissionRequiredDialog -import org.fossify.commons.extensions.applyColorFilter -import org.fossify.commons.extensions.beVisibleIf -import org.fossify.commons.extensions.getColoredDrawableWithColor -import org.fossify.commons.extensions.getContrastColor -import org.fossify.commons.extensions.getFormattedDuration -import org.fossify.commons.extensions.getProperPrimaryColor -import org.fossify.commons.extensions.getProperTextColor -import org.fossify.commons.extensions.openNotificationSettings -import org.fossify.commons.extensions.setDebouncedClickListener +import org.fossify.commons.extensions.* import org.fossify.voicerecorder.R import org.fossify.voicerecorder.databinding.FragmentRecorderBinding +import org.fossify.voicerecorder.extensions.StoragePermission import org.fossify.voicerecorder.extensions.config +import org.fossify.voicerecorder.extensions.handleStoragePermission import org.fossify.voicerecorder.extensions.setKeepScreenAwake -import org.fossify.voicerecorder.helpers.CANCEL_RECORDING -import org.fossify.voicerecorder.helpers.GET_RECORDER_INFO -import org.fossify.voicerecorder.helpers.RECORDING_PAUSED -import org.fossify.voicerecorder.helpers.RECORDING_RUNNING -import org.fossify.voicerecorder.helpers.RECORDING_STOPPED -import org.fossify.voicerecorder.helpers.TOGGLE_PAUSE +import org.fossify.voicerecorder.helpers.* import org.fossify.voicerecorder.models.Events import org.fossify.voicerecorder.services.RecorderService import org.greenrobot.eventbus.EventBus @@ -39,8 +28,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 @@ -76,18 +64,21 @@ class RecorderFragment( updateRecordingDuration(0) binding.toggleRecordingButton.setDebouncedClickListener { - (context as? BaseSimpleActivity)?.let { activity -> - activity.handleNotificationPermission { granted -> + (context as? BaseSimpleActivity)?.apply { + handleStoragePermission(StoragePermission.WRITE) { 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() + 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 { + // TODO: what do do here? } } } @@ -130,15 +121,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) @@ -193,8 +182,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 } } } From bb2a80ddfdfe11b83d284e759484a4e9d7507a83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Tue, 27 Jan 2026 18:00:13 +0100 Subject: [PATCH 16/28] fix: creating recordings in MediaStore on SDK 28 --- store/src/androidTest/AndroidManifest.xml | 5 ++++- .../voicerecorder/store/RecordingStoreTest.kt | 1 + .../voicerecorder/store/RecordingStore.kt | 20 ++++++++++++------- .../voicerecorder/store/RecordingWriter.kt | 2 +- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/store/src/androidTest/AndroidManifest.xml b/store/src/androidTest/AndroidManifest.xml index 00522454..17fe1cb9 100644 --- a/store/src/androidTest/AndroidManifest.xml +++ b/store/src/androidTest/AndroidManifest.xml @@ -1,7 +1,10 @@ + + android:maxSdkVersion="28" /> , dstUri: Uri, toTrash: Boolean) { - val contentResolver = context.contentResolver for (recording in recordings) { - val dstUri = createMedia(contentResolver, dstUri, recording.title, recording.mimeType)!! + val dstUri = createMedia(context, dstUri, recording.title, recording.mimeType)!! - copyFile(contentResolver, recording.uri, dstUri) + copyFile(context.contentResolver, recording.uri, dstUri) - DocumentsContract.deleteDocument(contentResolver, recording.uri) + DocumentsContract.deleteDocument(context.contentResolver, recording.uri) - completeMedia(contentResolver, dstUri) + completeMedia(context.contentResolver, dstUri) if (toTrash) { updateMediaTrashed(dstUri, recording.title, trash = true) @@ -403,7 +403,7 @@ internal fun createDocument(context: Context, parentUri: Uri, name: String, mime ) -internal fun createMedia(contentResolver: ContentResolver, parentUri: Uri, name: String, mimeType: String): Uri? { +internal fun createMedia(context: Context, parentUri: Uri, name: String, mimeType: String): Uri? { val values = ContentValues().apply { put(MediaStore.Audio.Media.DISPLAY_NAME, name) put(MediaStore.Audio.Media.MIME_TYPE, mimeType) @@ -412,9 +412,15 @@ internal fun createMedia(contentResolver: ContentResolver, parentUri: Uri, name: put(MediaStore.Audio.Media.RELATIVE_PATH, DEFAULT_MEDIA_DIRECTORY) put(MediaStore.Audio.Media.IS_PENDING, 1) } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + Environment.getExternalStoragePublicDirectory(DEFAULT_MEDIA_DIRECTORY)?.let { dir -> + put(MediaStore.Audio.Media.DATA, File(dir, name).toString()) + } + } } - return contentResolver.insert(parentUri, values) + return context.contentResolver.insert(parentUri, values) } internal fun completeMedia(contentResolver: ContentResolver, uri: Uri) { diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt index 6422adf3..0246e329 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt @@ -120,7 +120,7 @@ sealed class RecordingWriter { private fun createFile(context: Context, parentUri: Uri, name: String, mimeType: String): Uri { val uri = if (parentUri.authority == MediaStore.AUTHORITY) { - createMedia(context.contentResolver, parentUri, name, mimeType) + createMedia(context, parentUri, name, mimeType) } else { createDocument(context, buildParentDocumentUri(parentUri), name, mimeType) } From 262724c5de73d9769a4b0fc483ea631956dbcaf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Wed, 28 Jan 2026 13:51:55 +0100 Subject: [PATCH 17/28] fix: storage permissions on SDK 27 --- .../activities/SimpleActivity.kt | 49 ++++++++++ .../voicerecorder/extensions/Activity.kt | 24 ++--- .../fragments/MyViewPagerFragment.kt | 11 ++- .../fragments/RecorderFragment.kt | 12 +-- gradle/libs.versions.toml | 2 + store/README.md | 5 ++ store/build.gradle.kts | 1 + .../voicerecorder/store/RecordingStoreTest.kt | 90 +++++++++---------- .../voicerecorder/store/RecordingStore.kt | 4 +- 9 files changed, 116 insertions(+), 82 deletions(-) create mode 100644 store/README.md 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 effe1ede..b3da451d 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/SimpleActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/SimpleActivity.kt @@ -1,11 +1,19 @@ package org.fossify.voicerecorder.activities +import android.Manifest +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import org.fossify.commons.activities.BaseSimpleActivity import org.fossify.voicerecorder.R import org.fossify.voicerecorder.helpers.REPOSITORY_NAME open class SimpleActivity : BaseSimpleActivity() { + companion object { + private const val PERMISSIONS_REQUEST_CODE = 10001 + } + private var permissionCallback: ((Boolean?) -> Unit)? = null override fun getAppIconIDs() = arrayListOf( R.mipmap.ic_launcher_red, @@ -32,4 +40,45 @@ 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 correctly? The only thing we do + // differently here is that we invoke the callback even when the request gets cancelled (passing `null` to it). + fun handleExternalStoragePermissions(externalStoragePermission: ExternalStoragePermission, callback: (Boolean?) -> Unit) { + 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 + } + + permissionCallback = callback + + ActivityCompat.requestPermissions( + this, arrayOf(permission), PERMISSIONS_REQUEST_CODE + ); + } + + override fun onRequestPermissionsResult( + requestCode: Int, permissions: Array, grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + if (requestCode != PERMISSIONS_REQUEST_CODE) { + return + } + + val callback = permissionCallback + permissionCallback = null + + callback?.invoke(if (grantResults.isNotEmpty()) grantResults[0] == PackageManager.PERMISSION_GRANTED else null) + } +} + +enum class ExternalStoragePermission { + READ, WRITE + } 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 3eb9b3ea..7164cad5 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.os.Build import android.view.WindowManager import org.fossify.commons.activities.BaseSimpleActivity +import org.fossify.commons.extensions.hasPermission import org.fossify.commons.helpers.* fun Activity.setKeepScreenAwake(keepScreenOn: Boolean) { @@ -15,6 +16,12 @@ fun Activity.setKeepScreenAwake(keepScreenOn: Boolean) { } fun BaseSimpleActivity.deleteExpiredTrashedRecordings() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + if (!hasPermission(PERMISSION_READ_STORAGE) || !hasPermission(PERMISSION_WRITE_STORAGE)) { + return + } + } + if (config.useRecycleBin && config.lastRecycleBinCheck < System.currentTimeMillis() - DAY_SECONDS * 1000) { config.lastRecycleBinCheck = System.currentTimeMillis() ensureBackgroundThread { @@ -31,20 +38,3 @@ fun BaseSimpleActivity.deleteExpiredTrashedRecordings() { } } -fun BaseSimpleActivity.handleStoragePermission(permission: StoragePermission, callback: (Boolean) -> Unit) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - handlePermission(permission.id, callback) - } else { - callback(true) - } -} - -enum class StoragePermission { - READ, WRITE; - - val id: Int - get() = when (this) { - READ -> PERMISSION_READ_STORAGE - WRITE -> PERMISSION_WRITE_STORAGE - } -} 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 5e2e0117..cd86973e 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/MyViewPagerFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/MyViewPagerFragment.kt @@ -3,10 +3,9 @@ package org.fossify.voicerecorder.fragments import android.content.Context import android.util.AttributeSet import androidx.constraintlayout.widget.ConstraintLayout -import org.fossify.commons.activities.BaseSimpleActivity import org.fossify.commons.helpers.ensureBackgroundThread -import org.fossify.voicerecorder.extensions.StoragePermission -import org.fossify.voicerecorder.extensions.handleStoragePermission +import org.fossify.voicerecorder.activities.ExternalStoragePermission +import org.fossify.voicerecorder.activities.SimpleActivity import org.fossify.voicerecorder.extensions.recordingStore import org.fossify.voicerecorder.store.Recording @@ -22,9 +21,9 @@ abstract class MyViewPagerFragment(context: Context, attributeSet: AttributeSet) open fun loadRecordings(trashed: Boolean = false) { onLoadingStart() - (context as? BaseSimpleActivity)?.apply { - handleStoragePermission(StoragePermission.READ) { granted -> - if (granted) { + (context as? SimpleActivity)?.apply { + handleExternalStoragePermissions(ExternalStoragePermission.READ) { granted -> + if (granted == true) { ensureBackgroundThread { val recordings = recordingStore.all(trashed).sortedByDescending { it.timestamp }.toCollection(ArrayList()) 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 47a2fde1..2fdbda2d 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt @@ -13,10 +13,10 @@ import org.fossify.commons.dialogs.ConfirmationDialog import org.fossify.commons.dialogs.PermissionRequiredDialog import org.fossify.commons.extensions.* 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.StoragePermission import org.fossify.voicerecorder.extensions.config -import org.fossify.voicerecorder.extensions.handleStoragePermission import org.fossify.voicerecorder.extensions.setKeepScreenAwake import org.fossify.voicerecorder.helpers.* import org.fossify.voicerecorder.models.Events @@ -64,9 +64,9 @@ class RecorderFragment( updateRecordingDuration(0) binding.toggleRecordingButton.setDebouncedClickListener { - (context as? BaseSimpleActivity)?.apply { - handleStoragePermission(StoragePermission.WRITE) { granted -> - if (granted) { + (context as? SimpleActivity)?.apply { + handleExternalStoragePermissions(ExternalStoragePermission.WRITE) { granted -> + if (granted == true) { handleNotificationPermission { granted -> if (granted) { cycleRecordingState() @@ -78,7 +78,7 @@ class RecorderFragment( } } } else { - // TODO: what do do here? + // TODO: storage permission not granted. What should we do? } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 112fe711..d2d09c8b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,7 @@ app-build-targetSDK = "36" app-build-minimumSDK = "26" app-build-javaVersion = "VERSION_17" app-build-kotlinJVMTarget = "17" +rules = "1.7.0" [libraries] #AndroidX androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" } @@ -48,6 +49,7 @@ audiorecordview = { module = "com.github.Armen101:AudioRecordView", version.ref tandroidlame = { module = "com.github.naman14:TAndroidLame", version.ref = "tandroidlame" } #AutofitTextView autofittextview = { module = "me.grantland:autofittextview", version.ref = "autofittextview" } +androidx-rules = { group = "androidx.test", name = "rules", version.ref = "rules" } [plugins] androidApplication = { id = "com.android.application", version.ref = "gradlePlugins-agp" } androidLibrary = { id = "com.android.library", version.ref = "gradlePlugins-agp" } diff --git a/store/README.md b/store/README.md new file mode 100644 index 00000000..60d6c052 --- /dev/null +++ b/store/README.md @@ -0,0 +1,5 @@ +This module implements an abstraction layer on top of Android's [Storage Access Framework](https://developer.android.com/guide/topics/providers/document-provider) +and [Media Store](https://developer.android.com/training/data-storage/shared/media) allowing to access both of them using an unified API. Using this, the Voice +Recorder app can store the recordings in the default location (using *MediaStore*) which should be suitable for most use cases but allows to change it to any +directory on the external storage or even in `DocumentsProvider`s exposed by other apps (using *Storage Access Framework*) for more advanced uses cases (e.g., +cloud storage). diff --git a/store/build.gradle.kts b/store/build.gradle.kts index dd5e6992..7356f686 100644 --- a/store/build.gradle.kts +++ b/store/build.gradle.kts @@ -25,5 +25,6 @@ android { dependencies { implementation(libs.fossify.commons) implementation(libs.androidx.documentfile) + implementation(libs.androidx.rules) androidTestImplementation(libs.androidx.test.runner) } diff --git a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt index d234fff3..3e3f556d 100644 --- a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt +++ b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt @@ -6,18 +6,20 @@ import android.content.Context import android.net.Uri import android.os.Build import android.os.Bundle -import android.os.Handler -import android.os.HandlerThread import android.provider.DocumentsContract import android.provider.MediaStore import android.webkit.MimeTypeMap import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule import org.junit.After import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestRule import java.io.File import java.io.FileOutputStream @@ -28,15 +30,19 @@ class RecordingStoreTest { private const val TAG = "RecordingStoreTest" } + @get:Rule + val permissionRule: TestRule = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + GrantPermissionRule.grant( + Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE + ) + } else { + RuleChain.emptyRuleChain() + } + private lateinit var tempDir: File @Before fun setup() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - instrumentation.uiAutomation.grantRuntimePermission(context.packageName, Manifest.permission.READ_EXTERNAL_STORAGE) - instrumentation.uiAutomation.grantRuntimePermission(context.packageName, Manifest.permission.WRITE_EXTERNAL_STORAGE) - } - tempDir = File(instrumentation.context.cacheDir, "temp-${System.currentTimeMillis()}") tempDir.mkdirs() @@ -211,8 +217,6 @@ class RecordingStoreTest { private val testMediaSuffix get() = ".${context.packageName}" - private val contentObserverHandler = Handler(HandlerThread("contentObserver").apply { start() }.looper) - private fun RecordingStore.createRecording(name: String): Uri { val inputFd = when (MimeTypeMap.getFileExtensionFromUrl(name)) { "ogg", "oga" -> instrumentation.context.assets.openFd("sample.ogg") @@ -231,52 +235,38 @@ class RecordingStoreTest { commit() } -// // HACK: Wait until the recording reaches the expected size. This is because sometimes the recording has not been fully written yet at this point for -// // some reason. This prevents some subsequent operations on the recording (e.g., move to trash) to fail. -// waitUntilSize(uri, inputFd.length) - return uri } -// // Waits until the document/media at the given URI reaches the expected size -// private fun waitUntilSize(uri: Uri, expectedSize: Long) { -// val latch = CountDownLatch(1) -// val observer = object : ContentObserver(contentObserverHandler) { -// override fun onChange(selfChange: Boolean) { -// super.onChange(selfChange) -// -// if (getSize(uri) >= expectedSize) { -// latch.countDown() -// } -// } -// } -// -// context.contentResolver.registerContentObserver(uri, false, observer) -// -// if (getSize(uri) < expectedSize) { -// latch.await() -// } -// -// context.contentResolver.unregisterContentObserver(observer) -// } -// - private fun getSize(uri: Uri): Long { - val column = when (uri.authority) { - MediaStore.AUTHORITY -> MediaStore.Audio.Media.SIZE - else -> DocumentsContract.Document.COLUMN_SIZE - } - - val projection = arrayOf(column) - - val size = context.contentResolver.query(uri, projection, null, null, null)?.use { cursor -> - val iSize = cursor.getColumnIndexOrThrow(column) - if (cursor.moveToNext()) { - cursor.getLong(iSize) + private fun getSize(uri: Uri): Long = when (uri.authority) { + MediaStore.AUTHORITY -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + queryLong(uri, MediaStore.Audio.Media.SIZE) } else { - null + // On SDK 28 or lower, querying for SIZE seems to always return 0, but on those SDKs we can get the actual media file and get + // its size directly. + context.contentResolver.query(uri, arrayOf(MediaStore.Audio.Media.DATA), null, null, null)?.use { cursor -> + if (cursor.moveToNext()) { + cursor.getString(0) + } else { + null + } + }?.let { File(it).length() } ?: 0 } - } ?: 0 + } - return size + else -> queryLong(uri, DocumentsContract.Document.COLUMN_SIZE) } + + private fun queryLong(uri: Uri, column: String): Long = context.contentResolver.query(uri, arrayOf(column), null, null, null)?.use { cursor -> + if (cursor.moveToNext()) { + cursor.getLong(0) + } else { + null + } + } ?: 0 } + + + + diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt index 85456e81..72289051 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt @@ -411,9 +411,7 @@ internal fun createMedia(context: Context, parentUri: Uri, name: String, mimeTyp if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { put(MediaStore.Audio.Media.RELATIVE_PATH, DEFAULT_MEDIA_DIRECTORY) put(MediaStore.Audio.Media.IS_PENDING, 1) - } - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + } else { Environment.getExternalStoragePublicDirectory(DEFAULT_MEDIA_DIRECTORY)?.let { dir -> put(MediaStore.Audio.Media.DATA, File(dir, name).toString()) } From 6dd9160088d948099dfdef4faf6a548f9b724407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Wed, 28 Jan 2026 13:59:30 +0100 Subject: [PATCH 18/28] chore: add doc comments --- .../voicerecorder/store/RecordingStore.kt | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt index 72289051..e29d6e7a 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt @@ -25,6 +25,9 @@ val DEFAULT_MEDIA_DIRECTORY = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S /** * Utility to manage stored recordings + * + * Provides unified API on top of [Storage Access Framework](https://developer.android.com/guide/topics/providers/document-provider) and + * [Media Store](https://developer.android.com/training/data-storage/shared/media). */ class RecordingStore(private val context: Context, val uri: Uri) { companion object { @@ -62,7 +65,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { fun isEmpty(): Boolean = all().none() /** - * Returns all recordings in this store as sequence. + * Returns all recordings in this store as [Sequence]. */ fun all(trashed: Boolean = false): Sequence = when (kind) { Kind.DOCUMENT -> allDocuments(trashed) @@ -204,14 +207,23 @@ class RecordingStore(private val context: Context, val uri: Uri) { } } + /** + * Move the given recordings to trash. + */ fun trash(recordings: Collection) = move(recordings, toTrash = true) + /** + * Restore the given trashed recordings + */ fun restore(recordings: Collection) = move(recordings, fromTrash = true) + /** + * Permanently delete all trashed recordings. + */ fun deleteTrashed(): Boolean = delete(all(trashed = true).toList()) /** - * Move all recordings in this store (including the trashed ones) into the new store. + * Move all recordings in this store (including the trashed ones) into a new store at the given URI. */ fun migrate(dstUri: Uri) { if (dstUri == uri) { @@ -351,6 +363,9 @@ class RecordingStore(private val context: Context, val uri: Uri) { } + /** + * Permanently delete (skipping the trash) the given recordings. + */ fun delete(recordings: Collection): Boolean { val resolver = context.contentResolver @@ -361,6 +376,10 @@ class RecordingStore(private val context: Context, val uri: Uri) { return true } + /** + * Create a [RecordingWriter] for writing a new recording with the given name. The name should contain the file extension (ogg, mp3, ...) which is used to + * select the format the recording will be stored in. + */ fun createWriter(name: String): RecordingWriter = RecordingWriter.create(context, uri, name) private val kind: Kind = Kind.of(uri) From 8bdf58eb2e773510f470eae025e09b95f30fbfb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Wed, 28 Jan 2026 14:03:43 +0100 Subject: [PATCH 19/28] refactor: rename Kind -> Backend --- .../voicerecorder/store/RecordingStore.kt | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt index e29d6e7a..1e37b64f 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt @@ -43,13 +43,13 @@ class RecordingStore(private val context: Context, val uri: Uri) { * Short, human-readable name of this store */ val shortName: String - get() = when (kind) { - Kind.DOCUMENT -> { + get() = when (backend) { + Backend.DOCUMENT -> { val documentId = DocumentsContract.getTreeDocumentId(uri) documentId.substringAfter(":").trimEnd('/') } - Kind.MEDIA -> DEFAULT_MEDIA_DIRECTORY + Backend.MEDIA -> DEFAULT_MEDIA_DIRECTORY } /** @@ -67,9 +67,9 @@ class RecordingStore(private val context: Context, val uri: Uri) { /** * Returns all recordings in this store as [Sequence]. */ - fun all(trashed: Boolean = false): Sequence = when (kind) { - Kind.DOCUMENT -> allDocuments(trashed) - Kind.MEDIA -> allMedia(trashed) + fun all(trashed: Boolean = false): Sequence = when (backend) { + Backend.DOCUMENT -> allDocuments(trashed) + Backend.MEDIA -> allMedia(trashed) } private fun allDocuments(trashed: Boolean): Sequence { @@ -241,15 +241,15 @@ class RecordingStore(private val context: Context, val uri: Uri) { val dstUri = dstUri ?: uri - when (kind) { - Kind.DOCUMENT -> when (Kind.of(dstUri)) { - Kind.DOCUMENT -> moveDocumentsToDocuments(recordings, dstUri, fromTrash, toTrash) - Kind.MEDIA -> moveDocumentsToMedia(recordings, dstUri, toTrash) + when (backend) { + Backend.DOCUMENT -> when (Backend.of(dstUri)) { + Backend.DOCUMENT -> moveDocumentsToDocuments(recordings, dstUri, fromTrash, toTrash) + Backend.MEDIA -> moveDocumentsToMedia(recordings, dstUri, toTrash) } - Kind.MEDIA -> when (Kind.of(dstUri)) { - Kind.DOCUMENT -> moveMediaToDocuments(recordings, dstUri, toTrash) - Kind.MEDIA -> moveMediaToMedia(recordings, dstUri, toTrash) + Backend.MEDIA -> when (Backend.of(dstUri)) { + Backend.DOCUMENT -> moveMediaToDocuments(recordings, dstUri, toTrash) + Backend.MEDIA -> moveMediaToMedia(recordings, dstUri, toTrash) } } } @@ -382,7 +382,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { */ fun createWriter(name: String): RecordingWriter = RecordingWriter.create(context, uri, name) - private val kind: Kind = Kind.of(uri) + private val backend: Backend = Backend.of(uri) private val trashFolder: Uri? get() = findChildDocument(context.contentResolver, uri, TRASH_FOLDER_NAME) @@ -402,11 +402,12 @@ class RecordingStore(private val context: Context, val uri: Uri) { private const val TRASH_FOLDER_NAME = ".trash" private const val TRASHED_PREFIX = ".trashed-" -private enum class Kind { +// Storage backend: Storage Access Framework or Media Store +private enum class Backend { DOCUMENT, MEDIA; companion object { - fun of(uri: Uri): Kind = if (uri.authority == MediaStore.AUTHORITY) { + fun of(uri: Uri): Backend = if (uri.authority == MediaStore.AUTHORITY) { MEDIA } else { DOCUMENT @@ -452,9 +453,9 @@ internal fun completeMedia(contentResolver: ContentResolver, uri: Uri) { contentResolver.update(uri, values, null, null) } -internal fun deleteFile(contentResolver: ContentResolver, uri: Uri) = when (Kind.of(uri)) { - Kind.MEDIA -> contentResolver.delete(uri, null, null) - Kind.DOCUMENT -> DocumentsContract.deleteDocument(contentResolver, uri) +internal fun deleteFile(contentResolver: ContentResolver, uri: Uri) = when (Backend.of(uri)) { + Backend.MEDIA -> contentResolver.delete(uri, null, null) + Backend.DOCUMENT -> DocumentsContract.deleteDocument(contentResolver, uri) } private fun copyFile(contentResolver: ContentResolver, srcUri: Uri, dstUri: Uri) = contentResolver.openInputStream(srcUri)?.use { inputStream -> From 57b5f4bc94a702c2fc585cd40c2a912f8d3d1c67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Wed, 28 Jan 2026 17:58:19 +0100 Subject: [PATCH 20/28] fix: incorrect metadata on MediaStore backend --- .../activities/SimpleActivity.kt | 7 ++ .../store/MockDocumentsProvider.kt | 3 + .../voicerecorder/store/RecordingStoreTest.kt | 53 +++++++------- .../voicerecorder/store/RecordingStore.kt | 69 +++++++++++++------ 4 files changed, 84 insertions(+), 48 deletions(-) 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 b3da451d..cce0ca68 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/SimpleActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/SimpleActivity.kt @@ -2,6 +2,7 @@ package org.fossify.voicerecorder.activities import android.Manifest import android.content.pm.PackageManager +import android.os.Build import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import org.fossify.commons.activities.BaseSimpleActivity @@ -45,6 +46,12 @@ open class SimpleActivity : BaseSimpleActivity() { // because this app invokes the permission request from multiple places and `BaseSimpleActivity` doesn't handle it correctly? The only thing we do // differently here is that we invoke the callback even when the request gets cancelled (passing `null` to it). fun handleExternalStoragePermissions(externalStoragePermission: ExternalStoragePermission, callback: (Boolean?) -> Unit) { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + // 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 diff --git a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt index 2def8167..b4968651 100644 --- a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt +++ b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/MockDocumentsProvider.kt @@ -80,6 +80,9 @@ class MockDocumentsProvider() : DocumentsProvider() { row.add(DocumentsContract.Document.COLUMN_SIZE, file.length()) } + if (columnNames.contains(DocumentsContract.Document.COLUMN_LAST_MODIFIED)) { + row.add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, file.lastModified()) + } } override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean = documentId.startsWith("$parentDocumentId/") diff --git a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt index 3e3f556d..671a59c5 100644 --- a/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt +++ b/store/src/androidTest/kotlin/org/fossify/voicerecorder/store/RecordingStoreTest.kt @@ -3,6 +3,7 @@ package org.fossify.voicerecorder.store import android.Manifest import android.content.ContentResolver import android.content.Context +import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build import android.os.Bundle @@ -12,8 +13,8 @@ import android.webkit.MimeTypeMap import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule @@ -22,6 +23,7 @@ import org.junit.rules.RuleChain import org.junit.rules.TestRule import java.io.File import java.io.FileOutputStream +import kotlin.math.roundToInt class RecordingStoreTest { companion object { @@ -64,15 +66,34 @@ class RecordingStoreTest { private fun createRecording(uri: Uri) { val store = RecordingStore(context, uri) - val name = makeTestName("sample.ogg") + + val timeBefore = System.currentTimeMillis() val uri = store.createRecording(name) + val timeAfter = System.currentTimeMillis() + + val recording = store.all().find { it.uri == uri }!! + + val orig = instrumentation.context.assets.openFd("sample.ogg") + val origSize = orig.length + + val origDuration = MediaMetadataRetriever().run { + try { + setDataSource(orig.fileDescriptor, orig.startOffset, orig.length) + extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + } finally { + release() + } + }?.let { + (it.toLong() / 1000.toDouble()).roundToInt() + } ?: 0 - val recording = store.all().find { it.uri == uri } - assertNotNull(recording) + assertEquals(origSize, recording.size.toLong()) + assertEquals(origDuration, recording.duration) - val size = getSize(uri) - assertTrue(size > 0) + val tolerance = 1000 // the timestamps are rounded to nearest second, so adding 1-second tolerance to account for that. + assertTrue(recording.timestamp > timeBefore - tolerance) + assertTrue(recording.timestamp < timeAfter + tolerance) } @Test @@ -238,26 +259,6 @@ class RecordingStoreTest { return uri } - private fun getSize(uri: Uri): Long = when (uri.authority) { - MediaStore.AUTHORITY -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - queryLong(uri, MediaStore.Audio.Media.SIZE) - } else { - // On SDK 28 or lower, querying for SIZE seems to always return 0, but on those SDKs we can get the actual media file and get - // its size directly. - context.contentResolver.query(uri, arrayOf(MediaStore.Audio.Media.DATA), null, null, null)?.use { cursor -> - if (cursor.moveToNext()) { - cursor.getString(0) - } else { - null - } - }?.let { File(it).length() } ?: 0 - } - } - - else -> queryLong(uri, DocumentsContract.Document.COLUMN_SIZE) - } - private fun queryLong(uri: Uri, column: String): Long = context.contentResolver.query(uri, arrayOf(column), null, null, null)?.use { cursor -> if (cursor.moveToNext()) { cursor.getLong(0) diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt index 1e37b64f..05fcd85c 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt @@ -13,11 +13,11 @@ import android.os.Environment import android.provider.DocumentsContract import android.provider.MediaStore import java.io.File -import kotlin.math.roundToLong +import kotlin.math.roundToInt -val DEFAULT_MEDIA_URI = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI +val DEFAULT_MEDIA_URI: Uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI -val DEFAULT_MEDIA_DIRECTORY = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { +val DEFAULT_MEDIA_DIRECTORY: String = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { Environment.DIRECTORY_RECORDINGS } else { Environment.DIRECTORY_MUSIC @@ -116,7 +116,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { continue } - val duration = getDurationFromUri(uri).toInt() + val duration = getDuration(MetadataSource.Uri(context, uri)) yield( Recording( @@ -137,6 +137,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { private fun allMedia(trashed: Boolean): Sequence { val projection = arrayOf( MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.DATA, MediaStore.Audio.Media.DATE_MODIFIED, MediaStore.Audio.Media.DISPLAY_NAME, MediaStore.Audio.Media.DURATION, @@ -172,6 +173,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { return sequence { cursor?.use { cursor -> val iId = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) + val iData = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA) val iDateModified = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_MODIFIED) val iDisplayName = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) val iDuration = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) @@ -191,15 +193,27 @@ class RecordingStore(private val context: Context, val uri: Uri) { } } + + // Note: On SDK 28 and lower, the value of `DATE_MODIFIED`, `DURATION` and `SIZE` columns seem to be always zero for some reason. To + // work around it, we retrieve them from the media file directly (which is still allowed on those SDKs) + val timestamp: Long + val duration: Int + val size: Int + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + timestamp = cursor.getInt(iDateModified).toLong() * 1000 + duration = cursor.getLong(iDuration).toSeconds() + size = cursor.getInt(iSize) + } else { + val file = File(cursor.getString(iData)) + timestamp = file.lastModified() + duration = getDuration(MetadataSource.Path(file.path)) + size = file.length().toInt() + } + yield( Recording( - id = id.toInt(), - title = title, - uri = rowUri, - timestamp = cursor.getLong(iDateModified), - duration = cursor.getInt(iDuration), - size = cursor.getInt(iSize), - mimeType = mimeType + id = id.toInt(), title = title, uri = rowUri, timestamp = timestamp, duration = duration, size = size, mimeType = mimeType ) ) } @@ -386,17 +400,6 @@ class RecordingStore(private val context: Context, val uri: Uri) { private val trashFolder: Uri? get() = findChildDocument(context.contentResolver, uri, TRASH_FOLDER_NAME) - - private fun getDurationFromUri(uri: Uri): Long { - return try { - val retriever = MediaMetadataRetriever() - retriever.setDataSource(context, uri) - val time = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)!! - (time.toLong() / 1000.toDouble()).roundToLong() - } catch (_: Exception) { - 0L - } - } } private const val TRASH_FOLDER_NAME = ".trash" @@ -415,6 +418,26 @@ private enum class Backend { } } +private sealed class MetadataSource { + data class Uri(val context: Context, val uri: android.net.Uri) : MetadataSource() + data class Path(val path: String) : MetadataSource() +} + +private fun getDuration(source: MetadataSource): Int = MediaMetadataRetriever().run { + try { + when (source) { + is MetadataSource.Uri -> setDataSource(source.context, source.uri) + is MetadataSource.Path -> setDataSource(source.path) + } + extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)!!.toLong().toSeconds() + } catch (_: Exception) { + 0 + } finally { + release() + } +} + + internal fun createDocument(context: Context, parentUri: Uri, name: String, mimeType: String): Uri? = DocumentsContract.createDocument( context.contentResolver, parentUri, @@ -468,3 +491,5 @@ private fun getOrCreateTrashFolder(contentResolver: ContentResolver, parentUri: contentResolver, parentUri, DocumentsContract.Document.MIME_TYPE_DIR, TRASH_FOLDER_NAME ) +private fun Long.toSeconds(): Int = (this / 1000.toDouble()).roundToInt() + From 74142718e6fdca34d06d5258707818d9eec6fa39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Thu, 29 Jan 2026 12:05:03 +0100 Subject: [PATCH 21/28] fix: storage permissions on SDK <= 28 --- .../activities/SimpleActivity.kt | 28 ++++++++----------- .../voicerecorder/store/RecordingWriter.kt | 4 +-- 2 files changed, 13 insertions(+), 19 deletions(-) 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 cce0ca68..9d488f18 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/SimpleActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/SimpleActivity.kt @@ -10,11 +10,8 @@ import org.fossify.voicerecorder.R import org.fossify.voicerecorder.helpers.REPOSITORY_NAME open class SimpleActivity : BaseSimpleActivity() { - companion object { - private const val PERMISSIONS_REQUEST_CODE = 10001 - } - - private var permissionCallback: ((Boolean?) -> Unit)? = null + private var permissionCallbacks = mutableMapOf Unit>() + private var permissionNextRequestCode: Int = 10000 override fun getAppIconIDs() = arrayListOf( R.mipmap.ic_launcher_red, @@ -43,10 +40,9 @@ open class SimpleActivity : BaseSimpleActivity() { 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 correctly? The only thing we do - // differently here is that we invoke the callback even when the request gets cancelled (passing `null` to it). + // because this app invokes the permission request from multiple places and `BaseSimpleActivity` doesn't handle it well? fun handleExternalStoragePermissions(externalStoragePermission: ExternalStoragePermission, callback: (Boolean?) -> Unit) { - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // External storage permissions to access MediaStore are no longer needed callback(true) return @@ -62,10 +58,12 @@ open class SimpleActivity : BaseSimpleActivity() { return } - permissionCallback = callback + + val requestCode = permissionNextRequestCode++ + permissionCallbacks[requestCode] = callback ActivityCompat.requestPermissions( - this, arrayOf(permission), PERMISSIONS_REQUEST_CODE + this, arrayOf(permission), requestCode ); } @@ -74,14 +72,10 @@ open class SimpleActivity : BaseSimpleActivity() { ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); - if (requestCode != PERMISSIONS_REQUEST_CODE) { - return - } - - val callback = permissionCallback - permissionCallback = null + val callback = permissionCallbacks.remove(requestCode) + val result = grantResults.firstOrNull()?.let { it == PackageManager.PERMISSION_GRANTED } - callback?.invoke(if (grantResults.isNotEmpty()) grantResults[0] == PackageManager.PERMISSION_GRANTED else null) + callback?.invoke(result) } } diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt index 0246e329..cedb3dc1 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt @@ -13,8 +13,8 @@ import java.io.FileInputStream /** * Helper class to write recordings to the device. * - * Note: Why not use [DocumentsContract.createDocument] directly? Because there is currently a bug in [MediaStore] (TODO: link to the - * bugreport) which causes crash when writing to some [android.provider.DocumentsProvider]s. Using this class works around the bug. + * Note: Why not use [DocumentsContract.createDocument] directly? Because there is currently a [bug in `MediaRecorder`](https://issuetracker.google.com/issues/479420499) + * which causes crash when writing to some [android.provider.DocumentsProvider]s. Using this class works around the bug. */ sealed class RecordingWriter { companion object { From a26901ab90060237ea3e21c648fb55583e862bf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Thu, 29 Jan 2026 12:49:04 +0100 Subject: [PATCH 22/28] fix: not recognizing OGG as audio on SDK 26 --- .../org/fossify/voicerecorder/store/RecordingStore.kt | 6 +++--- .../org/fossify/voicerecorder/store/RecordingWriter.kt | 4 ---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt index 05fcd85c..5622f1b4 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt @@ -107,12 +107,10 @@ class RecordingStore(private val context: Context, val uri: Uri) { while (cursor.moveToNext()) { val documentId = cursor.getString(iDocumentId) val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId) - - val mimeType = cursor.getString(iMimeType) ?: continue val displayName = cursor.getString(iDisplayName) - if (!mimeType.startsWith("audio") || displayName.startsWith(".")) { + if (!isMimeAudio(mimeType) || displayName.startsWith(".")) { continue } @@ -493,3 +491,5 @@ private fun getOrCreateTrashFolder(contentResolver: ContentResolver, parentUri: private fun Long.toSeconds(): Int = (this / 1000.toDouble()).roundToInt() +// HACK: On SDK 26, 'ogg' is sometimes identified as 'application/ogg' instead of 'audio/ogg' +private fun isMimeAudio(mime: String): Boolean = mime.startsWith("audio/") || mime == "application/ogg" diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt index cedb3dc1..efaed14b 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt @@ -105,10 +105,6 @@ sealed class RecordingWriter { tempFile.delete() - if (dstUri.authority == MediaStore.AUTHORITY) { - completeMedia(context.contentResolver, dstUri) - } - return dstUri } From 42439493694296a7d3f10ab07cb1f2ee157662bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Mon, 2 Feb 2026 14:12:31 +0100 Subject: [PATCH 23/28] refactor: Improve error handling --- .../activities/SettingsActivity.kt | 9 ++- .../activities/SimpleActivity.kt | 2 +- .../adapters/RecordingsAdapter.kt | 37 +++++++++---- .../voicerecorder/adapters/TrashAdapter.kt | 5 +- .../fragments/MyViewPagerFragment.kt | 10 +++- .../fragments/RecorderFragment.kt | 2 +- .../voicerecorder/services/RecorderService.kt | 22 +------- .../voicerecorder/store/RecordingStore.kt | 55 ++++++++++++------- .../voicerecorder/store/RecordingWriter.kt | 44 ++++++--------- 9 files changed, 101 insertions(+), 85 deletions(-) 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 8acb5688..cdaaf93b 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt @@ -42,7 +42,14 @@ class SettingsActivity : SimpleActivity() { ) ensureBackgroundThread { - val hasRecordings = !recordingStore.isEmpty() + val hasRecordings = try { + !recordingStore.isEmpty() + } catch (_: SecurityException) { + // The permission to access the store has been revoked (perhaps the providing app has been reinstalled). Swallow this exception to allow the + // user to select different store. + false + } + runOnUiThread { if (newUri != oldUri && hasRecordings) { MoveRecordingsDialog( 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 9d488f18..c4539d87 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/SimpleActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/SimpleActivity.kt @@ -41,7 +41,7 @@ open class SimpleActivity : BaseSimpleActivity() { // 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 handleExternalStoragePermissions(externalStoragePermission: ExternalStoragePermission, callback: (Boolean?) -> Unit) { + 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) 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 22637461..5e8d8ef6 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt @@ -10,6 +10,7 @@ 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 @@ -161,13 +162,14 @@ class RecordingsAdapter( return } - val oldRecordingIndex = recordings.indexOfFirst { it.id == currRecordingId } - val recordingsToRemove = recordings.filter { selectedKeys.contains(it.id) }.toList() + runWithWriteExternalStoragePermission() { + val oldRecordingIndex = recordings.indexOfFirst { it.id == currRecordingId } + val recordingsToRemove = recordings.filter { selectedKeys.contains(it.id) }.toList() - val positions = getSelectedItemPositions() + val positions = getSelectedItemPositions() - ensureBackgroundThread { - if (activity.recordingStore.delete(recordingsToRemove)) { + ensureBackgroundThread { + activity.recordingStore.delete(recordingsToRemove) doDeleteAnimation(oldRecordingIndex, recordingsToRemove, positions) } } @@ -178,16 +180,18 @@ class RecordingsAdapter( return } - val oldRecordingIndex = recordings.indexOfFirst { it.id == currRecordingId } - val recordingsToRemove = recordings.filter { selectedKeys.contains(it.id) }.toList() + 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) + ensureBackgroundThread { + activity.recordingStore.trash(recordingsToRemove) - doDeleteAnimation(oldRecordingIndex, recordingsToRemove, positions) - EventBus.getDefault().post(Events.RecordingTrashUpdated()) + doDeleteAnimation(oldRecordingIndex, recordingsToRemove, positions) + EventBus.getDefault().post(Events.RecordingTrashUpdated()) + } } } @@ -245,4 +249,13 @@ 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 82a49cda..c6a96496 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/TrashAdapter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/TrashAdapter.kt @@ -128,9 +128,8 @@ class TrashAdapter( val positions = getSelectedItemPositions() ensureBackgroundThread { - if (activity.recordingStore.delete(recordingsToRemove)) { - doDeleteAnimation(recordingsToRemove, positions) - } + activity.recordingStore.delete(recordingsToRemove) + doDeleteAnimation(recordingsToRemove, positions) } } 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 cd86973e..8c6a8303 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/MyViewPagerFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/MyViewPagerFragment.kt @@ -22,10 +22,16 @@ abstract class MyViewPagerFragment(context: Context, attributeSet: AttributeSet) onLoadingStart() (context as? SimpleActivity)?.apply { - handleExternalStoragePermissions(ExternalStoragePermission.READ) { granted -> + handleExternalStoragePermission(ExternalStoragePermission.READ) { granted -> if (granted == true) { ensureBackgroundThread { - val recordings = recordingStore.all(trashed).sortedByDescending { it.timestamp }.toCollection(ArrayList()) + val recordings = try { + recordingStore.all(trashed).sortedByDescending { it.timestamp }.toCollection(ArrayList()) + } catch (_: SecurityException) { + // The access to the store has been revoked. + // TODO: show an error dialog + ArrayList() + } runOnUiThread { onLoadingEnd(recordings) 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 2fdbda2d..6f4ee06a 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt @@ -65,7 +65,7 @@ class RecorderFragment( updateRecordingDuration(0) binding.toggleRecordingButton.setDebouncedClickListener { (context as? SimpleActivity)?.apply { - handleExternalStoragePermissions(ExternalStoragePermission.WRITE) { granted -> + handleExternalStoragePermission(ExternalStoragePermission.WRITE) { granted -> if (granted == true) { handleNotificationPermission { granted -> if (granted) { 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 d9ac9438..3da6bc34 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt @@ -6,7 +6,6 @@ import android.content.Intent import android.net.Uri import android.os.IBinder import android.util.Log -import android.util.Log.e import androidx.core.app.NotificationCompat import org.fossify.commons.extensions.getLaunchIntent import org.fossify.commons.extensions.showErrorToast @@ -141,13 +140,12 @@ class RecorderService : Service() { try { val uri = writer.commit() - // TODO: - // scanRecording() - recordingSavedSuccessfully(uri) EventBus.getDefault().post(Events.RecordingCompleted()) } catch (e: Exception) { Log.e(TAG, "failed to commit recording writer", e) + + // TODO: send the exception to the activity and show an error dialog there showErrorToast(e) } } @@ -207,22 +205,6 @@ class RecorderService : Service() { } } - // TODO: what is this for? -// 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)) diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt index 5622f1b4..5270b252 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt @@ -32,7 +32,7 @@ val DEFAULT_MEDIA_DIRECTORY: String = if (Build.VERSION.SDK_INT >= Build.VERSION class RecordingStore(private val context: Context, val uri: Uri) { companion object { - private const val TAG = "RecordingStore" + internal const val TAG = "RecordingStore" } init { @@ -232,7 +232,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { /** * Permanently delete all trashed recordings. */ - fun deleteTrashed(): Boolean = delete(all(trashed = true).toList()) + fun deleteTrashed() = delete(all(trashed = true).toList()) /** * Move all recordings in this store (including the trashed ones) into a new store at the given URI. @@ -276,7 +276,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { } val dstParentUri = if (toTrash) { - getOrCreateTrashFolder(contentResolver, dstUri)!! + getOrCreateTrashFolder(contentResolver, dstUri) } else { buildParentDocumentUri(dstUri) } @@ -289,6 +289,8 @@ class RecordingStore(private val context: Context, val uri: Uri) { ) } catch (@Suppress("SwallowedException") _: IllegalStateException) { moveDocumentFallback(recording, dstParentUri) + } catch (_: UnsupportedOperationException) { + moveDocumentFallback(recording, dstParentUri) } } } else { @@ -304,9 +306,13 @@ class RecordingStore(private val context: Context, val uri: Uri) { dstParentUri: Uri, ) { val contentResolver = context.contentResolver - val dstUri = DocumentsContract.createDocument( - contentResolver, dstParentUri, src.mimeType, src.title - )!! + val dstUri = checkNotNull( + DocumentsContract.createDocument( + contentResolver, dstParentUri, src.mimeType, src.title + ) + ) { + "failed to create document '${src.title}' in $dstParentUri" + } copyFile(contentResolver, src.uri, dstUri) @@ -315,7 +321,9 @@ class RecordingStore(private val context: Context, val uri: Uri) { private fun moveDocumentsToMedia(recordings: Collection, dstUri: Uri, toTrash: Boolean) { for (recording in recordings) { - val dstUri = createMedia(context, dstUri, recording.title, recording.mimeType)!! + val dstUri = checkNotNull(createMedia(context, dstUri, recording.title, recording.mimeType)) { + "failed to create media '${recording.title}' in $dstUri" + } copyFile(context.contentResolver, recording.uri, dstUri) @@ -332,13 +340,15 @@ class RecordingStore(private val context: Context, val uri: Uri) { private fun moveMediaToDocuments(recordings: Collection, dstUri: Uri, toTrash: Boolean) { val contentResolver = context.contentResolver val dstParentUri = if (toTrash) { - getOrCreateTrashFolder(contentResolver, dstUri)!! + getOrCreateTrashFolder(contentResolver, dstUri) } else { buildParentDocumentUri(dstUri) } for (recording in recordings) { - val dstUri = createDocument(context, dstParentUri, recording.title, recording.mimeType)!! + val dstUri = checkNotNull(createDocument(context, dstParentUri, recording.title, recording.mimeType)) { + "failed to create document '${recording.title}' in $dstParentUri" + } copyFile(contentResolver, recording.uri, dstUri) contentResolver.delete(recording.uri, null, null) } @@ -378,14 +388,12 @@ class RecordingStore(private val context: Context, val uri: Uri) { /** * Permanently delete (skipping the trash) the given recordings. */ - fun delete(recordings: Collection): Boolean { + fun delete(recordings: Collection) { val resolver = context.contentResolver recordings.forEach { deleteFile(resolver, it.uri) } - - return true } /** @@ -398,6 +406,7 @@ class RecordingStore(private val context: Context, val uri: Uri) { private val trashFolder: Uri? get() = findChildDocument(context.contentResolver, uri, TRASH_FOLDER_NAME) + } private const val TRASH_FOLDER_NAME = ".trash" @@ -427,7 +436,7 @@ private fun getDuration(source: MetadataSource): Int = MediaMetadataRetriever(). is MetadataSource.Uri -> setDataSource(source.context, source.uri) is MetadataSource.Path -> setDataSource(source.path) } - extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)!!.toLong().toSeconds() + extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong()?.toSeconds() ?: 0 } catch (_: Exception) { 0 } finally { @@ -479,17 +488,25 @@ internal fun deleteFile(contentResolver: ContentResolver, uri: Uri) = when (Back Backend.DOCUMENT -> DocumentsContract.deleteDocument(contentResolver, uri) } -private fun copyFile(contentResolver: ContentResolver, srcUri: Uri, dstUri: Uri) = contentResolver.openInputStream(srcUri)?.use { inputStream -> - contentResolver.openOutputStream(dstUri)?.use { outputStream -> - inputStream.copyTo(outputStream) +private fun copyFile(contentResolver: ContentResolver, srcUri: Uri, dstUri: Uri) { + val src = checkNotNull(contentResolver.openInputStream(srcUri)) { "failed to open input stream for $srcUri" } + val dst = checkNotNull(contentResolver.openOutputStream(dstUri)) { "failed to open output stream for $dstUri" } + + src.use { inputStream -> + dst.use { outputStream -> + inputStream.copyTo(outputStream) + } } } -private fun getOrCreateTrashFolder(contentResolver: ContentResolver, parentUri: Uri): Uri? = getOrCreateDocument( - contentResolver, parentUri, DocumentsContract.Document.MIME_TYPE_DIR, TRASH_FOLDER_NAME -) +private fun getOrCreateTrashFolder(contentResolver: ContentResolver, parentUri: Uri): Uri = checkNotNull( + getOrCreateDocument( + contentResolver, parentUri, DocumentsContract.Document.MIME_TYPE_DIR, TRASH_FOLDER_NAME + ) +) { "failed to create the trash folder in $parentUri" } private fun Long.toSeconds(): Int = (this / 1000.toDouble()).roundToInt() // HACK: On SDK 26, 'ogg' is sometimes identified as 'application/ogg' instead of 'audio/ogg' private fun isMimeAudio(mime: String): Boolean = mime.startsWith("audio/") || mime == "application/ogg" + diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt index efaed14b..f8f4b9a0 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt @@ -24,19 +24,18 @@ sealed class RecordingWriter { val direct = DIRECT_EXTENSIONS.contains(extension) or DIRECT_AUTHORITIES.contains(parentUri.authority) - if (direct) { - val uri = createFile(context, parentUri, name, mimeType) + return if (direct) { + val uri = checkNotNull(createFile(context, parentUri, name, mimeType)) { "failed to create file '$name' in $parentUri" } val fileDescriptor = checkNotNull(context.contentResolver.openFileDescriptor(uri, "w")) { - "failed to open file descriptor at $uri" + "failed to open file descriptor for $uri" } - return Direct(context.contentResolver, uri, fileDescriptor) + Direct(context.contentResolver, uri, fileDescriptor) } else { - return Workaround(context, parentUri, name, mimeType) + Workaround(context, parentUri, name, mimeType) } } - // Mime types not affected by the MediaStore bug private val DIRECT_EXTENSIONS = arrayOf("mp3") @@ -76,23 +75,21 @@ sealed class RecordingWriter { // Writes to a temporary file first, then copies it into the destination document. class Workaround internal constructor( - private val context: Context, - private val parentTreeUri: Uri, - private val name: String, - private val mimeType: String + private val context: Context, private val parentTreeUri: Uri, private val name: String, private val mimeType: String ) : RecordingWriter() { private val tempFile: File = File(context.cacheDir, "$name.tmp") override val fileDescriptor: ParcelFileDescriptor get() = ParcelFileDescriptor.open( - tempFile, - ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_CREATE or ParcelFileDescriptor.MODE_TRUNCATE + tempFile, ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_CREATE or ParcelFileDescriptor.MODE_TRUNCATE ) - override fun commit(): Uri { - val dstUri = createFile(context, parentTreeUri, name, mimeType) - val dst = requireNotNull(context.contentResolver.openOutputStream(dstUri)) { - "failed to open output stream at $dstUri" + override fun commit(): Uri { + val dstUri = checkNotNull(createFile(context, parentTreeUri, name, mimeType)) { + "failed to create file '$name' in $parentTreeUri" + } + val dst = checkNotNull(context.contentResolver.openOutputStream(dstUri)) { + "failed to open output stream for $dstUri" } val src = FileInputStream(tempFile) @@ -114,14 +111,9 @@ sealed class RecordingWriter { } } -private fun createFile(context: Context, parentUri: Uri, name: String, mimeType: String): Uri { - val uri = if (parentUri.authority == MediaStore.AUTHORITY) { - createMedia(context, parentUri, name, mimeType) - } else { - createDocument(context, buildParentDocumentUri(parentUri), name, mimeType) - } - - return requireNotNull(uri) { - "failed to create file '$name' in $parentUri" - } +private fun createFile(context: Context, parentUri: Uri, name: String, mimeType: String): Uri? = if (parentUri.authority == MediaStore.AUTHORITY) { + createMedia(context, parentUri, name, mimeType) +} else { + createDocument(context, buildParentDocumentUri(parentUri), name, mimeType) } + From efc1eb2bc2f243f26b9ddf5d546a3798c222821a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Mon, 2 Feb 2026 16:07:02 +0100 Subject: [PATCH 24/28] refactor: change RecordingWriter to inner class of RecordingStore --- .../voicerecorder/services/RecorderService.kt | 4 +- .../voicerecorder/store/RecordingStore.kt | 117 ++++++++++++++++- .../voicerecorder/store/RecordingWriter.kt | 119 ------------------ 3 files changed, 114 insertions(+), 126 deletions(-) delete mode 100644 store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt 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 3da6bc34..86b0152c 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt @@ -23,7 +23,7 @@ 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.RecordingWriter +import org.fossify.voicerecorder.store.RecordingStore import org.greenrobot.eventbus.EventBus import java.util.Timer import java.util.TimerTask @@ -42,7 +42,7 @@ class RecorderService : Service() { private var durationTimer = Timer() private var amplitudeTimer = Timer() private var recorder: Recorder? = null - private var writer: RecordingWriter? = null + private var writer: RecordingStore.Writer? = null override fun onBind(intent: Intent?): IBinder? = null diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt index 5270b252..a6e0d6f0 100644 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt +++ b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingStore.kt @@ -10,9 +10,12 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Environment +import android.os.ParcelFileDescriptor import android.provider.DocumentsContract import android.provider.MediaStore +import android.webkit.MimeTypeMap import java.io.File +import java.io.FileInputStream import kotlin.math.roundToInt val DEFAULT_MEDIA_URI: Uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI @@ -400,13 +403,112 @@ class RecordingStore(private val context: Context, val uri: Uri) { * Create a [RecordingWriter] for writing a new recording with the given name. The name should contain the file extension (ogg, mp3, ...) which is used to * select the format the recording will be stored in. */ - fun createWriter(name: String): RecordingWriter = RecordingWriter.create(context, uri, name) + fun createWriter(name: String): Writer { + val extension = MimeTypeMap.getFileExtensionFromUrl(name) + val mimeType = extension?.let { MimeTypeMap.getSingleton().getMimeTypeFromExtension(it) } ?: "application/octet-stream" + + val direct = Writer.DIRECT_EXTENSIONS.contains(extension) or Writer.DIRECT_AUTHORITIES.contains(uri.authority) + + return if (direct) { + val fileUri = checkNotNull(createFile(context, uri, name, mimeType)) { "failed to create file '$name' in $uri" } + val fileDescriptor = checkNotNull(context.contentResolver.openFileDescriptor(fileUri, "w")) { + "failed to open file descriptor for $uri" + } + + DirectWriter(fileUri, fileDescriptor) + } else { + WorkaroundWriter(name, mimeType) + } + } private val backend: Backend = Backend.of(uri) private val trashFolder: Uri? get() = findChildDocument(context.contentResolver, uri, TRASH_FOLDER_NAME) + /** + * Helper class to write recordings to the device. + * + * Note: Why not use [DocumentsContract.createDocument] directly? Because there is currently a [bug in `MediaRecorder`](https://issuetracker.google.com/issues/479420499) + * which causes crash when writing to some [android.provider.DocumentsProvider]s. Using this class works around the bug. + */ + sealed class Writer { + companion object { + // Mime types not affected by the MediaStore bug + internal val DIRECT_EXTENSIONS = arrayOf("mp3") + + // Document providers not affected by the MediaStore bug + internal val DIRECT_AUTHORITIES = arrayOf("com.android.externalstorage.documents", MediaStore.AUTHORITY) + } + + /** + * File descriptor to write the recording data to. + */ + abstract val fileDescriptor: ParcelFileDescriptor + + abstract fun commit(): Uri + + abstract fun cancel() + } + + // Writes directly to the document at the given URI. + private inner class DirectWriter internal constructor( + private val uri: Uri, + override val fileDescriptor: ParcelFileDescriptor + ) : Writer() { + override fun commit(): Uri { + fileDescriptor.close() + + if (uri.authority == MediaStore.AUTHORITY) { + completeMedia(context.contentResolver, uri) + } + + return uri + } + + override fun cancel() { + fileDescriptor.close() + deleteFile(context.contentResolver, uri) + } + } + + // Writes to a temporary file first, then copies it into the destination document. + private inner class WorkaroundWriter internal constructor( + private val name: String, private val mimeType: String + ) : Writer() { + private val tempFile: File = File(context.cacheDir, "$name.tmp") + + override val fileDescriptor: ParcelFileDescriptor + get() = ParcelFileDescriptor.open( + tempFile, ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_CREATE or ParcelFileDescriptor.MODE_TRUNCATE + ) + + override fun commit(): Uri { + val dstUri = checkNotNull(createFile(context, uri, name, mimeType)) { + "failed to create file '$name' in $uri" + } + val dst = checkNotNull(context.contentResolver.openOutputStream(dstUri)) { + "failed to open output stream for $dstUri" + } + + val src = FileInputStream(tempFile) + + src.use { src -> + dst.use { dst -> + src.copyTo(dst) + } + } + + tempFile.delete() + + return dstUri + } + + override fun cancel() { + tempFile.delete() + } + } + } private const val TRASH_FOLDER_NAME = ".trash" @@ -444,8 +546,13 @@ private fun getDuration(source: MetadataSource): Int = MediaMetadataRetriever(). } } +private fun createFile(context: Context, parentUri: Uri, name: String, mimeType: String): Uri? = if (parentUri.authority == MediaStore.AUTHORITY) { + createMedia(context, parentUri, name, mimeType) +} else { + createDocument(context, buildParentDocumentUri(parentUri), name, mimeType) +} -internal fun createDocument(context: Context, parentUri: Uri, name: String, mimeType: String): Uri? = DocumentsContract.createDocument( +private fun createDocument(context: Context, parentUri: Uri, name: String, mimeType: String): Uri? = DocumentsContract.createDocument( context.contentResolver, parentUri, mimeType, @@ -453,7 +560,7 @@ internal fun createDocument(context: Context, parentUri: Uri, name: String, mime ) -internal fun createMedia(context: Context, parentUri: Uri, name: String, mimeType: String): Uri? { +private fun createMedia(context: Context, parentUri: Uri, name: String, mimeType: String): Uri? { val values = ContentValues().apply { put(MediaStore.Audio.Media.DISPLAY_NAME, name) put(MediaStore.Audio.Media.MIME_TYPE, mimeType) @@ -471,7 +578,7 @@ internal fun createMedia(context: Context, parentUri: Uri, name: String, mimeTyp return context.contentResolver.insert(parentUri, values) } -internal fun completeMedia(contentResolver: ContentResolver, uri: Uri) { +private fun completeMedia(contentResolver: ContentResolver, uri: Uri) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { return } @@ -483,7 +590,7 @@ internal fun completeMedia(contentResolver: ContentResolver, uri: Uri) { contentResolver.update(uri, values, null, null) } -internal fun deleteFile(contentResolver: ContentResolver, uri: Uri) = when (Backend.of(uri)) { +private fun deleteFile(contentResolver: ContentResolver, uri: Uri) = when (Backend.of(uri)) { Backend.MEDIA -> contentResolver.delete(uri, null, null) Backend.DOCUMENT -> DocumentsContract.deleteDocument(contentResolver, uri) } diff --git a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt b/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt deleted file mode 100644 index f8f4b9a0..00000000 --- a/store/src/main/kotlin/org/fossify/voicerecorder/store/RecordingWriter.kt +++ /dev/null @@ -1,119 +0,0 @@ -package org.fossify.voicerecorder.store - -import android.content.ContentResolver -import android.content.Context -import android.net.Uri -import android.os.ParcelFileDescriptor -import android.provider.DocumentsContract -import android.provider.MediaStore -import android.webkit.MimeTypeMap -import java.io.File -import java.io.FileInputStream - -/** - * Helper class to write recordings to the device. - * - * Note: Why not use [DocumentsContract.createDocument] directly? Because there is currently a [bug in `MediaRecorder`](https://issuetracker.google.com/issues/479420499) - * which causes crash when writing to some [android.provider.DocumentsProvider]s. Using this class works around the bug. - */ -sealed class RecordingWriter { - companion object { - fun create(context: Context, parentUri: Uri, name: String): RecordingWriter { - val extension = MimeTypeMap.getFileExtensionFromUrl(name) - val mimeType = extension?.let { MimeTypeMap.getSingleton().getMimeTypeFromExtension(it) } ?: "application/octet-stream" - - val direct = DIRECT_EXTENSIONS.contains(extension) or DIRECT_AUTHORITIES.contains(parentUri.authority) - - return if (direct) { - val uri = checkNotNull(createFile(context, parentUri, name, mimeType)) { "failed to create file '$name' in $parentUri" } - val fileDescriptor = checkNotNull(context.contentResolver.openFileDescriptor(uri, "w")) { - "failed to open file descriptor for $uri" - } - - Direct(context.contentResolver, uri, fileDescriptor) - } else { - Workaround(context, parentUri, name, mimeType) - } - } - - // Mime types not affected by the MediaStore bug - private val DIRECT_EXTENSIONS = arrayOf("mp3") - - // Document providers not affected by the MediaStore bug - private val DIRECT_AUTHORITIES = arrayOf("com.android.externalstorage.documents", MediaStore.AUTHORITY) - - private const val TAG = "RecordingWriter" - } - - /** - * File descriptor to write the recording data to. - */ - abstract val fileDescriptor: ParcelFileDescriptor - - abstract fun commit(): Uri - - abstract fun cancel() - - // Writes directly to the document at the given URI. - class Direct internal constructor(private val contentResolver: ContentResolver, private val uri: Uri, override val fileDescriptor: ParcelFileDescriptor) : - RecordingWriter() { - override fun commit(): Uri { - fileDescriptor.close() - - if (uri.authority == MediaStore.AUTHORITY) { - completeMedia(contentResolver, uri) - } - - return uri - } - - override fun cancel() { - fileDescriptor.close() - deleteFile(contentResolver, uri) - } - } - - // Writes to a temporary file first, then copies it into the destination document. - class Workaround internal constructor( - private val context: Context, private val parentTreeUri: Uri, private val name: String, private val mimeType: String - ) : RecordingWriter() { - private val tempFile: File = File(context.cacheDir, "$name.tmp") - - override val fileDescriptor: ParcelFileDescriptor - get() = ParcelFileDescriptor.open( - tempFile, ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_CREATE or ParcelFileDescriptor.MODE_TRUNCATE - ) - - override fun commit(): Uri { - val dstUri = checkNotNull(createFile(context, parentTreeUri, name, mimeType)) { - "failed to create file '$name' in $parentTreeUri" - } - val dst = checkNotNull(context.contentResolver.openOutputStream(dstUri)) { - "failed to open output stream for $dstUri" - } - - val src = FileInputStream(tempFile) - - src.use { src -> - dst.use { dst -> - src.copyTo(dst) - } - } - - tempFile.delete() - - return dstUri - } - - override fun cancel() { - tempFile.delete() - } - } -} - -private fun createFile(context: Context, parentUri: Uri, name: String, mimeType: String): Uri? = if (parentUri.authority == MediaStore.AUTHORITY) { - createMedia(context, parentUri, name, mimeType) -} else { - createDocument(context, buildParentDocumentUri(parentUri), name, mimeType) -} - From b7265e1854eefac3a0f0c27bef74367abd237539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Cig=C3=A1nek?= Date: Tue, 3 Feb 2026 16:36:04 +0100 Subject: [PATCH 25/28] feat: handle recording store errors gracefully --- .../voicerecorder/activities/MainActivity.kt | 19 ++- .../activities/SettingsActivity.kt | 20 ++- .../dialogs/MoveRecordingsDialog.kt | 13 +- .../voicerecorder/extensions/Activity.kt | 26 +++- .../fragments/MyViewPagerFragment.kt | 6 +- .../fragments/RecorderFragment.kt | 2 + .../fossify/voicerecorder/models/Events.kt | 4 +- .../voicerecorder/recorder/Mp3Recorder.kt | 12 +- .../voicerecorder/services/RecorderService.kt | 52 +++----- app/src/main/res/values/strings.xml | 2 + .../store/MockDocumentsProvider.kt | 12 +- .../voicerecorder/store/RecordingStoreTest.kt | 21 ++- .../voicerecorder/store/DocumentsUtils.kt | 10 -- .../voicerecorder/store/RecordingStore.kt | 126 ++++++++++-------- 14 files changed, 188 insertions(+), 137 deletions(-) diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/activities/MainActivity.kt b/app/src/main/kotlin/org/fossify/voicerecorder/activities/MainActivity.kt index dda615dd..5148c59e 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/MainActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/MainActivity.kt @@ -7,15 +7,7 @@ import android.widget.ImageView import android.widget.TextView import androidx.appcompat.content.res.AppCompatResources import me.grantland.widget.AutofitHelper -import org.fossify.commons.extensions.appLaunched -import org.fossify.commons.extensions.checkAppSideloading -import org.fossify.commons.extensions.getBottomNavigationBackgroundColor -import org.fossify.commons.extensions.hideKeyboard -import org.fossify.commons.extensions.launchMoreAppsFromUsIntent -import org.fossify.commons.extensions.onPageChangeListener -import org.fossify.commons.extensions.onTabSelectionChanged -import org.fossify.commons.extensions.toast -import org.fossify.commons.extensions.updateBottomTabItemColors +import org.fossify.commons.extensions.* import org.fossify.commons.helpers.* import org.fossify.commons.models.FAQItem import org.fossify.voicerecorder.BuildConfig @@ -24,6 +16,7 @@ import org.fossify.voicerecorder.adapters.ViewPagerAdapter import org.fossify.voicerecorder.databinding.ActivityMainBinding import org.fossify.voicerecorder.extensions.config import org.fossify.voicerecorder.extensions.deleteExpiredTrashedRecordings +import org.fossify.voicerecorder.extensions.handleRecordingStoreError import org.fossify.voicerecorder.helpers.STOP_AMPLITUDE_UPDATE import org.fossify.voicerecorder.models.Events import org.fossify.voicerecorder.services.RecorderService @@ -277,11 +270,17 @@ class MainActivity : SimpleActivity() { fun recordingSaved(event: Events.RecordingSaved) { if (isThirdPartyIntent()) { Intent().apply { - data = event.uri!! + data = event.uri flags = Intent.FLAG_GRANT_READ_URI_PERMISSION 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 cdaaf93b..c314fe65 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt @@ -28,6 +28,13 @@ 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 = "focus_save_recordings_folder" + } + private var recycleBinContentSize = 0 private lateinit var binding: ActivitySettingsBinding @@ -44,9 +51,8 @@ class SettingsActivity : SimpleActivity() { ensureBackgroundThread { val hasRecordings = try { !recordingStore.isEmpty() - } catch (_: SecurityException) { - // The permission to access the store has been revoked (perhaps the providing app has been reinstalled). Swallow this exception to allow the - // user to select different store. + } catch (_: Exception) { + // Something went wrong accessing the current store. Swallow the exception to allow the user to select a different one. false } @@ -74,6 +80,10 @@ class SettingsActivity : SimpleActivity() { setupEdgeToEdge(padBottomSystem = listOf(binding.settingsNestedScrollview)) setupMaterialScrollListener(binding.settingsNestedScrollview, binding.settingsAppbar) + + if (intent.getBooleanExtra(EXTRA_FOCUS_SAVE_RECORDINGS_FOLDER, false)) { + focusSaveRecordingsFolder() + } } override fun onResume() { @@ -183,6 +193,10 @@ class SettingsActivity : SimpleActivity() { } + private fun focusSaveRecordingsFolder() = binding.settingsSaveRecordingsHolder.post { + binding.settingsNestedScrollview.smoothScrollTo(0, binding.settingsSaveRecordingsHolder.top) + } + private fun setupFilenamePattern() { binding.settingsFilenamePattern.text = config.filenamePattern binding.settingsFilenamePatternHolder.setOnClickListener { 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 effcfbe1..8612db76 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/MoveRecordingsDialog.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/MoveRecordingsDialog.kt @@ -10,6 +10,7 @@ import org.fossify.commons.helpers.MEDIUM_ALPHA import org.fossify.commons.helpers.ensureBackgroundThread import org.fossify.voicerecorder.R import org.fossify.voicerecorder.databinding.DialogMoveRecordingsBinding +import org.fossify.voicerecorder.extensions.handleRecordingStoreError import org.fossify.voicerecorder.store.RecordingStore class MoveRecordingsDialog( @@ -28,7 +29,7 @@ class MoveRecordingsDialog( 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() @@ -55,10 +56,12 @@ class MoveRecordingsDialog( private fun moveAllRecordings() = ensureBackgroundThread { RecordingStore(activity, oldFolder).let { store -> - store.migrate(newFolder) - - activity.runOnUiThread { - callback() + try { + store.migrate(newFolder) + activity.runOnUiThread { callback() } + } catch (e: Exception) { + activity.handleRecordingStoreError(e) + } finally { dialog.dismiss() } } 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 7164cad5..38a29767 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt @@ -1,11 +1,16 @@ package org.fossify.voicerecorder.extensions import android.app.Activity +import android.content.Intent import android.os.Build +import android.util.Log import android.view.WindowManager import org.fossify.commons.activities.BaseSimpleActivity +import org.fossify.commons.extensions.getAlertDialogBuilder import org.fossify.commons.extensions.hasPermission import org.fossify.commons.helpers.* +import org.fossify.voicerecorder.R +import org.fossify.voicerecorder.activities.SettingsActivity fun Activity.setKeepScreenAwake(keepScreenOn: Boolean) { if (keepScreenOn) { @@ -19,7 +24,7 @@ fun BaseSimpleActivity.deleteExpiredTrashedRecordings() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { if (!hasPermission(PERMISSION_READ_STORAGE) || !hasPermission(PERMISSION_WRITE_STORAGE)) { return - } + } } if (config.useRecycleBin && config.lastRecycleBinCheck < System.currentTimeMillis() - DAY_SECONDS * 1000) { @@ -38,3 +43,22 @@ fun BaseSimpleActivity.deleteExpiredTrashedRecordings() { } } +fun BaseSimpleActivity.handleRecordingStoreError(exception: Exception) { + Log.w(this::class.simpleName, "recording store error", exception) + + // TODO: invoke the intent at [exception.userAction] then handle its result + // if (exception is AuthenticationRequiredException) { + // TODO() + // 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() + } +} 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 8c6a8303..21cdf7fe 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/MyViewPagerFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/MyViewPagerFragment.kt @@ -6,6 +6,7 @@ import androidx.constraintlayout.widget.ConstraintLayout import org.fossify.commons.helpers.ensureBackgroundThread import org.fossify.voicerecorder.activities.ExternalStoragePermission import org.fossify.voicerecorder.activities.SimpleActivity +import org.fossify.voicerecorder.extensions.handleRecordingStoreError import org.fossify.voicerecorder.extensions.recordingStore import org.fossify.voicerecorder.store.Recording @@ -27,9 +28,8 @@ abstract class MyViewPagerFragment(context: Context, attributeSet: AttributeSet) ensureBackgroundThread { val recordings = try { recordingStore.all(trashed).sortedByDescending { it.timestamp }.toCollection(ArrayList()) - } catch (_: SecurityException) { - // The access to the store has been revoked. - // TODO: show an error dialog + } catch (e: Exception) { + handleRecordingStoreError(e) ArrayList() } 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 6f4ee06a..706c9cc5 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt @@ -17,6 +17,8 @@ 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.handleRecordingStoreError +import org.fossify.voicerecorder.extensions.recordingStore import org.fossify.voicerecorder.extensions.setKeepScreenAwake import org.fossify.voicerecorder.helpers.* import org.fossify.voicerecorder.models.Events 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/recorder/Mp3Recorder.kt b/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Mp3Recorder.kt index a2cf8c51..6fe581be 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Mp3Recorder.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Mp3Recorder.kt @@ -8,7 +8,6 @@ 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.FileOutputStream import java.io.IOException @@ -38,6 +37,8 @@ class Mp3Recorder(val context: Context) : Recorder { minBufferSize * 2 ) + private var thread: Thread? = null + override fun prepare() {} override fun start() { @@ -54,12 +55,12 @@ 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 } outputStream.use { outputStream -> @@ -81,13 +82,16 @@ class Mp3Recorder(val context: Context) : Recorder { } } } - } + }.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() { 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 86b0152c..478edf9b 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt @@ -79,17 +79,22 @@ class RecorderService : Service() { val recordingFormat = config.recordingFormat try { + 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 + } + recorder = when (recordingFormat) { RecordingFormat.M4A, RecordingFormat.OGG -> MediaRecorderWrapper(this) RecordingFormat.MP3 -> Mp3Recorder(this) } - val writer = recordingStore.createWriter( - "${getFormattedFilename()}.${recordingFormat.getExtension(this)}", - ).also { - this.writer = it - } - recorder?.setOutputFile(writer.fileDescriptor) recorder?.prepare() recorder?.start() @@ -103,8 +108,6 @@ class RecorderService : Service() { startAmplitudeUpdates() } catch (e: Exception) { - Log.e(TAG, "failed to start recording", e) - showErrorToast(e) stopRecording() } @@ -122,14 +125,12 @@ class RecorderService : Service() { } } catch ( @Suppress( - "TooGenericExceptionCaught", - "SwallowedException" + "TooGenericExceptionCaught", "SwallowedException" ) e: RuntimeException ) { - Log.e(TAG, "failed to stop recorder", e) toast(R.string.recording_too_short) } catch (e: Exception) { - Log.e(TAG, "failed to stop recorder", e) + Log.e(TAG, "failed to stop recording", e) showErrorToast(e) } finally { recorder = null @@ -139,13 +140,10 @@ class RecorderService : Service() { ensureBackgroundThread { try { val uri = writer.commit() - recordingSavedSuccessfully(uri) EventBus.getDefault().post(Events.RecordingCompleted()) } catch (e: Exception) { Log.e(TAG, "failed to commit recording writer", e) - - // TODO: send the exception to the activity and show an error dialog there showErrorToast(e) } } @@ -223,8 +221,7 @@ class RecorderService : Service() { override fun run() { if (recorder != null) { try { - EventBus.getDefault() - .post(Events.RecordingAmplitude(recorder!!.getMaxAmplitude())) + EventBus.getDefault().post(Events.RecordingAmplitude(recorder!!.getMaxAmplitude())) } catch (_: Exception) { } } @@ -234,8 +231,7 @@ class RecorderService : Service() { private fun showNotification(): Notification { val channelId = "simple_recorder" val label = getString(R.string.app_name) - val notificationManager = - getSystemService(NOTIFICATION_SERVICE) as NotificationManager + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager NotificationChannel(channelId, label, NotificationManager.IMPORTANCE_DEFAULT).apply { setSound(null, null) @@ -249,16 +245,9 @@ class RecorderService : Service() { text += " (${getString(R.string.paused)})" } - 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) + 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() } @@ -266,10 +255,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 ) } 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