Update video sample app to read and write from private app storage.

This commit is contained in:
Nicholas Tinsley 2024-08-22 12:38:43 -04:00 committed by mtang-signal
parent 8727f0d90d
commit 076df8c429
6 changed files with 100 additions and 104 deletions

View file

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

View file

@ -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<FileMetadata> {
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 {

View file

@ -87,7 +87,7 @@ class TranscodeTestViewModel : ViewModel() {
fun setOutputDirectoryAndCleanFailedTranscodes(context: Context, folderUri: Uri) {
outputDirectory = folderUri
repository.cleanFailedTranscodes(context, folderUri)
repository.cleanFailedTranscodes(context)
}
fun reset() {

View file

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

View file

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

View file

@ -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<WorkInfo>, resetButtonOnClick: () -> Unit, modifier: Modifier = Modifier) {
fun TranscodingJobProgress(transcodingJobs: List<WorkState>, 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<WorkInfo>, 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 = {}
)
}