mirror of
https://github.com/Myzel394/Alibi.git
synced 2025-06-18 23:05:26 +02:00
feat: Add support for media folders to video recording
This commit is contained in:
parent
99085b2176
commit
7401454269
@ -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 ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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))!!
|
||||
|
@ -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())
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user