feat: Add support for media folders to video recording

This commit is contained in:
Myzel394 2023-12-30 20:39:33 +01:00
parent 99085b2176
commit 7401454269
No known key found for this signature in database
GPG Key ID: 79CC92F37B3E1A2B
7 changed files with 277 additions and 29 deletions

View File

@ -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 ""
}
}
}

View File

@ -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<Iterable<String>, String, String, CompletableDeferred<Unit>>
abstract val ffmpegParameters: Array<String>
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<String> {
return when (type) {
BatchType.INTERNAL ->
@ -64,6 +121,21 @@ abstract class BatchesFolder(
it.uri,
)!!
}
BatchType.MEDIA -> {
val filePaths = mutableListOf<String>()
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,
}
}

View File

@ -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))!!

View File

@ -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())

View File

@ -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,

View File

@ -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)

View File

@ -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)
}