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 e29c053..14ef3eb 100644 --- a/app/src/main/java/app/myzel394/alibi/helpers/AudioBatchesFolder.kt +++ b/app/src/main/java/app/myzel394/alibi/helpers/AudioBatchesFolder.kt @@ -4,9 +4,12 @@ import android.content.Context import android.net.Uri 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.helpers.VideoBatchesFolder.Companion.MEDIA_SUBFOLDER import com.arthenica.ffmpegkit.FFmpegKitConfig +import java.io.File import java.io.FileDescriptor import java.time.LocalDateTime @@ -23,7 +26,11 @@ class AudioBatchesFolder( ) { override val concatenationFunction = ::concatenateVideoFiles override val ffmpegParameters = FFMPEG_PARAMETERS - override val mediaContentUri: Uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + override val scopedMediaContentUri: Uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + override val legacyMediaFolder = File( + scopedMediaContentUri.toFile(), + MEDIA_SUBFOLDER + "/" + subfolderName + ) private var customFileFileDescriptor: ParcelFileDescriptor? = null 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 016d915..5e4bd1c 100644 --- a/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt +++ b/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt @@ -16,8 +16,11 @@ import java.time.LocalDateTime import java.time.format.DateTimeFormatter import com.arthenica.ffmpegkit.FFmpegKitConfig import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.net.toFile import app.myzel394.alibi.ui.RECORDER_INTERNAL_SELECTED_VALUE import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE +import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE import kotlinx.coroutines.CompletableDeferred import kotlin.reflect.KFunction3 @@ -29,7 +32,8 @@ abstract class BatchesFolder( ) { abstract val concatenationFunction: KFunction3, String, String, CompletableDeferred> abstract val ffmpegParameters: Array - abstract val mediaContentUri: Uri + abstract val scopedMediaContentUri: Uri + abstract val legacyMediaFolder: File val mediaPrefix get() = MEDIA_RECORDINGS_PREFIX + subfolderName.substring(1) + "-" @@ -45,7 +49,11 @@ abstract class BatchesFolder( } BatchType.MEDIA -> { - // Add support for < Android 10 + // Scoped storage works fine on new Android versions, + // we need to manually manage the folder on older versions + if (!SUPPORTS_SCOPED_STORAGE) { + legacyMediaFolder.mkdirs() + } } } } @@ -58,11 +66,12 @@ abstract class BatchesFolder( return customFolder!!.findFile(subfolderName)!! } + @RequiresApi(Build.VERSION_CODES.Q) protected fun queryMediaContent( callback: (rawName: String, counter: Int, uri: Uri, cursor: Cursor) -> Any?, ) { context.contentResolver.query( - mediaContentUri, + scopedMediaContentUri, null, null, null, @@ -89,7 +98,7 @@ abstract class BatchesFolder( continue } - val uri = Uri.withAppendedPath(mediaContentUri, id) + val uri = Uri.withAppendedPath(scopedMediaContentUri, id) val result = callback(rawName, counter, uri, cursor) @@ -127,13 +136,19 @@ abstract class BatchesFolder( BatchType.MEDIA -> { val filePaths = mutableListOf() - queryMediaContent { _, _, uri, _ -> - filePaths.add( - FFmpegKitConfig.getSafParameterForRead( - context, - uri, - )!! - ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + queryMediaContent { _, _, uri, _ -> + filePaths.add( + FFmpegKitConfig.getSafParameterForRead( + context, + uri, + )!! + ) + } + } else { + legacyMediaFolder.listFiles()?.forEach { + filePaths.add(it.absolutePath) + } } filePaths @@ -175,15 +190,22 @@ abstract class BatchesFolder( BatchType.MEDIA -> { var exists = false - queryMediaContent { rawName, _, _, _ -> - if (rawName == fileName) { - exists = true - return@queryMediaContent true - } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + queryMediaContent { rawName, _, _, _ -> + if (rawName == fileName) { + exists = true + return@queryMediaContent true + } else { + } } - } - exists + return exists + } else { + return File( + legacyMediaFolder, + fileName, + ).exists() + } } } } @@ -251,12 +273,16 @@ abstract class BatchesFolder( } BatchType.MEDIA -> { - queryMediaContent { _, _, uri, _ -> - context.contentResolver.delete( - uri, - null, - null, - ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + queryMediaContent { _, _, uri, _ -> + context.contentResolver.delete( + uri, + null, + null, + ) + } + } else { + legacyMediaFolder.deleteRecursively() } } } @@ -272,12 +298,16 @@ abstract class BatchesFolder( BatchType.MEDIA -> { var hasRecordings = false - queryMediaContent { _, _, _, _ -> - hasRecordings = true - return@queryMediaContent true - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + queryMediaContent { _, _, _, _ -> + hasRecordings = true + return@queryMediaContent true + } - hasRecordings + return hasRecordings + } else { + return legacyMediaFolder.listFiles()?.isNotEmpty() ?: false + } } } } @@ -301,13 +331,23 @@ abstract class BatchesFolder( } BatchType.MEDIA -> { - queryMediaContent { _, counter, uri, _ -> - if (counter < earliestCounter) { - context.contentResolver.delete( - uri, - null, - null, - ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + queryMediaContent { _, counter, uri, _ -> + if (counter < earliestCounter) { + context.contentResolver.delete( + uri, + null, + null, + ) + } + } + } else { + legacyMediaFolder.listFiles()?.forEach { + val fileCounter = it.nameWithoutExtension.toIntOrNull() ?: return@forEach + + if (fileCounter < earliestCounter) { + it.delete() + } } } } @@ -327,6 +367,7 @@ abstract class BatchesFolder( return File(getInternalFolder(), "$counter.$fileExtension") } + @RequiresApi(Build.VERSION_CODES.Q) fun getOrCreateMediaFile( name: String, mimeType: String, @@ -336,7 +377,7 @@ abstract class BatchesFolder( var uri: Uri? = null context.contentResolver.query( - mediaContentUri, + scopedMediaContentUri, arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DISPLAY_NAME), "${MediaStore.MediaColumns.DISPLAY_NAME} = '$name'", null, @@ -351,7 +392,7 @@ abstract class BatchesFolder( } uri = ContentUris.withAppendedId( - mediaContentUri, + scopedMediaContentUri, cursor.getLong(id) ) } @@ -360,7 +401,7 @@ abstract class BatchesFolder( if (uri == null) { // Create empty output file to be able to write to it uri = context.contentResolver.insert( - mediaContentUri, + scopedMediaContentUri, ContentValues().apply { put( MediaStore.MediaColumns.DISPLAY_NAME, 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 10311d3..dd33595 100644 --- a/app/src/main/java/app/myzel394/alibi/helpers/VideoBatchesFolder.kt +++ b/app/src/main/java/app/myzel394/alibi/helpers/VideoBatchesFolder.kt @@ -2,6 +2,7 @@ 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 @@ -10,6 +11,7 @@ import app.myzel394.alibi.helpers.MediaConverter.Companion.concatenateVideoFiles 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.time.LocalDateTime class VideoBatchesFolder( @@ -25,7 +27,11 @@ class VideoBatchesFolder( ) { override val concatenationFunction = ::concatenateVideoFiles override val ffmpegParameters = FFMPEG_PARAMETERS - override val mediaContentUri: Uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + override val scopedMediaContentUri: Uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + override val legacyMediaFolder = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM), + MEDIA_SUBFOLDER + ) private var customParcelFileDescriptor: ParcelFileDescriptor? = null @@ -46,16 +52,25 @@ class VideoBatchesFolder( } BatchType.MEDIA -> { - val mediaUri = getOrCreateMediaFile( - name = getName(date, extension), - mimeType = "video/$extension", - relativePath = MEDIA_RELATIVE_PATH, - ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val mediaUri = getOrCreateMediaFile( + name = getName(date, extension), + mimeType = "video/$extension", + relativePath = SCOPED_STORAGE_RELATIVE_PATH, + ) - FFmpegKitConfig.getSafParameterForWrite( - context, - mediaUri - )!! + return FFmpegKitConfig.getSafParameterForWrite( + context, + mediaUri + )!! + } else { + return File( + legacyMediaFolder.parentFile!!, + getName(date, extension) + ).apply { + createNewFile() + }.absolutePath + } } } } @@ -105,7 +120,8 @@ class VideoBatchesFolder( ) } - val MEDIA_RELATIVE_PATH = Environment.DIRECTORY_DCIM + "/alibi/video_recordings" + val MEDIA_SUBFOLDER = "/alibi/.video_recordings" + val SCOPED_STORAGE_RELATIVE_PATH = Environment.DIRECTORY_DCIM + MEDIA_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/VideoRecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/VideoRecorderService.kt index d342b8c..faaef91 100644 --- a/app/src/main/java/app/myzel394/alibi/services/VideoRecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/VideoRecorderService.kt @@ -5,7 +5,6 @@ import android.content.ContentValues import android.content.Intent import android.content.pm.ServiceInfo import android.os.Build -import android.os.Environment import android.provider.MediaStore import android.util.Range import androidx.camera.core.Camera @@ -28,7 +27,7 @@ import app.myzel394.alibi.db.RecordingInformation import app.myzel394.alibi.enums.RecorderState import app.myzel394.alibi.helpers.BatchesFolder import app.myzel394.alibi.helpers.VideoBatchesFolder -import app.myzel394.alibi.ui.VIDEO_RECORDER_SUPPORTS_CUSTOM_FOLDER +import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -36,6 +35,7 @@ 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 : @@ -227,11 +227,14 @@ class VideoRecorderService : } } + private fun getNameForMediaFile() = + "${batchesFolder.mediaPrefix}$counter.${settings.videoRecorderSettings.fileExtension}" + @SuppressLint("MissingPermission", "NewApi") private fun prepareVideoRecording() = videoCapture!!.output .let { - if (batchesFolder.type == BatchesFolder.BatchType.CUSTOM && VIDEO_RECORDER_SUPPORTS_CUSTOM_FOLDER) { + if (batchesFolder.type == BatchesFolder.BatchType.CUSTOM && SUPPORTS_SCOPED_STORAGE) { it.prepareRecording( this, FileDescriptorOutputOptions.Builder( @@ -242,34 +245,48 @@ class VideoRecorderService : ).build() ) } else if (batchesFolder.type == BatchesFolder.BatchType.MEDIA) { - it.prepareRecording( - this, - MediaStoreOutputOptions.Builder( - contentResolver, - batchesFolder.mediaContentUri, - ).setContentValues( - ContentValues().apply { - val name = - "${batchesFolder.mediaPrefix}$counter.${settings.videoRecorderSettings.fileExtension}" + if (SUPPORTS_SCOPED_STORAGE) { + 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, + ) + } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { put( - MediaStore.Video.Media.IS_PENDING, - 1 - ) - put( - MediaStore.Video.Media.RELATIVE_PATH, - VideoBatchesFolder.MEDIA_RELATIVE_PATH, + MediaStore.Video.Media.DISPLAY_NAME, + name ) } + ).build() + ) + } else { + val name = getNameForMediaFile() + val file = File( + batchesFolder.legacyMediaFolder, + name + ).apply { + createNewFile() + } - put( - MediaStore.Video.Media.DISPLAY_NAME, - name - ) - } - ).build() - ) + it.prepareRecording( + this, + FileOutputOptions.Builder(file).build() + ) + } } else { it.prepareRecording( this, 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 9c62b55..c294b56 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/Constants.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/Constants.kt @@ -2,13 +2,14 @@ package app.myzel394.alibi.ui import android.os.Build import androidx.compose.ui.unit.dp -import java.io.File val BIG_PRIMARY_BUTTON_SIZE = 64.dp val MAX_AMPLITUDE = 20000 val SUPPORTS_DARK_MODE_NATIVELY = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q val RECORDER_SUBFOLDER_NAME = ".recordings" -val VIDEO_RECORDER_SUPPORTS_CUSTOM_FOLDER = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + +// TODO: Fix! +val SUPPORTS_SCOPED_STORAGE = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O val MEDIA_RECORDINGS_PREFIX = "alibi-recording-" val RECORDER_MEDIA_SELECTED_VALUE = "_'media" val RECORDER_INTERNAL_SELECTED_VALUE = "_'internal" diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/Tiles/SaveFolderTile.kt b/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/Tiles/SaveFolderTile.kt index 2adef3d..765bb50 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/Tiles/SaveFolderTile.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/Tiles/SaveFolderTile.kt @@ -35,12 +35,11 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.documentfile.provider.DocumentFile import app.myzel394.alibi.R import app.myzel394.alibi.dataStore import app.myzel394.alibi.db.AppSettings import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE -import app.myzel394.alibi.ui.VIDEO_RECORDER_SUPPORTS_CUSTOM_FOLDER +import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE import app.myzel394.alibi.ui.components.atoms.SettingsTile import app.myzel394.alibi.ui.utils.rememberFolderSelectorDialog import kotlinx.coroutines.launch @@ -219,7 +218,7 @@ fun SaveFolderTile( modifier = Modifier.fillMaxWidth(), ) } - if (!VIDEO_RECORDER_SUPPORTS_CUSTOM_FOLDER) { + if (!SUPPORTS_SCOPED_STORAGE) { Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically,