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 25994c7e..ff66ecc4 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/MainActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/MainActivity.kt @@ -30,8 +30,9 @@ import org.fossify.voicerecorder.BuildConfig import org.fossify.voicerecorder.R import org.fossify.voicerecorder.adapters.ViewPagerAdapter import org.fossify.voicerecorder.databinding.ActivityMainBinding -import org.fossify.voicerecorder.extensions.checkRecycleBinItems 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 @@ -65,7 +66,7 @@ class MainActivity : SimpleActivity() { } if (savedInstanceState == null) { - checkRecycleBinItems() + deleteExpiredTrashedRecordings() } handlePermission(PERMISSION_RECORD_AUDIO) { @@ -172,12 +173,20 @@ class MainActivity : SimpleActivity() { private fun tryInitVoiceRecorder() { if (isRPlus()) { - setupViewPager() + 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() } } 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 fab49a09..6d123a65 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/SettingsActivity.kt @@ -5,8 +5,6 @@ import android.media.MediaRecorder import android.os.Bundle import org.fossify.commons.dialogs.ChangeDateTimeFormatDialog import org.fossify.commons.dialogs.ConfirmationDialog -import org.fossify.commons.dialogs.FeatureLockedDialog -import org.fossify.commons.dialogs.FilePickerDialog import org.fossify.commons.dialogs.RadioGroupDialog import org.fossify.commons.extensions.addLockedLabelIfNeeded import org.fossify.commons.extensions.beGone @@ -31,8 +29,9 @@ import org.fossify.commons.models.RadioItem import org.fossify.voicerecorder.R import org.fossify.voicerecorder.databinding.ActivitySettingsBinding import org.fossify.voicerecorder.extensions.config -import org.fossify.voicerecorder.extensions.emptyTheRecycleBin +import org.fossify.voicerecorder.extensions.deleteTrashedRecordings import org.fossify.voicerecorder.extensions.getAllRecordings +import org.fossify.voicerecorder.extensions.launchFilePickerDialog import org.fossify.voicerecorder.helpers.BITRATES import org.fossify.voicerecorder.helpers.EXTENSION_M4A import org.fossify.voicerecorder.helpers.EXTENSION_MP3 @@ -158,27 +157,8 @@ class SettingsActivity : SimpleActivity() { addLockedLabelIfNeeded(R.string.save_recordings_in) binding.settingsSaveRecordings.text = humanizePath(config.saveRecordingsFolder) binding.settingsSaveRecordingsHolder.setOnClickListener { - if (isOrWasThankYouInstalled()) { - FilePickerDialog(this, config.saveRecordingsFolder, false, showFAB = true) { - val path = it - handleSAFDialog(path) { grantedSAF -> - if (!grantedSAF) { - return@handleSAFDialog - } - - handleSAFDialogSdk30(path) { grantedSAF30 -> - if (!grantedSAF30) { - return@handleSAFDialogSdk30 - } - - config.saveRecordingsFolder = path - binding.settingsSaveRecordings.text = - humanizePath(config.saveRecordingsFolder) - } - } - } - } else { - FeatureLockedDialog(this) { } + launchFilePickerDialog { + binding.settingsSaveRecordings.text = humanizePath(config.saveRecordingsFolder) } } } @@ -272,7 +252,7 @@ class SettingsActivity : SimpleActivity() { negative = org.fossify.commons.R.string.no ) { ensureBackgroundThread { - emptyTheRecycleBin() + deleteTrashedRecordings() 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 5b32d8b9..9021b6e4 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/RecordingsAdapter.kt @@ -19,7 +19,6 @@ import org.fossify.commons.extensions.openPathIntent import org.fossify.commons.extensions.setupViewBackground import org.fossify.commons.extensions.sharePathsIntent import org.fossify.commons.helpers.ensureBackgroundThread -import org.fossify.commons.helpers.isQPlus import org.fossify.commons.views.MyRecyclerView import org.fossify.voicerecorder.BuildConfig import org.fossify.voicerecorder.R @@ -29,8 +28,7 @@ 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.moveRecordingsToRecycleBin -import org.fossify.voicerecorder.helpers.getAudioFileContentUri +import org.fossify.voicerecorder.extensions.trashRecordings import org.fossify.voicerecorder.interfaces.RefreshRecordingsListener import org.fossify.voicerecorder.models.Events import org.fossify.voicerecorder.models.Recording @@ -133,23 +131,13 @@ class RecordingsAdapter( private fun openRecordingWith() { val recording = getItemWithKey(selectedKeys.first()) ?: return - val path = if (isQPlus()) { - getAudioFileContentUri(recording.id.toLong()).toString() - } else { - recording.path - } - + val path = recording.path activity.openPathIntent(path, false, BuildConfig.APPLICATION_ID, "audio/*") } private fun shareRecordings() { val selectedItems = getSelectedItems() - val paths = selectedItems.map { - it.path.ifEmpty { - getAudioFileContentUri(it.id.toLong()).toString() - } - } - + val paths = selectedItems.map { it.path } activity.sharePathsIntent(paths, BuildConfig.APPLICATION_ID) } @@ -177,15 +165,15 @@ class RecordingsAdapter( ensureBackgroundThread { val toRecycleBin = !skipRecycleBin && activity.config.useRecycleBin if (toRecycleBin) { - moveMediaStoreRecordingsToRecycleBin() + trashRecordings() } else { - deleteMediaStoreRecordings() + deleteRecordings() } } } } - private fun deleteMediaStoreRecordings() { + private fun deleteRecordings() { if (selectedKeys.isEmpty()) { return } @@ -203,7 +191,7 @@ class RecordingsAdapter( } } - private fun moveMediaStoreRecordingsToRecycleBin() { + private fun trashRecordings() { if (selectedKeys.isEmpty()) { return } @@ -214,7 +202,7 @@ class RecordingsAdapter( val positions = getSelectedItemPositions() - activity.moveRecordingsToRecycleBin(recordingsToRemove) { success -> + activity.trashRecordings(recordingsToRemove) { success -> if (success) { doDeleteAnimation(oldRecordingIndex, recordingsToRemove, positions) EventBus.getDefault().post(Events.RecordingTrashUpdated()) 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 bcfa84f4..afe55f0d 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/RenameRecordingDialog.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/RenameRecordingDialog.kt @@ -1,7 +1,5 @@ package org.fossify.voicerecorder.dialogs -import android.content.ContentValues -import android.provider.MediaStore.Audio.Media import androidx.appcompat.app.AlertDialog import org.fossify.commons.activities.BaseSimpleActivity import org.fossify.commons.extensions.getAlertDialogBuilder @@ -19,7 +17,6 @@ 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.helpers.getAudioFileContentUri import org.fossify.voicerecorder.models.Events import org.fossify.voicerecorder.models.Recording import org.greenrobot.eventbus.EventBus @@ -60,9 +57,9 @@ class RenameRecordingDialog( ensureBackgroundThread { if (isRPlus()) { - updateMediaStoreTitle(recording, newTitle) + renameRecording(recording, newTitle) } else { - updateLegacyFilename(recording, newTitle) + renameRecordingLegacy(recording, newTitle) } activity.runOnUiThread { @@ -75,40 +72,25 @@ class RenameRecordingDialog( } } - private fun updateMediaStoreTitle(recording: Recording, newTitle: String) { + private fun renameRecording(recording: Recording, newTitle: String) { val oldExtension = recording.title.getFilenameExtension() val newDisplayName = "${newTitle.removeSuffix(".$oldExtension")}.$oldExtension" - val values = ContentValues().apply { - put(Media.TITLE, newTitle.substringAfterLast('.')) - put(Media.DISPLAY_NAME, newDisplayName) - } - - // if the old way of renaming fails, try the new SDK 30 one on Android 11+ try { - activity.contentResolver.update( - getAudioFileContentUri(recording.id.toLong()), - values, - null, - null - ) - } catch (e: Exception) { - 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()) - } + 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()) } - } catch (e: Exception) { - activity.showErrorToast(e) } + } catch (e: Exception) { + activity.showErrorToast(e) } } - private fun updateLegacyFilename(recording: Recording, newTitle: String) { + private fun renameRecordingLegacy(recording: Recording, newTitle: String) { val oldExtension = recording.title.getFilenameExtension() val oldPath = recording.path val newFilename = "${newTitle.removeSuffix(".$oldExtension")}.$oldExtension" diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/StoragePermissionDialog.kt b/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/StoragePermissionDialog.kt new file mode 100644 index 00000000..3cc1849c --- /dev/null +++ b/app/src/main/kotlin/org/fossify/voicerecorder/dialogs/StoragePermissionDialog.kt @@ -0,0 +1,34 @@ +package org.fossify.voicerecorder.dialogs + +import androidx.appcompat.app.AlertDialog +import org.fossify.commons.activities.BaseSimpleActivity +import org.fossify.commons.databinding.DialogMessageBinding +import org.fossify.commons.extensions.getAlertDialogBuilder +import org.fossify.commons.extensions.setupDialogStuff +import org.fossify.voicerecorder.R + +class StoragePermissionDialog( + private val activity: BaseSimpleActivity, + private val callback: (result: Boolean) -> Unit +) { + private var dialog: AlertDialog? = null + + init { + val view = DialogMessageBinding.inflate(activity.layoutInflater, null, false) + view.message.text = activity.getString(R.string.confirm_recording_folder) + + activity.getAlertDialogBuilder() + .setPositiveButton(org.fossify.commons.R.string.ok) { _, _ -> + callback(true) + } + .apply { + activity.setupDialogStuff( + view = view.root, + dialog = this, + cancelOnTouchOutside = false, + ) { alertDialog -> + dialog = alertDialog + } + } + } +} \ No newline at end of file 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 f0b30cb4..38ffaa8d 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Activity.kt @@ -1,186 +1,171 @@ package org.fossify.voicerecorder.extensions -import android.content.ContentValues -import android.provider.MediaStore -import android.provider.MediaStore.Audio.Media +import android.provider.DocumentsContract +import androidx.core.net.toUri import org.fossify.commons.activities.BaseSimpleActivity +import org.fossify.commons.dialogs.FilePickerDialog +import org.fossify.commons.extensions.createDocumentUriUsingFirstParentTreeUri import org.fossify.commons.extensions.deleteFile -import org.fossify.commons.extensions.getParentPath +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.isQPlus import org.fossify.commons.helpers.isRPlus import org.fossify.commons.models.FileDirItem -import org.fossify.voicerecorder.helpers.getAudioFileContentUri +import org.fossify.voicerecorder.dialogs.StoragePermissionDialog import org.fossify.voicerecorder.models.Recording import java.io.File -fun BaseSimpleActivity.deleteRecordings( - recordingsToRemove: Collection, - callback: (success: Boolean) -> Unit -) { - when { - isRPlus() -> { - val fileUris = recordingsToRemove.map { recording -> - getAudioFileContentUri(recording.id.toLong()) - } - - deleteSDK30Uris(fileUris, callback) +fun BaseSimpleActivity.ensureStoragePermission(callback: (result: Boolean) -> Unit) { + if (isRPlus() && !hasProperStoredFirstParentUri(config.saveRecordingsFolder)) { + StoragePermissionDialog(this) { + launchFilePickerDialog(callback) } + } else { + callback(true) + } +} - isQPlus() -> { - recordingsToRemove.forEach { - val uri = Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - val selection = "${Media._ID} = ?" - val selectionArgs = arrayOf(it.id.toString()) - val result = contentResolver.delete(uri, selection, selectionArgs) +fun BaseSimpleActivity.launchFilePickerDialog(callback: (success: Boolean) -> Unit) { + FilePickerDialog( + activity = this, + currPath = config.saveRecordingsFolder, + pickFile = false, + showFAB = true + ) { path -> + handleSAFDialog(path) { grantedSAF -> + if (!grantedSAF) { + callback(false) + return@handleSAFDialog + } - if (result == 0) { - val fileDirItem = File(it.path).toFileDirItem(this) - deleteFile(fileDirItem) + handleSAFDialogSdk30(path) { grantedSAF30 -> + if (!grantedSAF30) { + callback(false) + return@handleSAFDialogSdk30 } + + config.saveRecordingsFolder = path + callback(true) } - callback(true) } + } +} - else -> { +fun BaseSimpleActivity.deleteRecordings( + recordingsToRemove: Collection, + callback: (success: Boolean) -> Unit +) { + ensureBackgroundThread { + if (isRPlus()) { + val resolver = contentResolver + recordingsToRemove.forEach { + DocumentsContract.deleteDocument(resolver, it.path.toUri()) + } + } else { recordingsToRemove.forEach { val fileDirItem = File(it.path).toFileDirItem(this) deleteFile(fileDirItem) } - callback(true) } + + callback(true) } } +fun BaseSimpleActivity.trashRecordings( + recordingsToMove: Collection, + callback: (success: Boolean) -> Unit +) = moveRecordings( + recordingsToMove = recordingsToMove, + sourceParent = config.saveRecordingsFolder, + destinationParent = getOrCreateTrashFolder(), + callback = callback +) + fun BaseSimpleActivity.restoreRecordings( recordingsToRestore: Collection, callback: (success: Boolean) -> Unit +) = moveRecordings( + recordingsToMove = recordingsToRestore, + sourceParent = getOrCreateTrashFolder(), + destinationParent = config.saveRecordingsFolder, + callback = callback +) + +fun BaseSimpleActivity.moveRecordings( + recordingsToMove: Collection, + sourceParent: String, + destinationParent: String, + callback: (success: Boolean) -> Unit ) { - when { - isRPlus() -> { - val fileUris = recordingsToRestore.map { recording -> - getAudioFileContentUri(recording.id.toLong()) - } - - trashSDK30Uris(fileUris, false, callback) - } - - isQPlus() -> { - var wait = false - recordingsToRestore.forEach { - val uri = Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - val selection = "${Media._ID} = ?" - val selectionArgs = arrayOf(it.id.toString()) - val values = ContentValues().apply { - put(Media.IS_TRASHED, 0) - } - val result = contentResolver.update(uri, values, selection, selectionArgs) - - if (result == 0) { - wait = true - copyMoveFilesTo( - fileDirItems = arrayListOf(File(it.path).toFileDirItem(this)), - source = it.path.getParentPath(), - destination = config.saveRecordingsFolder, - isCopyOperation = false, - copyPhotoVideoOnly = false, - copyHidden = false - ) { - callback(true) - } - } - } - if (!wait) { - callback(true) - } - } - - else -> { - copyMoveFilesTo( - fileDirItems = recordingsToRestore - .map { File(it.path).toFileDirItem(this) } - .toMutableList() as ArrayList, - source = recordingsToRestore.first().path.getParentPath(), - destination = config.saveRecordingsFolder, - isCopyOperation = false, - copyPhotoVideoOnly = false, - copyHidden = false - ) { - callback(true) - } - } + if (isRPlus()) { + moveRecordingsSAF( + recordings = recordingsToMove, + sourceParent = sourceParent, + destinationParent = destinationParent, + callback = callback + ) + } else { + moveRecordingsLegacy( + recordings = recordingsToMove, + sourceParent = sourceParent, + destinationParent = destinationParent, + callback = callback + ) } } -fun BaseSimpleActivity.moveRecordingsToRecycleBin( - recordingsToMove: Collection, +private fun BaseSimpleActivity.moveRecordingsSAF( + recordings: Collection, + sourceParent: String, + destinationParent: String, callback: (success: Boolean) -> Unit ) { - when { - isRPlus() -> { - val fileUris = recordingsToMove.map { recording -> - getAudioFileContentUri(recording.id.toLong()) - } - - trashSDK30Uris(fileUris, true, callback) - } - - isQPlus() -> { - var wait = false - recordingsToMove.forEach { - val uri = Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - val selection = "${Media._ID} = ?" - val selectionArgs = arrayOf(it.id.toString()) - val values = ContentValues().apply { - put(Media.IS_TRASHED, 1) - } - val result = contentResolver.update(uri, values, selection, selectionArgs) - - if (result == 0) { - wait = true - copyMoveFilesTo( - fileDirItems = arrayListOf(File(it.path).toFileDirItem(this)), - source = it.path.getParentPath(), - destination = getOrCreateTrashFolder(), - isCopyOperation = false, - copyPhotoVideoOnly = false, - copyHidden = false - ) { - callback(true) - } - } - } - if (!wait) { - callback(true) - } + ensureBackgroundThread { + val contentResolver = contentResolver + val sourceParentDocumentUri = createDocumentUriUsingFirstParentTreeUri(sourceParent) + val destinationParentDocumentUri = + createDocumentUriUsingFirstParentTreeUri(destinationParent) + recordings.forEach { recording -> + DocumentsContract.moveDocument( + contentResolver, + recording.path.toUri(), + sourceParentDocumentUri, + destinationParentDocumentUri + ) } - else -> { - copyMoveFilesTo( - fileDirItems = recordingsToMove - .map { File(it.path).toFileDirItem(this) } - .toMutableList() as ArrayList, - source = recordingsToMove.first().path.getParentPath(), - destination = getOrCreateTrashFolder(), - isCopyOperation = false, - copyPhotoVideoOnly = false, - copyHidden = false - ) { - callback(true) - } - } + callback(true) } } -fun BaseSimpleActivity.checkRecycleBinItems() { - if (isQPlus()) { - // System is handling recycle bin on Q+ devices - return +private fun BaseSimpleActivity.moveRecordingsLegacy( + recordings: Collection, + sourceParent: String, + destinationParent: String, + callback: (success: Boolean) -> Unit +) { + copyMoveFilesTo( + fileDirItems = recordings + .map { File(it.path).toFileDirItem(this) } + .toMutableList() as ArrayList, + source = sourceParent, + destination = destinationParent, + isCopyOperation = false, + copyPhotoVideoOnly = false, + copyHidden = false + ) { + callback(true) } +} + +fun BaseSimpleActivity.deleteTrashedRecordings() { + deleteRecordings(getAllRecordings(trashed = true)) {} +} +fun BaseSimpleActivity.deleteExpiredTrashedRecordings() { if ( config.useRecycleBin && config.lastRecycleBinCheck < System.currentTimeMillis() - DAY_SECONDS * 1000 @@ -188,7 +173,7 @@ fun BaseSimpleActivity.checkRecycleBinItems() { config.lastRecycleBinCheck = System.currentTimeMillis() ensureBackgroundThread { try { - val recordingsToRemove = getLegacyRecordings(trashed = true) + val recordingsToRemove = getAllRecordings(trashed = true) .filter { it.timestamp < System.currentTimeMillis() - MONTH_SECONDS * 1000L } if (recordingsToRemove.isNotEmpty()) { deleteRecordings(recordingsToRemove) {} @@ -199,7 +184,3 @@ fun BaseSimpleActivity.checkRecycleBinItems() { } } } - -fun BaseSimpleActivity.emptyTheRecycleBin() { - deleteRecordings(getAllRecordings(trashed = true)) {} -} 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 b07e98ca..680ff3a3 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Context.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/Context.kt @@ -1,43 +1,34 @@ package org.fossify.voicerecorder.extensions -import android.annotation.SuppressLint import android.appwidget.AppWidgetManager import android.content.ComponentName -import android.content.ContentResolver import android.content.Context import android.content.Intent -import android.database.Cursor import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.drawable.Drawable import android.media.MediaMetadataRetriever import android.net.Uri -import android.os.Bundle -import android.os.Environment -import android.provider.MediaStore -import android.provider.MediaStore.Audio.Media +import androidx.documentfile.provider.DocumentFile import org.fossify.commons.extensions.getDocumentSdk30 import org.fossify.commons.extensions.getDuration -import org.fossify.commons.extensions.getIntValue -import org.fossify.commons.extensions.getLongValue -import org.fossify.commons.extensions.getStringValue import org.fossify.commons.extensions.internalStoragePath import org.fossify.commons.extensions.isAudioFast -import org.fossify.commons.extensions.queryCursor -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.getAudioFileContentUri import org.fossify.voicerecorder.models.Recording import java.io.File import kotlin.math.roundToLong val Context.config: Config get() = Config.newInstance(applicationContext) +val Context.trashFolder + get() = "${config.saveRecordingsFolder}/.trash" + fun Context.drawableToBitmap(drawable: Drawable): Bitmap { val size = (60 * resources.displayMetrics.density).toInt() val mutableBitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) @@ -65,85 +56,71 @@ fun Context.updateWidgets(isRecording: Boolean) { } } +fun Context.getOrCreateTrashFolder(): String { + val folder = File(trashFolder) + if (!folder.exists()) { + folder.mkdir() + } + return trashFolder +} + fun Context.getDefaultRecordingsFolder(): String { - val defaultPath = getDefaultRecordingsRelativePath() - return "$internalStoragePath/$defaultPath" + return "$internalStoragePath/$DEFAULT_RECORDINGS_FOLDER" } -fun Context.getDefaultRecordingsRelativePath(): String { - return if (isQPlus()) { - "${Environment.DIRECTORY_MUSIC}/Recordings" +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 + recordings.addAll(getMediaStoreTrashedRecordings()) + } + + recordings } else { - getString(R.string.app_name) + getLegacyRecordings(trashed) } } -@SuppressLint("InlinedApi") -fun Context.getNewMediaStoreRecordings(trashed: Boolean = false): ArrayList { +private fun Context.getRecordings(trashed: Boolean = false): ArrayList { val recordings = ArrayList() - - val uri = Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - val projection = arrayOf( - Media._ID, - Media.DISPLAY_NAME, - Media.DATE_ADDED, - Media.DURATION, - Media.SIZE - ) - - val bundle = Bundle().apply { - putStringArray(ContentResolver.QUERY_ARG_SORT_COLUMNS, arrayOf(Media.DATE_ADDED)) - putInt( - ContentResolver.QUERY_ARG_SORT_DIRECTION, - ContentResolver.QUERY_SORT_DIRECTION_DESCENDING - ) - putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${Media.OWNER_PACKAGE_NAME} = ?") - putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(packageName)) - if (config.useRecycleBin) { - val trashedValue = if (trashed) MediaStore.MATCH_ONLY else MediaStore.MATCH_EXCLUDE - putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, trashedValue) + 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) + ) } } - queryCursor(uri, projection, bundle, true) { cursor -> - val recording = readRecordingFromCursor(cursor) - recordings.add(recording) - } return recordings } -@SuppressLint("InlinedApi") -fun Context.getMediaStoreRecordings(trashed: Boolean = false): ArrayList { +@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 uri = Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - val projection = arrayOf( - Media._ID, - Media.DISPLAY_NAME, - Media.DATE_ADDED, - Media.DURATION, - Media.SIZE - ) - - var selection = "${Media.OWNER_PACKAGE_NAME} = ?" - var selectionArgs = arrayOf(packageName) - val sortOrder = "${Media.DATE_ADDED} DESC" - - if (config.useRecycleBin) { - val trashedValue = if (trashed) 1 else 0 - selection += " AND ${Media.IS_TRASHED} = ?" - selectionArgs = selectionArgs.plus(trashedValue.toString()) - } - - queryCursor(uri, projection, selection, selectionArgs, sortOrder, true) { cursor -> - val recording = readRecordingFromCursor(cursor) - recordings.add(recording) + val folder = config.saveRecordingsFolder + val documentFiles = getDocumentSdk30(folder)?.listFiles() ?: return recordings + documentFiles.forEach { file -> + if (file.isTrashedMediaStoreRecording()) { + val recording = readRecordingFromFile(file) + recordings.add( + recording.copy( + title = "^\\.trashed-\\d+-".toRegex().replace(file.name!!, "") + ) + ) + } } return recordings } -fun Context.getLegacyRecordings(trashed: Boolean = false): ArrayList { +private fun Context.getLegacyRecordings(trashed: Boolean = false): ArrayList { val recordings = ArrayList() val folder = if (trashed) { trashFolder @@ -173,106 +150,23 @@ fun Context.getLegacyRecordings(trashed: Boolean = false): ArrayList return recordings } -fun Context.getSAFRecordings(trashed: Boolean = false): ArrayList { - val recordings = ArrayList() - val folder = if (trashed) { - trashFolder - } else { - config.saveRecordingsFolder - } - val files = getDocumentSdk30(folder)?.listFiles() ?: return recordings - - files.filter { it.type?.startsWith("audio") == true && !it.name.isNullOrEmpty() }.forEach { - val id = it.hashCode() - val title = it.name!! - val path = it.uri.toString() - val timestamp = (it.lastModified() / 1000).toInt() - val duration = getDurationFromUri(it.uri) - val size = it.length().toInt() - recordings.add( - Recording( - id = id, - title = title, - path = path, - timestamp = timestamp, - duration = duration.toInt(), - size = size - ) - ) - } - - recordings.sortByDescending { it.timestamp } - return recordings -} - -fun Context.getAllRecordings(trashed: Boolean = false): ArrayList { - val recordings = ArrayList() - return when { - isRPlus() -> { - recordings.addAll(getNewMediaStoreRecordings(trashed)) - recordings.addAll(getSAFRecordings(trashed)) - recordings - } - - isQPlus() -> { - recordings.addAll(getMediaStoreRecordings(trashed)) - recordings.addAll(getLegacyRecordings(trashed)) - recordings - } - - else -> { - recordings.addAll(getLegacyRecordings(trashed)) - recordings - } - } -} - -val Context.trashFolder - get() = "${config.saveRecordingsFolder}/.trash" - -fun Context.getOrCreateTrashFolder(): String { - val folder = File(trashFolder) - if (!folder.exists()) { - folder.mkdir() - } - return trashFolder -} - -private fun Context.readRecordingFromCursor(cursor: Cursor): Recording { - val id = cursor.getIntValue(Media._ID) - val title = cursor.getStringValue(Media.DISPLAY_NAME) - val timestamp = cursor.getIntValue(Media.DATE_ADDED) - var duration = cursor.getLongValue(Media.DURATION) / 1000 - var size = cursor.getIntValue(Media.SIZE) - - if (duration == 0L) { - duration = getDurationFromUri(getAudioFileContentUri(id.toLong())) - } - - if (size == 0) { - size = getSizeFromUri(id.toLong()) - } - +private fun Context.readRecordingFromFile(file: DocumentFile): Recording { + val id = file.hashCode() + val title = file.name!! + val path = file.uri.toString() + val timestamp = (file.lastModified() / 1000).toInt() + val duration = getDurationFromUri(file.uri) + val size = file.length().toInt() return Recording( id = id, title = title, - path = "", + path = path, timestamp = timestamp, duration = duration.toInt(), size = size ) } -private fun Context.getSizeFromUri(id: Long): Int { - val recordingUri = getAudioFileContentUri(id) - return try { - contentResolver.openInputStream(recordingUri) - ?.use { it.available() } ?: 0 - } catch (e: Exception) { - 0 - } -} - private fun Context.getDurationFromUri(uri: Uri): Long { return try { val retriever = MediaMetadataRetriever() diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/DocumentFile.kt b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/DocumentFile.kt new file mode 100644 index 00000000..50d92fcf --- /dev/null +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/DocumentFile.kt @@ -0,0 +1,11 @@ +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-") +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/extensions/String.kt b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/String.kt new file mode 100644 index 00000000..e7fce211 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/voicerecorder/extensions/String.kt @@ -0,0 +1,5 @@ +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/PlayerFragment.kt b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/PlayerFragment.kt index c99d82c8..79965156 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/PlayerFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/PlayerFragment.kt @@ -5,13 +5,12 @@ import android.content.Context import android.graphics.drawable.Drawable import android.media.AudioAttributes import android.media.MediaPlayer -import android.net.Uri import android.os.Handler import android.os.Looper import android.os.PowerManager -import android.provider.DocumentsContract 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 @@ -30,7 +29,6 @@ import org.fossify.voicerecorder.activities.SimpleActivity import org.fossify.voicerecorder.adapters.RecordingsAdapter import org.fossify.voicerecorder.databinding.FragmentPlayerBinding import org.fossify.voicerecorder.extensions.config -import org.fossify.voicerecorder.helpers.getAudioFileContentUri import org.fossify.voicerecorder.interfaces.RefreshRecordingsListener import org.fossify.voicerecorder.models.Events import org.fossify.voicerecorder.models.Recording @@ -246,20 +244,7 @@ class PlayerFragment( reset() try { - val uri = Uri.parse(recording.path) - when { - DocumentsContract.isDocumentUri(context, uri) -> { - setDataSource(context, uri) - } - - recording.path.isEmpty() -> { - setDataSource(context, getAudioFileContentUri(recording.id.toLong())) - } - - else -> { - setDataSource(recording.path) - } - } + setDataSource(context, recording.path.toUri()) } catch (e: Exception) { context?.showErrorToast(e) return 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 e492f160..a7a5b6b9 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt @@ -20,8 +20,10 @@ 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.toast import org.fossify.voicerecorder.databinding.FragmentRecorderBinding import org.fossify.voicerecorder.extensions.config +import org.fossify.voicerecorder.extensions.ensureStoragePermission import org.fossify.voicerecorder.extensions.setDebouncedClickListener import org.fossify.voicerecorder.helpers.GET_RECORDER_INFO import org.fossify.voicerecorder.helpers.RECORDING_PAUSED @@ -74,17 +76,24 @@ class RecorderFragment( updateRecordingDuration(0) binding.toggleRecordingButton.setDebouncedClickListener { - (context as? BaseSimpleActivity)?.handleNotificationPermission { granted -> - if (granted) { - toggleRecording() - } else { - PermissionRequiredDialog( - activity = context as BaseSimpleActivity, - textId = org.fossify.commons.R.string.allow_notifications_voice_recorder, - positiveActionCallback = { - (context as BaseSimpleActivity).openNotificationSettings() + val activity = context as? BaseSimpleActivity + activity?.ensureStoragePermission { + if (it) { + activity.handleNotificationPermission { granted -> + if (granted) { + toggleRecording() + } 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/helpers/Constants.kt b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Constants.kt index a5ebd607..f42f790c 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Constants.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Constants.kt @@ -1,12 +1,5 @@ package org.fossify.voicerecorder.helpers -import android.annotation.SuppressLint -import android.content.ContentUris -import android.net.Uri -import android.provider.MediaStore -import android.provider.MediaStore.Audio.Media -import org.fossify.commons.helpers.isQPlus - const val REPOSITORY_NAME = "Voice-Recorder" const val RECORDER_RUNNING_NOTIF_ID = 10000 @@ -42,13 +35,4 @@ const val USE_RECYCLE_BIN = "use_recycle_bin" const val LAST_RECYCLE_BIN_CHECK = "last_recycle_bin_check" const val KEEP_SCREEN_ON = "keep_screen_on" -@SuppressLint("InlinedApi") -fun getAudioFileContentUri(id: Long): Uri { - val baseUri = if (isQPlus()) { - Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - } else { - Media.EXTERNAL_CONTENT_URI - } - - return ContentUris.withAppendedId(baseUri, id) -} +const val DEFAULT_RECORDINGS_FOLDER = "Recordings" \ No newline at end of file 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 772e8939..9b133697 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt @@ -6,25 +6,20 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service -import android.content.ContentValues import android.content.Context import android.content.Intent import android.media.MediaScannerConnection import android.net.Uri import android.os.IBinder -import android.provider.MediaStore -import android.provider.MediaStore.Audio.Media import androidx.core.app.NotificationCompat import org.fossify.commons.extensions.createDocumentUriUsingFirstParentTreeUri import org.fossify.commons.extensions.createSAFFileSdk30 import org.fossify.commons.extensions.getCurrentFormattedDateTime import org.fossify.commons.extensions.getDocumentFile -import org.fossify.commons.extensions.getFileInputStreamSync import org.fossify.commons.extensions.getFilenameFromPath import org.fossify.commons.extensions.getLaunchIntent import org.fossify.commons.extensions.getMimeType import org.fossify.commons.extensions.getParentPath -import org.fossify.commons.extensions.hasProperStoredFirstParentUri import org.fossify.commons.extensions.isPathOnSD import org.fossify.commons.extensions.showErrorToast import org.fossify.commons.extensions.toast @@ -33,7 +28,6 @@ import org.fossify.commons.helpers.isRPlus import org.fossify.voicerecorder.R import org.fossify.voicerecorder.activities.SplashActivity import org.fossify.voicerecorder.extensions.config -import org.fossify.voicerecorder.extensions.getDefaultRecordingsRelativePath import org.fossify.voicerecorder.extensions.updateWidgets import org.fossify.voicerecorder.helpers.EXTENSION_MP3 import org.fossify.voicerecorder.helpers.GET_RECORDER_INFO @@ -60,7 +54,7 @@ class RecorderService : Service() { } - private var currFilePath = "" + private var recordingFile = "" private var duration = 0 private var status = RECORDING_STOPPED private var durationTimer = Timer() @@ -103,14 +97,8 @@ class RecorderService : Service() { defaultFolder.mkdir() } - val baseFolder = - if (isRPlus() && !hasProperStoredFirstParentUri(defaultFolder.absolutePath)) { - cacheDir - } else { - defaultFolder.absolutePath - } - - currFilePath = "$baseFolder/${getCurrentFormattedDateTime()}.${config.getExtension()}" + val recordingFolder = defaultFolder.absolutePath + recordingFile = "$recordingFolder/${getCurrentFormattedDateTime()}.${config.getExtension()}" try { recorder = if (recordMp3()) { @@ -119,24 +107,24 @@ class RecorderService : Service() { MediaRecorderWrapper(this) } - if (isRPlus() && hasProperStoredFirstParentUri(currFilePath)) { - val fileUri = createDocumentUriUsingFirstParentTreeUri(currFilePath) - createSAFFileSdk30(currFilePath) + if (isRPlus()) { + val fileUri = createDocumentUriUsingFirstParentTreeUri(recordingFile) + createSAFFileSdk30(recordingFile) val outputFileDescriptor = contentResolver.openFileDescriptor(fileUri, "w")!!.fileDescriptor recorder?.setOutputFile(outputFileDescriptor) - } else if (!isRPlus() && isPathOnSD(currFilePath)) { - var document = getDocumentFile(currFilePath.getParentPath()) - document = document?.createFile("", currFilePath.getFilenameFromPath()) + } else if (isPathOnSD(recordingFile)) { + var document = getDocumentFile(recordingFile.getParentPath()) + document = document?.createFile("", recordingFile.getFilenameFromPath()) val outputFileDescriptor = contentResolver.openFileDescriptor(document!!.uri, "w")!!.fileDescriptor recorder?.setOutputFile(outputFileDescriptor) } else { - recorder?.setOutputFile(currFilePath) + recorder?.setOutputFile(recordingFile) } recorder?.prepare() @@ -165,13 +153,8 @@ class RecorderService : Service() { try { stop() release() - ensureBackgroundThread { - if (isRPlus() && !hasProperStoredFirstParentUri(currFilePath)) { - addFileInNewMediaStore() - } else { - addFileInLegacyMediaStore() - } + scanRecording() EventBus.getDefault().post(Events.RecordingCompleted()) } } catch (e: RuntimeException) { @@ -213,40 +196,19 @@ class RecorderService : Service() { } } - @SuppressLint("InlinedApi") - private fun addFileInNewMediaStore() { - val audioCollection = Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - val storeFilename = currFilePath.getFilenameFromPath() - - val newSongDetails = ContentValues().apply { - put(Media.DISPLAY_NAME, storeFilename) - put(Media.TITLE, storeFilename) - put(Media.MIME_TYPE, storeFilename.getMimeType()) - put(Media.RELATIVE_PATH, getDefaultRecordingsRelativePath()) - } - - val newUri = contentResolver.insert(audioCollection, newSongDetails) - if (newUri == null) { - toast(org.fossify.commons.R.string.unknown_error_occurred) - return - } - - try { - val outputStream = contentResolver.openOutputStream(newUri) - val inputStream = getFileInputStreamSync(currFilePath) - inputStream!!.copyTo(outputStream!!, DEFAULT_BUFFER_SIZE) - recordingSavedSuccessfully(newUri) - } catch (e: Exception) { - showErrorToast(e) - } - } - - private fun addFileInLegacyMediaStore() { + private fun scanRecording() { MediaScannerConnection.scanFile( this, - arrayOf(currFilePath), - arrayOf(currFilePath.getMimeType()) - ) { _, uri -> recordingSavedSuccessfully(uri) } + arrayOf(recordingFile), + arrayOf(recordingFile.getMimeType()) + ) { _, uri -> + if (uri == null) { + toast(org.fossify.commons.R.string.unknown_error_occurred) + return@scanFile + } + + recordingSavedSuccessfully(uri) + } } private fun recordingSavedSuccessfully(savedUri: Uri) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fdd05a63..77d49178 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,7 @@ Voice Recorder + You must confirm the folder where you want to save your recordings. Please press "OK" to continue. Recording saved successfully Recording was too short to record! Recording diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ba8c5831..d6769640 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ androidx-swiperefreshlayout = "1.1.0" eventbus = "3.3.1" #Fossify #noinspection GradleDependency -commons = "4013116a24" +commons = "1d71c8a2e8" #AudioRecordView audiorecordview = "1.0.4" #TAndroidLame