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 b814c0f..e29c053 100644 --- a/app/src/main/java/app/myzel394/alibi/helpers/AudioBatchesFolder.kt +++ b/app/src/main/java/app/myzel394/alibi/helpers/AudioBatchesFolder.kt @@ -3,8 +3,8 @@ package app.myzel394.alibi.helpers import android.content.Context import android.net.Uri import android.os.ParcelFileDescriptor +import android.provider.MediaStore import androidx.documentfile.provider.DocumentFile -import app.myzel394.alibi.helpers.MediaConverter.Companion.concatenateAudioFiles import app.myzel394.alibi.helpers.MediaConverter.Companion.concatenateVideoFiles import com.arthenica.ffmpegkit.FFmpegKitConfig import java.io.FileDescriptor @@ -23,6 +23,7 @@ class AudioBatchesFolder( ) { override val concatenationFunction = ::concatenateVideoFiles override val ffmpegParameters = FFMPEG_PARAMETERS + override val mediaContentUri: Uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI private var customFileFileDescriptor: ParcelFileDescriptor? = null @@ -32,6 +33,7 @@ class AudioBatchesFolder( ): String { return when (type) { BatchType.INTERNAL -> asInternalGetOutputFile(date, extension).absolutePath + BatchType.CUSTOM -> { val name = getName(date, extension) @@ -43,6 +45,10 @@ class AudioBatchesFolder( )!!).uri )!! } + + BatchType.MEDIA -> { + return "" + } } } 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 892e40a..f7257da 100644 --- a/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt +++ b/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt @@ -1,16 +1,18 @@ package app.myzel394.alibi.helpers +import app.myzel394.alibi.ui.MEDIA_RECORDINGS_PREFIX + import android.content.Context +import android.database.Cursor import android.net.Uri +import android.provider.MediaStore.Video.Media import androidx.documentfile.provider.DocumentFile import java.io.File import java.time.LocalDateTime import java.time.format.DateTimeFormatter import com.arthenica.ffmpegkit.FFmpegKitConfig -import android.os.ParcelFileDescriptor import android.util.Log import kotlinx.coroutines.CompletableDeferred -import java.io.FileDescriptor import kotlin.reflect.KFunction3 abstract class BatchesFolder( @@ -21,15 +23,24 @@ abstract class BatchesFolder( ) { abstract val concatenationFunction: KFunction3, String, String, CompletableDeferred> abstract val ffmpegParameters: Array + abstract val mediaContentUri: Uri + + val mediaPrefix + get() = MEDIA_RECORDINGS_PREFIX + subfolderName fun initFolders() { when (type) { BatchType.INTERNAL -> getInternalFolder().mkdirs() + BatchType.CUSTOM -> { if (customFolder!!.findFile(subfolderName) == null) { customFolder!!.createDirectory(subfolderName) } } + + BatchType.MEDIA -> { + // Add support for < Android 10 + } } } @@ -41,6 +52,52 @@ abstract class BatchesFolder( return customFolder!!.findFile(subfolderName)!! } + protected fun queryMediaContent( + callback: (rawName: String, counter: Int, uri: Uri, cursor: Cursor) -> Any?, + ) { + context.contentResolver.query( + mediaContentUri, + null, + null, + null, + null, + )!!.use { cursor -> + while (cursor.moveToNext()) { + val rawName = cursor.getColumnIndex(Media.DISPLAY_NAME).let { id -> + if (id == -1) "" else cursor.getString(id) + } + + if (rawName == "" || rawName == null) { + continue + } + + if (!rawName.startsWith(mediaPrefix)) { + continue + } + + val counter = + rawName.substringAfter(mediaPrefix).substringBeforeLast(".").toIntOrNull() + ?: continue + + val id = cursor.getColumnIndex(Media._ID).let { id -> + if (id == -1) "" else cursor.getString(id) + } + + if (id == "" || id == null) { + continue + } + + val uri = Uri.withAppendedPath(mediaContentUri, id) + + val result = callback(rawName, counter, uri, cursor) + + if (result != null) { + return + } + } + } + } + fun getBatchesForFFmpeg(): List { return when (type) { BatchType.INTERNAL -> @@ -64,6 +121,21 @@ abstract class BatchesFolder( it.uri, )!! } + + BatchType.MEDIA -> { + val filePaths = mutableListOf() + + queryMediaContent { _, _, uri, _ -> + filePaths.add( + FFmpegKitConfig.getSafParameterForRead( + context, + uri, + )!! + ) + } + + filePaths + } } } @@ -81,27 +153,36 @@ abstract class BatchesFolder( return File(getInternalFolder(), getName(date, extension)) } - fun asCustomGetOutputFile( - date: LocalDateTime, - extension: String, - ): DocumentFile { - return getCustomDefinedFolder().createFile("audio/$extension", getName(date, extension))!! - } - fun checkIfOutputAlreadyExists( date: LocalDateTime, extension: String ): Boolean { - val name = date + val stem = date .format(DateTimeFormatter.ISO_DATE_TIME) .toString() .replace(":", "-") .replace(".", "_") + val fileName = "$stem.$extension" return when (type) { - BatchType.INTERNAL -> File(getInternalFolder(), "$name.$extension").exists() + BatchType.INTERNAL -> File(getInternalFolder(), fileName).exists() + BatchType.CUSTOM -> - getCustomDefinedFolder().findFile("${name}.${extension}")?.exists() ?: false + getCustomDefinedFolder().findFile(fileName)?.exists() ?: false + + BatchType.MEDIA -> { + var exists = false + + queryMediaContent { rawName, _, _, _ -> + if (rawName == fileName) { + exists = true + return@queryMediaContent true + } else { + } + } + + exists + } } } @@ -154,24 +235,48 @@ abstract class BatchesFolder( return when (type) { BatchType.INTERNAL -> "_'internal" BatchType.CUSTOM -> customFolder!!.uri.toString() + BatchType.MEDIA -> "_'media" } } fun deleteRecordings() { when (type) { BatchType.INTERNAL -> getInternalFolder().deleteRecursively() + BatchType.CUSTOM -> customFolder?.findFile(subfolderName)?.delete() ?: customFolder?.findFile(subfolderName)?.listFiles()?.forEach { it.delete() } + + BatchType.MEDIA -> { + queryMediaContent { _, _, uri, _ -> + context.contentResolver.delete( + uri, + null, + null, + ) + } + } } } fun hasRecordingsAvailable(): Boolean { return when (type) { BatchType.INTERNAL -> getInternalFolder().listFiles()?.isNotEmpty() ?: false + BatchType.CUSTOM -> customFolder?.findFile(subfolderName)?.listFiles()?.isNotEmpty() ?: false + + BatchType.MEDIA -> { + var hasRecordings = false + + queryMediaContent { _, _, _, _ -> + hasRecordings = true + return@queryMediaContent true + } + + hasRecordings + } } } @@ -192,6 +297,18 @@ abstract class BatchesFolder( it.delete() } } + + BatchType.MEDIA -> { + queryMediaContent { _, counter, uri, _ -> + if (counter < earliestCounter) { + context.contentResolver.delete( + uri, + null, + null, + ) + } + } + } } } @@ -199,6 +316,8 @@ abstract class BatchesFolder( return when (type) { BatchType.INTERNAL -> true BatchType.CUSTOM -> getCustomDefinedFolder().canWrite() && getCustomDefinedFolder().canRead() + // Add support for < Android 10 + BatchType.MEDIA -> true } } @@ -209,6 +328,7 @@ abstract class BatchesFolder( enum class BatchType { INTERNAL, CUSTOM, + MEDIA, } } 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 8c69369..5fed2cd 100644 --- a/app/src/main/java/app/myzel394/alibi/helpers/VideoBatchesFolder.kt +++ b/app/src/main/java/app/myzel394/alibi/helpers/VideoBatchesFolder.kt @@ -1,17 +1,20 @@ package app.myzel394.alibi.helpers +import android.content.ContentUris import android.content.Context import android.net.Uri +import android.os.Environment import android.os.ParcelFileDescriptor +import android.provider.MediaStore import androidx.documentfile.provider.DocumentFile import app.myzel394.alibi.helpers.MediaConverter.Companion.concatenateVideoFiles +import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE import com.arthenica.ffmpegkit.FFmpegKitConfig -import com.arthenica.ffmpegkit.ReturnCode import java.time.LocalDateTime class VideoBatchesFolder( override val context: Context, - override val type: BatchesFolder.BatchType, + override val type: BatchType, override val customFolder: DocumentFile? = null, override val subfolderName: String = ".video_recordings", ) : BatchesFolder( @@ -22,12 +25,14 @@ class VideoBatchesFolder( ) { override val concatenationFunction = ::concatenateVideoFiles override val ffmpegParameters = FFMPEG_PARAMETERS + override val mediaContentUri: Uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI private var customParcelFileDescriptor: ParcelFileDescriptor? = null override fun getOutputFileForFFmpeg(date: LocalDateTime, extension: String): String { return when (type) { BatchType.INTERNAL -> asInternalGetOutputFile(date, extension).absolutePath + BatchType.CUSTOM -> { val name = getName(date, extension) @@ -39,6 +44,72 @@ class VideoBatchesFolder( )!!).uri )!! } + + BatchType.MEDIA -> { + val name = getName(date, extension) + + // Check if already exists + var uri: Uri? = null + context.contentResolver.query( + mediaContentUri, + null, + // TODO: Improve + null, + null, + null, + )!!.use { cursor -> + while (cursor.moveToNext()) { + val id = cursor.getColumnIndex(MediaStore.MediaColumns._ID) + + if (id == -1) { + continue + } + + val nameID = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME) + + if (nameID == -1) { + continue + } + + val cursorName = cursor.getString(nameID) + + if (cursorName != name) { + continue + } + + uri = ContentUris.withAppendedId( + mediaContentUri, + cursor.getLong(id) + ) + return@use + } + } + + if (uri == null) { + uri = context.contentResolver.insert( + mediaContentUri, + android.content.ContentValues().apply { + put( + MediaStore.MediaColumns.DISPLAY_NAME, + name + ) + put( + MediaStore.MediaColumns.MIME_TYPE, + "video/$extension" + ) + put( + MediaStore.Video.Media.RELATIVE_PATH, + Environment.DIRECTORY_DCIM + "/alibi/video_recordings" + ) + } + )!! + } + + FFmpegKitConfig.getSafParameterForWrite( + context, + uri + )!! + } } } @@ -76,8 +147,11 @@ class VideoBatchesFolder( fun viaCustomFolder(context: Context, folder: DocumentFile) = VideoBatchesFolder(context, BatchType.CUSTOM, folder) + fun viaMediaFolder(context: Context) = VideoBatchesFolder(context, BatchType.MEDIA) + fun importFromFolder(folder: String, context: Context) = when (folder) { "_'internal" -> viaInternalFolder(context) + RECORDER_MEDIA_SELECTED_VALUE -> viaMediaFolder(context) else -> viaCustomFolder( context, DocumentFile.fromTreeUri(context, Uri.parse(folder))!! 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 50fec5b..a203697 100644 --- a/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/AudioRecorderService.kt @@ -1,7 +1,6 @@ package app.myzel394.alibi.services import android.content.Context -import android.content.Context.AUDIO_SERVICE import android.content.pm.ServiceInfo import android.media.AudioDeviceCallback import android.media.AudioDeviceInfo @@ -12,9 +11,7 @@ import android.os.Build import android.os.Handler import android.os.Looper import androidx.core.app.ServiceCompat -import androidx.core.content.ContextCompat.getSystemService import app.myzel394.alibi.NotificationHelper -import app.myzel394.alibi.db.AppSettings import app.myzel394.alibi.db.RecordingInformation import app.myzel394.alibi.enums.RecorderState import app.myzel394.alibi.helpers.AudioBatchesFolder @@ -198,6 +195,9 @@ class AudioRecorderService : ) ) } + + // TODO: Add media + else -> {} } 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 d071287..ac2d4ff 100644 --- a/app/src/main/java/app/myzel394/alibi/services/VideoRecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/VideoRecorderService.kt @@ -1,20 +1,20 @@ 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.os.ParcelFileDescriptor +import android.os.Environment +import android.provider.MediaStore import android.util.Range import androidx.camera.core.Camera import androidx.camera.core.CameraSelector import androidx.camera.core.TorchState -import androidx.camera.core.processing.SurfaceProcessorNode.Out import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.video.FileDescriptorOutputOptions import androidx.camera.video.FileOutputOptions import androidx.camera.video.MediaStoreOutputOptions -import androidx.camera.video.OutputOptions import androidx.camera.video.Quality import androidx.camera.video.QualitySelector import androidx.camera.video.Recorder @@ -36,7 +36,6 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull -import java.nio.file.Files.createFile import kotlin.properties.Delegates class VideoRecorderService : @@ -232,7 +231,6 @@ class VideoRecorderService : private fun prepareVideoRecording() = videoCapture!!.output .let { - // TODO: Add hint if (batchesFolder.type == BatchesFolder.BatchType.CUSTOM && VIDEO_RECORDER_SUPPORTS_CUSTOM_FOLDER) { it.prepareRecording( this, @@ -243,6 +241,39 @@ 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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put( + MediaStore.Video.Media.IS_PENDING, + 1 + ) + put( + MediaStore.Video.Media.RELATIVE_PATH, + Environment.DIRECTORY_DCIM + "/alibi/video_recordings" + ) + put( + MediaStore.Video.Media.DISPLAY_NAME, + name + ) + } else { + put( + MediaStore.Video.Media.DISPLAY_NAME, + name + ) + } + } + ).build() + ) } else { it.prepareRecording( this, diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/RecorderEventsHandler.kt b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/RecorderEventsHandler.kt index 186ec30..20dd06c 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/RecorderEventsHandler.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/RecorderEventsHandler.kt @@ -104,6 +104,15 @@ fun RecorderEventsHandler( context.startActivity(intent) } + fun showSnackbar() { + scope.launch { + snackbarHostState.showSnackbar( + message = successMessage, + duration = SnackbarDuration.Short, + ) + } + } + fun showSnackbar(uri: Uri) { scope.launch { val result = snackbarHostState.showSnackbar( @@ -186,6 +195,14 @@ fun RecorderEventsHandler( batchesFolder.deleteRecordings() } } + + BatchesFolder.BatchType.MEDIA -> { + showSnackbar() + + if (settings.deleteRecordingsImmediately) { + batchesFolder.deleteRecordings() + } + } } } catch (error: Exception) { Log.getStackTraceString(error) diff --git a/app/src/main/java/app/myzel394/alibi/ui/models/VideoRecorderModel.kt b/app/src/main/java/app/myzel394/alibi/ui/models/VideoRecorderModel.kt index 4550674..be787f8 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/models/VideoRecorderModel.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/models/VideoRecorderModel.kt @@ -13,10 +13,9 @@ import androidx.documentfile.provider.DocumentFile 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.BatchesFolder import app.myzel394.alibi.helpers.VideoBatchesFolder import app.myzel394.alibi.services.VideoRecorderService +import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE import app.myzel394.alibi.ui.utils.CameraInfo import app.myzel394.alibi.ui.utils.PermissionHelper @@ -43,16 +42,17 @@ class VideoRecorderModel : } override fun startRecording(context: Context, settings: AppSettings) { - batchesFolder = if (settings.saveFolder == null) - VideoBatchesFolder.viaInternalFolder(context) - else - VideoBatchesFolder.viaCustomFolder( + batchesFolder = when (settings.saveFolder) { + null -> VideoBatchesFolder.viaInternalFolder(context) + RECORDER_MEDIA_SELECTED_VALUE -> VideoBatchesFolder.viaMediaFolder(context) + else -> VideoBatchesFolder.viaCustomFolder( context, DocumentFile.fromTreeUri( context, Uri.parse(settings.saveFolder) )!! ) + } super.startRecording(context, settings) }