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.content.Context
import android.net.Uri import android.net.Uri
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import app.myzel394.alibi.helpers.MediaConverter.Companion.concatenateAudioFiles
import app.myzel394.alibi.helpers.MediaConverter.Companion.concatenateVideoFiles import app.myzel394.alibi.helpers.MediaConverter.Companion.concatenateVideoFiles
import com.arthenica.ffmpegkit.FFmpegKitConfig import com.arthenica.ffmpegkit.FFmpegKitConfig
import java.io.FileDescriptor import java.io.FileDescriptor
@ -23,6 +23,7 @@ class AudioBatchesFolder(
) { ) {
override val concatenationFunction = ::concatenateVideoFiles override val concatenationFunction = ::concatenateVideoFiles
override val ffmpegParameters = FFMPEG_PARAMETERS override val ffmpegParameters = FFMPEG_PARAMETERS
override val mediaContentUri: Uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
private var customFileFileDescriptor: ParcelFileDescriptor? = null private var customFileFileDescriptor: ParcelFileDescriptor? = null
@ -32,6 +33,7 @@ class AudioBatchesFolder(
): String { ): String {
return when (type) { return when (type) {
BatchType.INTERNAL -> asInternalGetOutputFile(date, extension).absolutePath BatchType.INTERNAL -> asInternalGetOutputFile(date, extension).absolutePath
BatchType.CUSTOM -> { BatchType.CUSTOM -> {
val name = getName(date, extension) val name = getName(date, extension)
@ -43,6 +45,10 @@ class AudioBatchesFolder(
)!!).uri )!!).uri
)!! )!!
} }
BatchType.MEDIA -> {
return ""
}
} }
} }

View File

@ -1,16 +1,18 @@
package app.myzel394.alibi.helpers package app.myzel394.alibi.helpers
import app.myzel394.alibi.ui.MEDIA_RECORDINGS_PREFIX
import android.content.Context import android.content.Context
import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.provider.MediaStore.Video.Media
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import java.io.File import java.io.File
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import com.arthenica.ffmpegkit.FFmpegKitConfig import com.arthenica.ffmpegkit.FFmpegKitConfig
import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import java.io.FileDescriptor
import kotlin.reflect.KFunction3 import kotlin.reflect.KFunction3
abstract class BatchesFolder( abstract class BatchesFolder(
@ -21,15 +23,24 @@ abstract class BatchesFolder(
) { ) {
abstract val concatenationFunction: KFunction3<Iterable<String>, String, String, CompletableDeferred<Unit>> abstract val concatenationFunction: KFunction3<Iterable<String>, String, String, CompletableDeferred<Unit>>
abstract val ffmpegParameters: Array<String> abstract val ffmpegParameters: Array<String>
abstract val mediaContentUri: Uri
val mediaPrefix
get() = MEDIA_RECORDINGS_PREFIX + subfolderName
fun initFolders() { fun initFolders() {
when (type) { when (type) {
BatchType.INTERNAL -> getInternalFolder().mkdirs() BatchType.INTERNAL -> getInternalFolder().mkdirs()
BatchType.CUSTOM -> { BatchType.CUSTOM -> {
if (customFolder!!.findFile(subfolderName) == null) { if (customFolder!!.findFile(subfolderName) == null) {
customFolder!!.createDirectory(subfolderName) customFolder!!.createDirectory(subfolderName)
} }
} }
BatchType.MEDIA -> {
// Add support for < Android 10
}
} }
} }
@ -41,6 +52,52 @@ abstract class BatchesFolder(
return customFolder!!.findFile(subfolderName)!! 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> { fun getBatchesForFFmpeg(): List<String> {
return when (type) { return when (type) {
BatchType.INTERNAL -> BatchType.INTERNAL ->
@ -64,6 +121,21 @@ abstract class BatchesFolder(
it.uri, 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)) return File(getInternalFolder(), getName(date, extension))
} }
fun asCustomGetOutputFile(
date: LocalDateTime,
extension: String,
): DocumentFile {
return getCustomDefinedFolder().createFile("audio/$extension", getName(date, extension))!!
}
fun checkIfOutputAlreadyExists( fun checkIfOutputAlreadyExists(
date: LocalDateTime, date: LocalDateTime,
extension: String extension: String
): Boolean { ): Boolean {
val name = date val stem = date
.format(DateTimeFormatter.ISO_DATE_TIME) .format(DateTimeFormatter.ISO_DATE_TIME)
.toString() .toString()
.replace(":", "-") .replace(":", "-")
.replace(".", "_") .replace(".", "_")
val fileName = "$stem.$extension"
return when (type) { return when (type) {
BatchType.INTERNAL -> File(getInternalFolder(), "$name.$extension").exists() BatchType.INTERNAL -> File(getInternalFolder(), fileName).exists()
BatchType.CUSTOM -> 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) { return when (type) {
BatchType.INTERNAL -> "_'internal" BatchType.INTERNAL -> "_'internal"
BatchType.CUSTOM -> customFolder!!.uri.toString() BatchType.CUSTOM -> customFolder!!.uri.toString()
BatchType.MEDIA -> "_'media"
} }
} }
fun deleteRecordings() { fun deleteRecordings() {
when (type) { when (type) {
BatchType.INTERNAL -> getInternalFolder().deleteRecursively() BatchType.INTERNAL -> getInternalFolder().deleteRecursively()
BatchType.CUSTOM -> customFolder?.findFile(subfolderName)?.delete() BatchType.CUSTOM -> customFolder?.findFile(subfolderName)?.delete()
?: customFolder?.findFile(subfolderName)?.listFiles()?.forEach { ?: customFolder?.findFile(subfolderName)?.listFiles()?.forEach {
it.delete() it.delete()
} }
BatchType.MEDIA -> {
queryMediaContent { _, _, uri, _ ->
context.contentResolver.delete(
uri,
null,
null,
)
}
}
} }
} }
fun hasRecordingsAvailable(): Boolean { fun hasRecordingsAvailable(): Boolean {
return when (type) { return when (type) {
BatchType.INTERNAL -> getInternalFolder().listFiles()?.isNotEmpty() ?: false BatchType.INTERNAL -> getInternalFolder().listFiles()?.isNotEmpty() ?: false
BatchType.CUSTOM -> customFolder?.findFile(subfolderName)?.listFiles()?.isNotEmpty() BatchType.CUSTOM -> customFolder?.findFile(subfolderName)?.listFiles()?.isNotEmpty()
?: false ?: false
BatchType.MEDIA -> {
var hasRecordings = false
queryMediaContent { _, _, _, _ ->
hasRecordings = true
return@queryMediaContent true
}
hasRecordings
}
} }
} }
@ -192,6 +297,18 @@ abstract class BatchesFolder(
it.delete() 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) { return when (type) {
BatchType.INTERNAL -> true BatchType.INTERNAL -> true
BatchType.CUSTOM -> getCustomDefinedFolder().canWrite() && getCustomDefinedFolder().canRead() BatchType.CUSTOM -> getCustomDefinedFolder().canWrite() && getCustomDefinedFolder().canRead()
// Add support for < Android 10
BatchType.MEDIA -> true
} }
} }
@ -209,6 +328,7 @@ abstract class BatchesFolder(
enum class BatchType { enum class BatchType {
INTERNAL, INTERNAL,
CUSTOM, CUSTOM,
MEDIA,
} }
} }

View File

@ -1,17 +1,20 @@
package app.myzel394.alibi.helpers package app.myzel394.alibi.helpers
import android.content.ContentUris
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Environment
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import app.myzel394.alibi.helpers.MediaConverter.Companion.concatenateVideoFiles 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.FFmpegKitConfig
import com.arthenica.ffmpegkit.ReturnCode
import java.time.LocalDateTime import java.time.LocalDateTime
class VideoBatchesFolder( class VideoBatchesFolder(
override val context: Context, override val context: Context,
override val type: BatchesFolder.BatchType, override val type: BatchType,
override val customFolder: DocumentFile? = null, override val customFolder: DocumentFile? = null,
override val subfolderName: String = ".video_recordings", override val subfolderName: String = ".video_recordings",
) : BatchesFolder( ) : BatchesFolder(
@ -22,12 +25,14 @@ class VideoBatchesFolder(
) { ) {
override val concatenationFunction = ::concatenateVideoFiles override val concatenationFunction = ::concatenateVideoFiles
override val ffmpegParameters = FFMPEG_PARAMETERS override val ffmpegParameters = FFMPEG_PARAMETERS
override val mediaContentUri: Uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
private var customParcelFileDescriptor: ParcelFileDescriptor? = null private var customParcelFileDescriptor: ParcelFileDescriptor? = null
override fun getOutputFileForFFmpeg(date: LocalDateTime, extension: String): String { override fun getOutputFileForFFmpeg(date: LocalDateTime, extension: String): String {
return when (type) { return when (type) {
BatchType.INTERNAL -> asInternalGetOutputFile(date, extension).absolutePath BatchType.INTERNAL -> asInternalGetOutputFile(date, extension).absolutePath
BatchType.CUSTOM -> { BatchType.CUSTOM -> {
val name = getName(date, extension) val name = getName(date, extension)
@ -39,6 +44,72 @@ class VideoBatchesFolder(
)!!).uri )!!).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) = fun viaCustomFolder(context: Context, folder: DocumentFile) =
VideoBatchesFolder(context, BatchType.CUSTOM, folder) VideoBatchesFolder(context, BatchType.CUSTOM, folder)
fun viaMediaFolder(context: Context) = VideoBatchesFolder(context, BatchType.MEDIA)
fun importFromFolder(folder: String, context: Context) = when (folder) { fun importFromFolder(folder: String, context: Context) = when (folder) {
"_'internal" -> viaInternalFolder(context) "_'internal" -> viaInternalFolder(context)
RECORDER_MEDIA_SELECTED_VALUE -> viaMediaFolder(context)
else -> viaCustomFolder( else -> viaCustomFolder(
context, context,
DocumentFile.fromTreeUri(context, Uri.parse(folder))!! DocumentFile.fromTreeUri(context, Uri.parse(folder))!!

View File

@ -1,7 +1,6 @@
package app.myzel394.alibi.services package app.myzel394.alibi.services
import android.content.Context import android.content.Context
import android.content.Context.AUDIO_SERVICE
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.media.AudioDeviceCallback import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo import android.media.AudioDeviceInfo
@ -12,9 +11,7 @@ import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat.getSystemService
import app.myzel394.alibi.NotificationHelper import app.myzel394.alibi.NotificationHelper
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.db.RecordingInformation import app.myzel394.alibi.db.RecordingInformation
import app.myzel394.alibi.enums.RecorderState import app.myzel394.alibi.enums.RecorderState
import app.myzel394.alibi.helpers.AudioBatchesFolder import app.myzel394.alibi.helpers.AudioBatchesFolder
@ -198,6 +195,9 @@ class AudioRecorderService :
) )
) )
} }
// TODO: Add media
else -> {}
} }
setOutputFormat(audioSettings.getOutputFormat()) setOutputFormat(audioSettings.getOutputFormat())

View File

@ -1,20 +1,20 @@
package app.myzel394.alibi.services package app.myzel394.alibi.services
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ContentValues
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import android.os.ParcelFileDescriptor import android.os.Environment
import android.provider.MediaStore
import android.util.Range import android.util.Range
import androidx.camera.core.Camera import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.TorchState import androidx.camera.core.TorchState
import androidx.camera.core.processing.SurfaceProcessorNode.Out
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.FileDescriptorOutputOptions import androidx.camera.video.FileDescriptorOutputOptions
import androidx.camera.video.FileOutputOptions import androidx.camera.video.FileOutputOptions
import androidx.camera.video.MediaStoreOutputOptions import androidx.camera.video.MediaStoreOutputOptions
import androidx.camera.video.OutputOptions
import androidx.camera.video.Quality import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector import androidx.camera.video.QualitySelector
import androidx.camera.video.Recorder import androidx.camera.video.Recorder
@ -36,7 +36,6 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import java.nio.file.Files.createFile
import kotlin.properties.Delegates import kotlin.properties.Delegates
class VideoRecorderService : class VideoRecorderService :
@ -232,7 +231,6 @@ class VideoRecorderService :
private fun prepareVideoRecording() = private fun prepareVideoRecording() =
videoCapture!!.output videoCapture!!.output
.let { .let {
// TODO: Add hint
if (batchesFolder.type == BatchesFolder.BatchType.CUSTOM && VIDEO_RECORDER_SUPPORTS_CUSTOM_FOLDER) { if (batchesFolder.type == BatchesFolder.BatchType.CUSTOM && VIDEO_RECORDER_SUPPORTS_CUSTOM_FOLDER) {
it.prepareRecording( it.prepareRecording(
this, this,
@ -243,6 +241,39 @@ class VideoRecorderService :
) )
).build() ).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 { } else {
it.prepareRecording( it.prepareRecording(
this, this,

View File

@ -104,6 +104,15 @@ fun RecorderEventsHandler(
context.startActivity(intent) context.startActivity(intent)
} }
fun showSnackbar() {
scope.launch {
snackbarHostState.showSnackbar(
message = successMessage,
duration = SnackbarDuration.Short,
)
}
}
fun showSnackbar(uri: Uri) { fun showSnackbar(uri: Uri) {
scope.launch { scope.launch {
val result = snackbarHostState.showSnackbar( val result = snackbarHostState.showSnackbar(
@ -186,6 +195,14 @@ fun RecorderEventsHandler(
batchesFolder.deleteRecordings() batchesFolder.deleteRecordings()
} }
} }
BatchesFolder.BatchType.MEDIA -> {
showSnackbar()
if (settings.deleteRecordingsImmediately) {
batchesFolder.deleteRecordings()
}
}
} }
} catch (error: Exception) { } catch (error: Exception) {
Log.getStackTraceString(error) 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.AppSettings
import app.myzel394.alibi.db.RecordingInformation import app.myzel394.alibi.db.RecordingInformation
import app.myzel394.alibi.enums.RecorderState 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.helpers.VideoBatchesFolder
import app.myzel394.alibi.services.VideoRecorderService 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.CameraInfo
import app.myzel394.alibi.ui.utils.PermissionHelper import app.myzel394.alibi.ui.utils.PermissionHelper
@ -43,16 +42,17 @@ class VideoRecorderModel :
} }
override fun startRecording(context: Context, settings: AppSettings) { override fun startRecording(context: Context, settings: AppSettings) {
batchesFolder = if (settings.saveFolder == null) batchesFolder = when (settings.saveFolder) {
VideoBatchesFolder.viaInternalFolder(context) null -> VideoBatchesFolder.viaInternalFolder(context)
else RECORDER_MEDIA_SELECTED_VALUE -> VideoBatchesFolder.viaMediaFolder(context)
VideoBatchesFolder.viaCustomFolder( else -> VideoBatchesFolder.viaCustomFolder(
context, context,
DocumentFile.fromTreeUri( DocumentFile.fromTreeUri(
context, context,
Uri.parse(settings.saveFolder) Uri.parse(settings.saveFolder)
)!! )!!
) )
}
super.startRecording(context, settings) super.startRecording(context, settings)
} }