From 1134819b254d0b7b3e2691cf745a724a79f50e4f Mon Sep 17 00:00:00 2001 From: darthpaul Date: Sun, 17 Apr 2022 16:13:51 +0100 Subject: [PATCH 1/2] allow users to customise recording folder - use SAF on SDK 30+ to give user a change to change the default location where recordings are stored - since we have requestLegacyExternalStorage="true" in the AndroidManifest, we can enable WRITE_EXTERNAL_STORAGE up to SDK 29 (Android 10) - modify methods to store recordings in RecorderService to use SAF for SDK 30+ and normal file paths for lower SDK versions. - at first installation, the app would not have SAF permissions, so we use the old method, using MediaStore and the app's cacheDir until the users decide to change; then we can switch to using SAF - modify methods to get all recordings in PlayerFragment - on SDK 30+, we need to combine recordings got from the MediaStore (getMediaStoreRecordings) and the ones from the recordings folder SAF (getSAFRecordings) - on SDK 29, we combine recordings got from the MediaStore (getMediaStoreRecordings) and the ones from direct file path (getLegacyRecordings) - on lower SDKs, we just use the file paths (getLegacyRecordings) - modify method for playing recordings in PlayerFragment - SDK 30+, when getting recordings with SAF, we store the string of the SAF URI of the file as the Recording#path field, we check if the path is a Document URI and pass that to the MediaPlayer#setDataSource method - if the Recording#path field is empty, we get the MediaStore URI using the ID and pass that to the MediaPlayer#setDataSource method - in other cases, the Recording#path field is a file path, so we use it. - in RecordingsAdapter, add support for changes to the paths used for in sharing the recordings - update the commons module --- app/build.gradle | 2 +- app/src/main/AndroidManifest.xml | 2 +- .../voicerecorder/activities/MainActivity.kt | 2 +- .../activities/SettingsActivity.kt | 13 +++- .../adapters/RecordingsAdapter.kt | 8 +-- .../voicerecorder/extensions/Context.kt | 17 +++++ .../voicerecorder/fragments/PlayerFragment.kt | 63 +++++++++++++++---- .../voicerecorder/helpers/Config.kt | 3 +- .../voicerecorder/services/RecorderService.kt | 27 ++++---- 9 files changed, 103 insertions(+), 34 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 7df722d..542a273 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -62,7 +62,7 @@ android { } dependencies { - implementation 'com.github.SimpleMobileTools:Simple-Commons:e3376e4f56' + implementation 'com.github.SimpleMobileTools:Simple-Commons:202656a071' implementation 'org.greenrobot:eventbus:3.2.0' implementation 'com.github.Armen101:AudioRecordView:1.0.4' implementation 'androidx.documentfile:documentfile:1.0.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d18255f..066c962 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,7 +9,7 @@ + android:maxSdkVersion="29" /> + if (!grantedSAF) { + return@handleSAFDialog + } + + handleSAFDialogSdk30(path) { grantedSAF30 -> + if (!grantedSAF30) { + return@handleSAFDialogSdk30 + } + config.saveRecordingsFolder = path settings_save_recordings.text = humanizePath(config.saveRecordingsFolder) } diff --git a/app/src/main/kotlin/com/simplemobiletools/voicerecorder/adapters/RecordingsAdapter.kt b/app/src/main/kotlin/com/simplemobiletools/voicerecorder/adapters/RecordingsAdapter.kt index 1da113b..0caa620 100644 --- a/app/src/main/kotlin/com/simplemobiletools/voicerecorder/adapters/RecordingsAdapter.kt +++ b/app/src/main/kotlin/com/simplemobiletools/voicerecorder/adapters/RecordingsAdapter.kt @@ -116,10 +116,10 @@ class RecordingsAdapter( private fun shareRecordings() { val selectedItems = getSelectedItems() - val paths = if (isQPlus()) { - selectedItems.map { getAudioFileContentUri(it.id.toLong()).toString() } - } else { - selectedItems.map { it.path } + val paths = selectedItems.map { + it.path.ifEmpty { + getAudioFileContentUri(it.id.toLong()).toString() + } } activity.sharePathsIntent(paths, BuildConfig.APPLICATION_ID) diff --git a/app/src/main/kotlin/com/simplemobiletools/voicerecorder/extensions/Context.kt b/app/src/main/kotlin/com/simplemobiletools/voicerecorder/extensions/Context.kt index b5f88f1..17067f1 100644 --- a/app/src/main/kotlin/com/simplemobiletools/voicerecorder/extensions/Context.kt +++ b/app/src/main/kotlin/com/simplemobiletools/voicerecorder/extensions/Context.kt @@ -7,6 +7,10 @@ import android.content.Intent import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.drawable.Drawable +import android.os.Environment +import com.simplemobiletools.commons.extensions.internalStoragePath +import com.simplemobiletools.commons.helpers.isQPlus +import com.simplemobiletools.voicerecorder.R import com.simplemobiletools.voicerecorder.helpers.Config import com.simplemobiletools.voicerecorder.helpers.IS_RECORDING import com.simplemobiletools.voicerecorder.helpers.MyWidgetRecordDisplayProvider @@ -34,3 +38,16 @@ fun Context.updateWidgets(isRecording: Boolean) { } } } + +fun Context.getDefaultRecordingsFolder(): String { + val defaultPath = getDefaultRecordingsRelativePath() + return "$internalStoragePath/$defaultPath" +} + +fun Context.getDefaultRecordingsRelativePath(): String { + return if (isQPlus()) { + "${Environment.DIRECTORY_MUSIC}/Recordings" + } else { + getString(R.string.app_name) + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/voicerecorder/fragments/PlayerFragment.kt b/app/src/main/kotlin/com/simplemobiletools/voicerecorder/fragments/PlayerFragment.kt index f2546ca..d906a7f 100644 --- a/app/src/main/kotlin/com/simplemobiletools/voicerecorder/fragments/PlayerFragment.kt +++ b/app/src/main/kotlin/com/simplemobiletools/voicerecorder/fragments/PlayerFragment.kt @@ -6,15 +6,18 @@ import android.graphics.drawable.Drawable import android.media.AudioManager import android.media.MediaMetadataRetriever 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.provider.MediaStore import android.provider.MediaStore.Audio.Media import android.util.AttributeSet import android.widget.SeekBar import com.simplemobiletools.commons.extensions.* import com.simplemobiletools.commons.helpers.isQPlus +import com.simplemobiletools.commons.helpers.isRPlus import com.simplemobiletools.voicerecorder.R import com.simplemobiletools.voicerecorder.activities.SimpleActivity import com.simplemobiletools.voicerecorder.adapters.RecordingsAdapter @@ -29,6 +32,8 @@ import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import java.io.File import java.util.* +import kotlin.collections.ArrayList +import kotlin.math.roundToLong class PlayerFragment(context: Context, attributeSet: AttributeSet) : MyViewPagerFragment(context, attributeSet), RefreshRecordingsListener { private val FAST_FORWARD_SKIP_MS = 10000 @@ -162,10 +167,20 @@ class PlayerFragment(context: Context, attributeSet: AttributeSet) : MyViewPager } private fun getRecordings(): ArrayList { - return if (isQPlus()) { - getMediaStoreRecordings() - } else { - getLegacyRecordings() + return when { + isRPlus() -> { + ArrayList(getMediaStoreRecordings() + getSAFRecordings()).apply { + sortByDescending { it.timestamp } + } + } + isQPlus() -> { + ArrayList(getMediaStoreRecordings() + getLegacyRecordings()).apply { + sortByDescending { it.timestamp } + } + } + else -> { + getLegacyRecordings() + } } } @@ -194,7 +209,7 @@ class PlayerFragment(context: Context, attributeSet: AttributeSet) : MyViewPager var size = cursor.getIntValue(Media.SIZE) if (duration == 0L) { - duration = getDurationFromUri(id.toLong()) + duration = getDurationFromUri(getAudioFileContentUri(id.toLong())) } if (size == 0) { @@ -222,17 +237,34 @@ class PlayerFragment(context: Context, attributeSet: AttributeSet) : MyViewPager val recording = Recording(id, title, path, timestamp, duration, size) recordings.add(recording) } + return recordings + } + + private fun getSAFRecordings(): ArrayList { + val recordings = ArrayList() + val files = context.getDocumentSdk30(context.config.saveRecordingsFolder)?.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() + val recording = Recording(id, title, path, timestamp, duration.toInt(), size) + recordings.add(recording) + } recordings.sortByDescending { it.timestamp } return recordings } - private fun getDurationFromUri(id: Long): Long { + private fun getDurationFromUri(uri: Uri): Long { return try { val retriever = MediaMetadataRetriever() - retriever.setDataSource(context, getAudioFileContentUri(id)) + retriever.setDataSource(context, uri) val time = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)!! - Math.round(time.toLong() / 1000.toDouble()) + (time.toLong() / 1000.toDouble()).roundToLong() } catch (e: Exception) { 0L } @@ -279,10 +311,17 @@ class PlayerFragment(context: Context, attributeSet: AttributeSet) : MyViewPager reset() try { - if (isQPlus()) { - setDataSource(context, getAudioFileContentUri(recording.id.toLong())) - } else { - setDataSource(recording.path) + 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) + } } } catch (e: Exception) { context?.showErrorToast(e) diff --git a/app/src/main/kotlin/com/simplemobiletools/voicerecorder/helpers/Config.kt b/app/src/main/kotlin/com/simplemobiletools/voicerecorder/helpers/Config.kt index 7bca16c..63e7f30 100644 --- a/app/src/main/kotlin/com/simplemobiletools/voicerecorder/helpers/Config.kt +++ b/app/src/main/kotlin/com/simplemobiletools/voicerecorder/helpers/Config.kt @@ -4,6 +4,7 @@ import android.content.Context import android.media.MediaRecorder import com.simplemobiletools.commons.helpers.BaseConfig import com.simplemobiletools.voicerecorder.R +import com.simplemobiletools.voicerecorder.extensions.getDefaultRecordingsFolder class Config(context: Context) : BaseConfig(context) { companion object { @@ -15,7 +16,7 @@ class Config(context: Context) : BaseConfig(context) { set(hideNotification) = prefs.edit().putBoolean(HIDE_NOTIFICATION, hideNotification).apply() var saveRecordingsFolder: String - get() = prefs.getString(SAVE_RECORDINGS, "$internalStoragePath/${context.getString(R.string.app_name)}")!! + get() = prefs.getString(SAVE_RECORDINGS, context.getDefaultRecordingsFolder())!! set(saveRecordingsFolder) = prefs.edit().putString(SAVE_RECORDINGS, saveRecordingsFolder).apply() var extension: Int diff --git a/app/src/main/kotlin/com/simplemobiletools/voicerecorder/services/RecorderService.kt b/app/src/main/kotlin/com/simplemobiletools/voicerecorder/services/RecorderService.kt index 0cf7b19..f28aa34 100644 --- a/app/src/main/kotlin/com/simplemobiletools/voicerecorder/services/RecorderService.kt +++ b/app/src/main/kotlin/com/simplemobiletools/voicerecorder/services/RecorderService.kt @@ -9,7 +9,6 @@ import android.content.Intent import android.media.MediaRecorder import android.media.MediaScannerConnection import android.os.Build -import android.os.Environment import android.os.IBinder import android.provider.MediaStore import android.provider.MediaStore.Audio.Media @@ -17,10 +16,11 @@ import androidx.core.app.NotificationCompat import com.simplemobiletools.commons.extensions.* import com.simplemobiletools.commons.helpers.ensureBackgroundThread import com.simplemobiletools.commons.helpers.isOreoPlus -import com.simplemobiletools.commons.helpers.isQPlus +import com.simplemobiletools.commons.helpers.isRPlus import com.simplemobiletools.voicerecorder.R import com.simplemobiletools.voicerecorder.activities.SplashActivity import com.simplemobiletools.voicerecorder.extensions.config +import com.simplemobiletools.voicerecorder.extensions.getDefaultRecordingsRelativePath import com.simplemobiletools.voicerecorder.extensions.updateWidgets import com.simplemobiletools.voicerecorder.helpers.* import com.simplemobiletools.voicerecorder.models.Events @@ -72,14 +72,14 @@ class RecorderService : Service() { return } - val baseFolder = if (isQPlus()) { + val defaultFolder = File(config.saveRecordingsFolder) + if (!defaultFolder.exists()) { + defaultFolder.mkdir() + } + + val baseFolder = if (isRPlus() && !hasProperStoredFirstParentUri(defaultFolder.absolutePath)) { cacheDir } else { - val defaultFolder = File(config.saveRecordingsFolder) - if (!defaultFolder.exists()) { - defaultFolder.mkdir() - } - defaultFolder.absolutePath } @@ -93,7 +93,12 @@ class RecorderService : Service() { setAudioEncodingBitRate(config.bitrate) setAudioSamplingRate(44100) - if (!isQPlus() && isPathOnSD(currFilePath)) { + if (isRPlus() && hasProperStoredFirstParentUri(currFilePath)) { + val fileUri = createDocumentUriUsingFirstParentTreeUri(currFilePath) + createSAFFileSdk30(currFilePath) + val outputFileDescriptor = contentResolver.openFileDescriptor(fileUri, "w")!!.fileDescriptor + setOutputFile(outputFileDescriptor) + } else if (!isRPlus() && isPathOnSD(currFilePath)) { var document = getDocumentFile(currFilePath.getParentPath()) document = document?.createFile("", currFilePath.getFilenameFromPath()) @@ -132,7 +137,7 @@ class RecorderService : Service() { release() ensureBackgroundThread { - if (isQPlus()) { + if (isRPlus() && !hasProperStoredFirstParentUri(currFilePath) ) { addFileInNewMediaStore() } else { addFileInLegacyMediaStore() @@ -184,7 +189,7 @@ class RecorderService : Service() { put(Media.DISPLAY_NAME, storeFilename) put(Media.TITLE, storeFilename) put(Media.MIME_TYPE, storeFilename.getMimeType()) - put(Media.RELATIVE_PATH, "${Environment.DIRECTORY_MUSIC}/Recordings") + put(Media.RELATIVE_PATH, getDefaultRecordingsRelativePath()) } val newUri = contentResolver.insert(audioCollection, newSongDetails) From ccc82c86050b97846811d6235d7055a89252757f Mon Sep 17 00:00:00 2001 From: darthpaul Date: Sun, 17 Apr 2022 17:09:29 +0100 Subject: [PATCH 2/2] cleanup PlayerFragment --- .../voicerecorder/fragments/PlayerFragment.kt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/com/simplemobiletools/voicerecorder/fragments/PlayerFragment.kt b/app/src/main/kotlin/com/simplemobiletools/voicerecorder/fragments/PlayerFragment.kt index d906a7f..21e821b 100644 --- a/app/src/main/kotlin/com/simplemobiletools/voicerecorder/fragments/PlayerFragment.kt +++ b/app/src/main/kotlin/com/simplemobiletools/voicerecorder/fragments/PlayerFragment.kt @@ -167,20 +167,24 @@ class PlayerFragment(context: Context, attributeSet: AttributeSet) : MyViewPager } private fun getRecordings(): ArrayList { + val recordings = ArrayList() return when { isRPlus() -> { - ArrayList(getMediaStoreRecordings() + getSAFRecordings()).apply { - sortByDescending { it.timestamp } - } + recordings.addAll(getMediaStoreRecordings()) + recordings.addAll(getSAFRecordings()) + recordings } isQPlus() -> { - ArrayList(getMediaStoreRecordings() + getLegacyRecordings()).apply { - sortByDescending { it.timestamp } - } + recordings.addAll(getMediaStoreRecordings()) + recordings.addAll(getLegacyRecordings()) + recordings } else -> { - getLegacyRecordings() + recordings.addAll(getLegacyRecordings()) + recordings } + }.apply { + sortByDescending { it.timestamp } } }