diff --git a/app/src/main/java/app/myzel394/alibi/helpers/AudioBatchesFolder.kt b/app/src/main/java/app/myzel394/alibi/helpers/AudioBatchesFolder.kt index 14ef3eb..b709f4f 100644 --- a/app/src/main/java/app/myzel394/alibi/helpers/AudioBatchesFolder.kt +++ b/app/src/main/java/app/myzel394/alibi/helpers/AudioBatchesFolder.kt @@ -2,12 +2,16 @@ package app.myzel394.alibi.helpers import android.content.Context import android.net.Uri +import android.os.Build +import android.os.Environment import android.os.ParcelFileDescriptor import android.provider.MediaStore -import androidx.core.net.toFile +import androidx.annotation.RequiresApi import androidx.documentfile.provider.DocumentFile -import app.myzel394.alibi.helpers.MediaConverter.Companion.concatenateVideoFiles -import app.myzel394.alibi.helpers.VideoBatchesFolder.Companion.MEDIA_SUBFOLDER +import app.myzel394.alibi.helpers.MediaConverter.Companion.concatenateAudioFiles +import app.myzel394.alibi.ui.MEDIA_SUBFOLDER_NAME +import app.myzel394.alibi.ui.RECORDER_INTERNAL_SELECTED_VALUE +import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE import com.arthenica.ffmpegkit.FFmpegKitConfig import java.io.File import java.io.FileDescriptor @@ -24,15 +28,17 @@ class AudioBatchesFolder( customFolder, subfolderName, ) { - override val concatenationFunction = ::concatenateVideoFiles + override val concatenationFunction = ::concatenateAudioFiles override val ffmpegParameters = FFMPEG_PARAMETERS override val scopedMediaContentUri: Uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI override val legacyMediaFolder = File( - scopedMediaContentUri.toFile(), - MEDIA_SUBFOLDER + "/" + subfolderName + // TODO: Add support for `DIRECTORY_RECORDINGS` + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM), + MEDIA_RECORDINGS_SUBFOLDER, ) private var customFileFileDescriptor: ParcelFileDescriptor? = null + private var mediaFileFileDescriptor: ParcelFileDescriptor? = null override fun getOutputFileForFFmpeg( date: LocalDateTime, @@ -54,7 +60,28 @@ class AudioBatchesFolder( } BatchType.MEDIA -> { - return "" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val mediaUri = getOrCreateMediaFile( + name = getName(date, extension), + mimeType = "audio/$extension", + relativePath = Environment.DIRECTORY_DCIM + "/" + MEDIA_SUBFOLDER_NAME, + ) + + return FFmpegKitConfig.getSafParameterForWrite( + context, + mediaUri + )!! + } else { + val path = arrayOf( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM), + MEDIA_SUBFOLDER_NAME, + getName(date, extension) + ).joinToString("/") + return File(path) + .apply { + createNewFile() + }.absolutePath + } } } } @@ -63,6 +90,9 @@ class AudioBatchesFolder( runCatching { customFileFileDescriptor?.close() } + runCatching { + mediaFileFileDescriptor?.close() + } } fun asCustomGetFileDescriptor( @@ -81,17 +111,44 @@ class AudioBatchesFolder( return customFileFileDescriptor!!.fileDescriptor } + @RequiresApi(Build.VERSION_CODES.Q) + fun asMediaGetScopedStorageFileDescriptor( + name: String, + mimeType: String + ): FileDescriptor { + runCatching { + mediaFileFileDescriptor?.close() + } + + val mediaUri = getOrCreateMediaFile( + name = name, + mimeType = mimeType, + relativePath = SCOPED_STORAGE_RELATIVE_PATH, + ) + + mediaFileFileDescriptor = context.contentResolver.openFileDescriptor(mediaUri, "w")!! + + return mediaFileFileDescriptor!!.fileDescriptor + } + companion object { fun viaInternalFolder(context: Context) = AudioBatchesFolder(context, BatchType.INTERNAL) fun viaCustomFolder(context: Context, folder: DocumentFile) = AudioBatchesFolder(context, BatchType.CUSTOM, folder) + fun viaMediaFolder(context: Context) = AudioBatchesFolder(context, BatchType.MEDIA) + fun importFromFolder(folder: String, context: Context) = when (folder) { - "_'internal" -> viaInternalFolder(context) + RECORDER_INTERNAL_SELECTED_VALUE -> viaInternalFolder(context) + RECORDER_MEDIA_SELECTED_VALUE -> viaMediaFolder(context) else -> viaCustomFolder(context, DocumentFile.fromTreeUri(context, Uri.parse(folder))!!) } + val MEDIA_RECORDINGS_SUBFOLDER = MEDIA_SUBFOLDER_NAME + "/audio_recordings" + val SCOPED_STORAGE_RELATIVE_PATH = + Environment.DIRECTORY_DCIM + "/" + MEDIA_RECORDINGS_SUBFOLDER + // Parameters to be passed in descending order // Those parameters first try to concatenate without re-encoding // if that fails, it'll try several fallback methods diff --git a/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt b/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt index e37d9c8..7793045 100644 --- a/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt +++ b/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt @@ -170,6 +170,14 @@ abstract class BatchesFolder( return File(getInternalFolder(), getName(date, extension)) } + fun asMediaGetLegacyFile(name: String): File = File( + legacyMediaFolder, + name + ).apply { + createNewFile() + } + + fun checkIfOutputAlreadyExists( date: LocalDateTime, extension: String diff --git a/app/src/main/java/app/myzel394/alibi/helpers/VideoBatchesFolder.kt b/app/src/main/java/app/myzel394/alibi/helpers/VideoBatchesFolder.kt index 64c6f0b..2ed06d0 100644 --- a/app/src/main/java/app/myzel394/alibi/helpers/VideoBatchesFolder.kt +++ b/app/src/main/java/app/myzel394/alibi/helpers/VideoBatchesFolder.kt @@ -1,21 +1,20 @@ package app.myzel394.alibi.helpers +import android.content.ContentValues import android.content.Context import android.net.Uri import android.os.Build import android.os.Environment import android.os.ParcelFileDescriptor import android.provider.MediaStore -import androidx.core.net.toFile import androidx.documentfile.provider.DocumentFile import app.myzel394.alibi.helpers.MediaConverter.Companion.concatenateVideoFiles +import app.myzel394.alibi.ui.MEDIA_SUBFOLDER_NAME import app.myzel394.alibi.ui.RECORDER_INTERNAL_SELECTED_VALUE import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE import com.arthenica.ffmpegkit.FFmpegKitConfig import java.io.File -import java.nio.file.Paths import java.time.LocalDateTime -import kotlin.io.path.Path class VideoBatchesFolder( override val context: Context, @@ -28,6 +27,7 @@ class VideoBatchesFolder( customFolder, subfolderName, ) { + // TODO: Sort batches! override val concatenationFunction = ::concatenateVideoFiles override val ffmpegParameters = FFMPEG_PARAMETERS override val scopedMediaContentUri: Uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI @@ -59,7 +59,7 @@ class VideoBatchesFolder( val mediaUri = getOrCreateMediaFile( name = getName(date, extension), mimeType = "video/$extension", - relativePath = Environment.DIRECTORY_DCIM + MEDIA_SUBFOLDER, + relativePath = Environment.DIRECTORY_DCIM + "/" + MEDIA_SUBFOLDER_NAME, ) return FFmpegKitConfig.getSafParameterForWrite( @@ -67,12 +67,12 @@ class VideoBatchesFolder( mediaUri )!! } else { - return Paths.get( - MediaStore.Video.Media.EXTERNAL_CONTENT_URI.path, - Environment.DIRECTORY_DCIM, - MEDIA_SUBFOLDER, + val path = arrayOf( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM), + MEDIA_SUBFOLDER_NAME, getName(date, extension) - ).toFile() + ).joinToString("/") + return File(path) .apply { createNewFile() }.absolutePath @@ -109,6 +109,24 @@ class VideoBatchesFolder( } } + fun asMediaGetScopedStorageContentValues(name: String) = ContentValues().apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put( + MediaStore.Video.Media.IS_PENDING, + 1 + ) + put( + MediaStore.Video.Media.RELATIVE_PATH, + SCOPED_STORAGE_RELATIVE_PATH, + ) + } + + put( + MediaStore.Video.Media.DISPLAY_NAME, + name + ) + } + companion object { fun viaInternalFolder(context: Context) = VideoBatchesFolder(context, BatchType.INTERNAL) @@ -126,9 +144,9 @@ class VideoBatchesFolder( ) } - val MEDIA_SUBFOLDER = "/alibi" - val MEDIA_RECORDINGS_SUBFOLDER = MEDIA_SUBFOLDER + "/video_recordings" - val SCOPED_STORAGE_RELATIVE_PATH = Environment.DIRECTORY_DCIM + MEDIA_RECORDINGS_SUBFOLDER + val MEDIA_RECORDINGS_SUBFOLDER = MEDIA_SUBFOLDER_NAME + "/video_recordings" + val SCOPED_STORAGE_RELATIVE_PATH = + Environment.DIRECTORY_DCIM + "/" + MEDIA_RECORDINGS_SUBFOLDER // Parameters to be passed in descending order // Those parameters first try to concatenate without re-encoding diff --git a/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt index a203697..48b16c2 100644 --- a/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt @@ -16,6 +16,7 @@ import app.myzel394.alibi.db.RecordingInformation import app.myzel394.alibi.enums.RecorderState import app.myzel394.alibi.helpers.AudioBatchesFolder import app.myzel394.alibi.helpers.BatchesFolder +import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE import app.myzel394.alibi.ui.utils.MicrophoneInfo import java.lang.IllegalStateException @@ -160,6 +161,9 @@ class AudioRecorderService : } } + private fun getNameForMediaFile() = + "${batchesFolder.mediaPrefix}$counter.${settings.videoRecorderSettings.fileExtension}" + // ==== Actual recording related ==== private fun createRecorder(): MediaRecorder { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -196,8 +200,22 @@ class AudioRecorderService : ) } - // TODO: Add media - else -> {} + BatchesFolder.BatchType.MEDIA -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + setOutputFile( + batchesFolder.asMediaGetScopedStorageFileDescriptor( + getNameForMediaFile(), + "audio/${audioSettings.fileExtension}" + ) + ) + } else { + val name = getNameForMediaFile() + val file = batchesFolder.asMediaGetLegacyFile(name) + + // TODO: Ask permission on settings screen + setOutputFile(file.absolutePath) + } + } } setOutputFormat(audioSettings.getOutputFormat()) diff --git a/app/src/main/java/app/myzel394/alibi/services/VideoRecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/VideoRecorderService.kt index faaef91..0ca1659 100644 --- a/app/src/main/java/app/myzel394/alibi/services/VideoRecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/VideoRecorderService.kt @@ -1,11 +1,9 @@ package app.myzel394.alibi.services import android.annotation.SuppressLint -import android.content.ContentValues import android.content.Intent import android.content.pm.ServiceInfo import android.os.Build -import android.provider.MediaStore import android.util.Range import androidx.camera.core.Camera import androidx.camera.core.CameraSelector @@ -35,7 +33,6 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull -import java.io.File import kotlin.properties.Delegates class VideoRecorderService : @@ -246,45 +243,30 @@ class VideoRecorderService : ) } else if (batchesFolder.type == BatchesFolder.BatchType.MEDIA) { if (SUPPORTS_SCOPED_STORAGE) { + val name = getNameForMediaFile() + it.prepareRecording( this, - MediaStoreOutputOptions.Builder( - contentResolver, - batchesFolder.scopedMediaContentUri, - ).setContentValues( - ContentValues().apply { - val name = getNameForMediaFile() - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - put( - MediaStore.Video.Media.IS_PENDING, - 1 - ) - put( - MediaStore.Video.Media.RELATIVE_PATH, - VideoBatchesFolder.SCOPED_STORAGE_RELATIVE_PATH, - ) - } - - put( - MediaStore.Video.Media.DISPLAY_NAME, + MediaStoreOutputOptions + .Builder( + contentResolver, + batchesFolder.scopedMediaContentUri, + ) + .setContentValues( + batchesFolder.asMediaGetScopedStorageContentValues( name ) - } - ).build() + ) + .build() ) } else { val name = getNameForMediaFile() - val file = File( - batchesFolder.legacyMediaFolder, - name - ).apply { - createNewFile() - } it.prepareRecording( this, - FileOutputOptions.Builder(file).build() + FileOutputOptions + .Builder(batchesFolder.asMediaGetLegacyFile(name)) + .build() ) } } else { diff --git a/app/src/main/java/app/myzel394/alibi/ui/Constants.kt b/app/src/main/java/app/myzel394/alibi/ui/Constants.kt index b8c9816..462cba7 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/Constants.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/Constants.kt @@ -9,7 +9,8 @@ val BIG_PRIMARY_BUTTON_SIZE = 64.dp val SHEET_BOTTOM_OFFSET = 56.dp val MAX_AMPLITUDE = 20000 val SUPPORTS_DARK_MODE_NATIVELY = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -val RECORDER_SUBFOLDER_NAME = ".recordings" + +val MEDIA_SUBFOLDER_NAME = "alibi" // TODO: Fix! val SUPPORTS_SCOPED_STORAGE = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O diff --git a/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt b/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt index f4fdf10..582dc77 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt @@ -10,7 +10,9 @@ import app.myzel394.alibi.db.AppSettings import app.myzel394.alibi.db.RecordingInformation import app.myzel394.alibi.enums.RecorderState import app.myzel394.alibi.helpers.AudioBatchesFolder +import app.myzel394.alibi.helpers.VideoBatchesFolder import app.myzel394.alibi.services.AudioRecorderService +import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE import app.myzel394.alibi.ui.utils.MicrophoneInfo class AudioRecorderModel : @@ -63,16 +65,17 @@ class AudioRecorderModel : } override fun startRecording(context: Context, settings: AppSettings) { - batchesFolder = if (settings.saveFolder == null) - AudioBatchesFolder.viaInternalFolder(context) - else - AudioBatchesFolder.viaCustomFolder( + batchesFolder = when (settings.saveFolder) { + null -> AudioBatchesFolder.viaInternalFolder(context) + RECORDER_MEDIA_SELECTED_VALUE -> AudioBatchesFolder.viaMediaFolder(context) + else -> AudioBatchesFolder.viaCustomFolder( context, DocumentFile.fromTreeUri( context, Uri.parse(settings.saveFolder) )!! ) + } super.startRecording(context, settings) }