From 076df8c42985e4d102dcae6791f962f96079d3c3 Mon Sep 17 00:00:00 2001 From: Nicholas Tinsley Date: Thu, 22 Aug 2024 12:38:43 -0400 Subject: [PATCH] Update video sample app to read and write from private app storage. --- .../app/transcode/TranscodeTestActivity.kt | 3 +- .../app/transcode/TranscodeTestRepository.kt | 38 +---- .../app/transcode/TranscodeTestViewModel.kt | 2 +- .../video/app/transcode/TranscodeWorker.kt | 134 ++++++++++-------- .../composables/ConfigurationSelection.kt | 4 +- .../app/transcode/composables/Progress.kt | 23 ++- 6 files changed, 100 insertions(+), 104 deletions(-) diff --git a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestActivity.kt b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestActivity.kt index 76137f651c..26fecac049 100644 --- a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestActivity.kt +++ b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestActivity.kt @@ -39,6 +39,7 @@ import org.thoughtcrime.video.app.transcode.composables.ConfigureEncodingParamet import org.thoughtcrime.video.app.transcode.composables.SelectInput import org.thoughtcrime.video.app.transcode.composables.SelectOutput import org.thoughtcrime.video.app.transcode.composables.TranscodingJobProgress +import org.thoughtcrime.video.app.transcode.composables.WorkState import org.thoughtcrime.video.app.ui.theme.SignalTheme /** @@ -67,7 +68,7 @@ class TranscodeTestActivity : AppCompatActivity() { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { val transcodingJobs = viewModel.getTranscodingJobsAsState().collectAsState(emptyList()) if (transcodingJobs.value.isNotEmpty()) { - TranscodingJobProgress(transcodingJobs = transcodingJobs.value, resetButtonOnClick = { viewModel.reset() }) + TranscodingJobProgress(transcodingJobs = transcodingJobs.value.map { WorkState.fromInfo(it) }, resetButtonOnClick = { viewModel.reset() }) } else if (viewModel.selectedVideos.isEmpty()) { SelectInput { pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)) } } else if (viewModel.outputDirectory == null) { diff --git a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestRepository.kt b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestRepository.kt index 19309c8894..3d9531380d 100644 --- a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestRepository.kt +++ b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestRepository.kt @@ -7,7 +7,6 @@ package org.thoughtcrime.video.app.transcode import android.content.Context import android.net.Uri -import android.provider.DocumentsContract import androidx.work.Data import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo @@ -15,7 +14,6 @@ import androidx.work.WorkManager import androidx.work.WorkQuery import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow -import org.signal.core.util.readToList import org.thoughtcrime.securesms.video.TranscodingPreset import java.util.UUID import kotlin.math.absoluteValue @@ -100,42 +98,12 @@ class TranscodeTestRepository(context: Context) { workManager.pruneWork() } - fun cleanFailedTranscodes(context: Context, folderUri: Uri) { - val docs = queryChildDocuments(context, folderUri) - docs.filter { it.documentId.endsWith(".tmp") }.forEach { - val fileUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, it.documentId) - DocumentsContract.deleteDocument(context.contentResolver, fileUri) + fun cleanFailedTranscodes(context: Context) { + context.filesDir.listFiles()?.filter { it.name.endsWith(TranscodeWorker.TEMP_FILE_EXTENSION) }?.forEach { + it.delete() } } - private fun queryChildDocuments(context: Context, folderUri: Uri): List { - val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( - folderUri, - DocumentsContract.getTreeDocumentId(folderUri) - ) - - context.contentResolver.query( - childrenUri, - arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Document.COLUMN_SIZE), - null, - null, - null - ).use { cursor -> - if (cursor == null) { - return emptyList() - } - return cursor.readToList { - FileMetadata( - documentId = it.getString(0), - label = it.getString(1), - size = it.getLong(2) - ) - } - } - } - - private data class FileMetadata(val documentId: String, val label: String, val size: Long) - data class CustomTranscodingOptions(val videoResolution: VideoResolution, val videoBitrate: Int, val audioBitrate: Int, val enableFastStart: Boolean, val enableAudioRemux: Boolean) companion object { diff --git a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestViewModel.kt b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestViewModel.kt index 0829da2e68..1fe9cb47c7 100644 --- a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestViewModel.kt +++ b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestViewModel.kt @@ -87,7 +87,7 @@ class TranscodeTestViewModel : ViewModel() { fun setOutputDirectoryAndCleanFailedTranscodes(context: Context, folderUri: Uri) { outputDirectory = folderUri - repository.cleanFailedTranscodes(context, folderUri) + repository.cleanFailedTranscodes(context) } fun reset() { diff --git a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeWorker.kt b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeWorker.kt index 10b3e078a4..2f4a3f8458 100644 --- a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeWorker.kt +++ b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeWorker.kt @@ -21,7 +21,6 @@ import androidx.work.Data import androidx.work.ForegroundInfo import androidx.work.WorkManager import androidx.work.WorkerParameters -import org.signal.core.util.getLength import org.signal.core.util.readLength import org.thoughtcrime.securesms.video.StreamingTranscoder import org.thoughtcrime.securesms.video.TranscodingPreset @@ -29,6 +28,8 @@ import org.thoughtcrime.securesms.video.postprocessing.Mp4FaststartPostProcessor import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.InputStreamMediaDataSource import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants import org.thoughtcrime.video.app.R +import java.io.File +import java.io.FileInputStream import java.io.IOException import java.io.InputStream import java.time.Instant @@ -48,80 +49,82 @@ class TranscodeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker( return Result.failure() } - val notificationId = inputData.getInt(KEY_NOTIFICATION_ID, -1) - if (notificationId < 0) { - Log.w(TAG, "$logPrefix Notification ID was null!") - return Result.failure() - } - - val inputUri = inputData.getString(KEY_INPUT_URI) - if (inputUri == null) { - Log.w(TAG, "$logPrefix Input URI was null!") - return Result.failure() - } - - val outputDirUri = inputData.getString(KEY_OUTPUT_URI) - if (outputDirUri == null) { - Log.w(TAG, "$logPrefix Output URI was null!") - return Result.failure() - } - - val postProcessForFastStart = inputData.getBoolean(KEY_ENABLE_FASTSTART, true) - val transcodingPreset = inputData.getString(KEY_TRANSCODING_PRESET_NAME) - val resolution = inputData.getInt(KEY_SHORT_EDGE, -1) - val videoBitrate = inputData.getInt(KEY_VIDEO_BIT_RATE, -1) - val audioBitrate = inputData.getInt(KEY_AUDIO_BIT_RATE, -1) - val audioRemux = inputData.getBoolean(KEY_ENABLE_AUDIO_REMUX, true) - - val input = DocumentFile.fromSingleUri(applicationContext, Uri.parse(inputUri))?.name - if (input == null) { + val inputParams = InputParams(inputData) + val inputFilename = DocumentFile.fromSingleUri(applicationContext, inputParams.inputUri)?.name + if (inputFilename == null) { Log.w(TAG, "$logPrefix Could not read input file name!") return Result.failure() } - val filenameBase = "transcoded-${Instant.now()}-$input" + val filenameBase = "transcoded-${Instant.now()}-$inputFilename" val tempFilename = "$filenameBase$TEMP_FILE_EXTENSION" val finalFilename = "$filenameBase$OUTPUT_FILE_EXTENSION" - val tempFile = createFile(Uri.parse(outputDirUri), tempFilename) - if (tempFile == null) { - Log.w(TAG, "$logPrefix Could not create temp file!") - return Result.failure() - } + setForeground(createForegroundInfo(-1, inputParams.notificationId)) - val datasource = WorkerMediaDataSource(applicationContext, Uri.parse(inputUri)) - - val transcoder = if (resolution > 0 && videoBitrate > 0) { - Log.d(TAG, "$logPrefix Initializing StreamingTranscoder with custom parameters: B:V=$videoBitrate, B:A=$audioBitrate, res=$resolution, audioRemux=$audioRemux") - StreamingTranscoder.createManuallyForTesting(datasource, null, videoBitrate, audioBitrate, resolution, audioRemux) - } else if (transcodingPreset != null) { - StreamingTranscoder(datasource, null, TranscodingPreset.valueOf(transcodingPreset), DEFAULT_FILE_SIZE_LIMIT, audioRemux) - } else { - throw IllegalArgumentException("Improper input data! No TranscodingPreset defined, or invalid manual parameters!") - } - - setForeground(createForegroundInfo(-1, notificationId)) - applicationContext.contentResolver.openOutputStream(tempFile.uri).use { outputStream -> + applicationContext.openFileOutput(tempFilename, Context.MODE_PRIVATE).use { outputStream -> if (outputStream == null) { Log.w(TAG, "$logPrefix Could not open temp file for I/O!") return Result.failure() } + + applicationContext.contentResolver.openInputStream(inputParams.inputUri).use { inputStream -> + applicationContext.openFileOutput(inputFilename, Context.MODE_PRIVATE).use { outputStream -> + Log.i(TAG, "Started copying input to internal storage.") + inputStream?.copyTo(outputStream) + Log.i(TAG, "Finished copying input to internal storage.") + } + } + } + + val datasource = WorkerMediaDataSource(File(applicationContext.filesDir, inputFilename)) + + val transcoder = if (inputParams.resolution > 0 && inputParams.videoBitrate > 0) { + Log.d(TAG, "$logPrefix Initializing StreamingTranscoder with custom parameters: B:V=${inputParams.videoBitrate}, B:A=${inputParams.audioBitrate}, res=${inputParams.resolution}, audioRemux=${inputParams.audioRemux}") + StreamingTranscoder.createManuallyForTesting(datasource, null, inputParams.videoBitrate, inputParams.audioBitrate, inputParams.resolution, inputParams.audioRemux) + } else if (inputParams.transcodingPreset != null) { + StreamingTranscoder(datasource, null, inputParams.transcodingPreset, DEFAULT_FILE_SIZE_LIMIT, inputParams.audioRemux) + } else { + throw IllegalArgumentException("Improper input data! No TranscodingPreset defined, or invalid manual parameters!") + } + + applicationContext.openFileOutput(tempFilename, Context.MODE_PRIVATE).use { outputStream -> transcoder.transcode({ percent: Int -> if (lastProgress != percent) { lastProgress = percent Log.v(TAG, "$logPrefix Updating progress percent to $percent%") setProgressAsync(Data.Builder().putInt(KEY_PROGRESS, percent).build()) - setForegroundAsync(createForegroundInfo(percent, notificationId)) + setForegroundAsync(createForegroundInfo(percent, inputParams.notificationId)) } }, outputStream, { isStopped }) } + Log.v(TAG, "$logPrefix Initial transcode completed successfully!") - if (!postProcessForFastStart) { - tempFile.renameTo(finalFilename) + + val finalFile = createFile(inputParams.outputDirUri, finalFilename) ?: run { + Log.w(TAG, "$logPrefix Could not create final file for faststart processing!") + return Result.failure() + } + + if (!inputParams.postProcessForFastStart) { + applicationContext.openFileInput(tempFilename).use { tempFileStream -> + if (tempFileStream == null) { + Log.w(TAG, "$logPrefix Could not open temp file for I/O!") + return Result.failure() + } + applicationContext.contentResolver.openOutputStream(finalFile.uri, "w").use { finalFileStream -> + if (finalFileStream == null) { + Log.w(TAG, "$logPrefix Could not open output file for I/O!") + return Result.failure() + } + + tempFileStream.copyTo(finalFileStream) + } + } Log.v(TAG, "$logPrefix Rename successful.") } else { val tempFileLength: Long - applicationContext.contentResolver.openInputStream(tempFile.uri).use { tempFileStream -> + applicationContext.openFileInput(tempFilename).use { tempFileStream -> if (tempFileStream == null) { Log.w(TAG, "$logPrefix Could not open temp file for I/O!") return Result.failure() @@ -129,17 +132,14 @@ class TranscodeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker( tempFileLength = tempFileStream.readLength() } - val finalFile = createFile(Uri.parse(outputDirUri), finalFilename) ?: run { - Log.w(TAG, "$logPrefix Could not create final file for faststart processing!") - return Result.failure() - } + applicationContext.contentResolver.openOutputStream(finalFile.uri, "w").use { finalFileStream -> if (finalFileStream == null) { Log.w(TAG, "$logPrefix Could not open output file for I/O!") return Result.failure() } - val inputStreamFactory = { applicationContext.contentResolver.openInputStream(tempFile.uri) ?: throw IOException("Could not open temp file for reading!") } + val inputStreamFactory = { applicationContext.openFileInput(tempFilename) ?: throw IOException("Could not open temp file for reading!") } val bytesCopied = Mp4FaststartPostProcessor(inputStreamFactory).processAndWriteTo(finalFileStream) if (bytesCopied != tempFileLength) { @@ -147,6 +147,7 @@ class TranscodeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker( return Result.failure() } + val tempFile = File(applicationContext.filesDir, tempFilename) if (!tempFile.delete()) { Log.w(TAG, "$logPrefix Failed to delete temp file after processing!") return Result.failure() @@ -194,10 +195,9 @@ class TranscodeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker( return DocumentFile.fromTreeUri(applicationContext, treeUri)?.createFile(VideoConstants.VIDEO_MIME_TYPE, filename) } - private class WorkerMediaDataSource(context: Context, private val uri: Uri) : InputStreamMediaDataSource() { + private class WorkerMediaDataSource(private val file: File) : InputStreamMediaDataSource() { - private val contentResolver = context.contentResolver - private val size = contentResolver.getLength(uri) ?: throw IllegalStateException() + private val size = file.length() private var inputStream: InputStream? = null @@ -211,17 +211,29 @@ class TranscodeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker( override fun createInputStream(position: Long): InputStream { inputStream?.close() - val openedInputStream = contentResolver.openInputStream(uri) ?: throw IllegalStateException() + val openedInputStream = FileInputStream(file) openedInputStream.skip(position) inputStream = openedInputStream return openedInputStream } } + private data class InputParams(private val inputData: Data) { + val notificationId: Int = inputData.getInt(KEY_NOTIFICATION_ID, -1) + val inputUri: Uri = Uri.parse(inputData.getString(KEY_INPUT_URI)) + val outputDirUri: Uri = Uri.parse(inputData.getString(KEY_OUTPUT_URI)) + val postProcessForFastStart: Boolean = inputData.getBoolean(KEY_ENABLE_FASTSTART, true) + val transcodingPreset: TranscodingPreset? = inputData.getString(KEY_TRANSCODING_PRESET_NAME)?.let { TranscodingPreset.valueOf(it) } + val resolution: Int = inputData.getInt(KEY_SHORT_EDGE, -1) + val videoBitrate: Int = inputData.getInt(KEY_VIDEO_BIT_RATE, -1) + val audioBitrate: Int = inputData.getInt(KEY_AUDIO_BIT_RATE, -1) + val audioRemux: Boolean = inputData.getBoolean(KEY_ENABLE_AUDIO_REMUX, true) + } + companion object { private const val TAG = "TranscodeWorker" private const val OUTPUT_FILE_EXTENSION = ".mp4" - private const val TEMP_FILE_EXTENSION = ".tmp" + const val TEMP_FILE_EXTENSION = ".tmp" private const val DEFAULT_FILE_SIZE_LIMIT: Long = 100 * 1024 * 1024 const val KEY_INPUT_URI = "input_uri" const val KEY_OUTPUT_URI = "output_uri" diff --git a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/composables/ConfigurationSelection.kt b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/composables/ConfigurationSelection.kt index 9ec0351c4d..09b8fa9a2a 100644 --- a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/composables/ConfigurationSelection.kt +++ b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/composables/ConfigurationSelection.kt @@ -139,7 +139,7 @@ private fun PresetPicker( .fillMaxWidth() .selectableGroup() ) { - TranscodingPreset.values().forEach { + TranscodingPreset.entries.forEach { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier @@ -185,7 +185,7 @@ private fun CustomSettings( .fillMaxWidth() .selectableGroup() ) { - VideoResolution.values().forEach { + VideoResolution.entries.forEach { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier diff --git a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/composables/Progress.kt b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/composables/Progress.kt index 31edbfbdb3..7e4a2c8dfc 100644 --- a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/composables/Progress.kt +++ b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/composables/Progress.kt @@ -25,20 +25,20 @@ import org.thoughtcrime.video.app.ui.composables.LabeledButton * A view that shows the current encodes in progress. */ @Composable -fun TranscodingJobProgress(transcodingJobs: List, resetButtonOnClick: () -> Unit, modifier: Modifier = Modifier) { +fun TranscodingJobProgress(transcodingJobs: List, resetButtonOnClick: () -> Unit, modifier: Modifier = Modifier) { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { transcodingJobs.forEach { workInfo -> - val currentProgress = workInfo.progress.getInt(TranscodeWorker.KEY_PROGRESS, -1) + val currentProgress = workInfo.progress Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier.padding(horizontal = 16.dp) ) { val progressIndicatorModifier = Modifier.weight(3f) Text( - text = "Job ${workInfo.id.toString().takeLast(4)}", + text = "Job ${workInfo.id.takeLast(4)}", modifier = Modifier .padding(end = 16.dp) .weight(1f) @@ -56,8 +56,23 @@ fun TranscodingJobProgress(transcodingJobs: List, resetButtonOnClick: } } +data class WorkState(val id: String, val state: WorkInfo.State, val progress: Int) { + companion object { + fun fromInfo(info: WorkInfo): WorkState { + return WorkState(info.id.toString(), info.state, info.progress.getInt(TranscodeWorker.KEY_PROGRESS, -1)) + } + } +} + @Preview @Composable private fun ProgressScreenPreview() { - TranscodingJobProgress(emptyList(), resetButtonOnClick = {}) + TranscodingJobProgress( + listOf( + WorkState("abcde", WorkInfo.State.RUNNING, 47), + WorkState("fghij", WorkInfo.State.ENQUEUED, -1), + WorkState("klmnop", WorkInfo.State.FAILED, -1) + ), + resetButtonOnClick = {} + ) }