Source added

This commit is contained in:
Fr4nz D13trich 2025-11-20 09:26:33 +01:00
parent b2864b500e
commit ba28ca859e
8352 changed files with 1487182 additions and 1 deletions

1
video/app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,76 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
plugins {
id("signal-sample-app")
alias(libs.plugins.compose.compiler)
}
val signalBuildToolsVersion: String by rootProject.extra
val signalCompileSdkVersion: String by rootProject.extra
val signalTargetSdkVersion: Int by rootProject.extra
val signalMinSdkVersion: Int by rootProject.extra
val signalJavaVersion: JavaVersion by rootProject.extra
val signalKotlinJvmTarget: String by rootProject.extra
android {
namespace = "org.thoughtcrime.video.app"
compileSdkVersion = signalCompileSdkVersion
defaultConfig {
applicationId = "org.thoughtcrime.video.app"
minSdk = 23
targetSdk = signalTargetSdkVersion
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = signalJavaVersion
targetCompatibility = signalJavaVersion
}
kotlinOptions {
jvmTarget = signalKotlinJvmTarget
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.material3)
implementation(libs.bundles.media3)
implementation(project(":video"))
implementation(project(":core-util"))
implementation("androidx.work:work-runtime-ktx:2.9.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4")
implementation(libs.androidx.compose.ui.tooling.core)
implementation(libs.androidx.compose.ui.test.manifest)
androidTestImplementation(testLibs.junit.junit)
androidTestImplementation(testLibs.androidx.test.runner)
androidTestImplementation(testLibs.androidx.test.ext.junit.ktx)
}

21
video/app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,27 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("org.thoughtcrime.video.app", appContext.packageName)
}
}

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2023 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Signal">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Signal">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".transcode.TranscodeTestActivity"
android:exported="false"
android:parentActivityName=".MainActivity"
android:theme="@style/Theme.Signal" />
<activity
android:name=".playback.PlaybackTestActivity"
android:exported="false"
android:parentActivityName=".MainActivity"
android:theme="@style/Theme.Signal" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application>
</manifest>

View file

@ -0,0 +1,157 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.media.MediaScannerConnection
import android.os.Bundle
import android.os.Environment
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import org.signal.core.util.logging.AndroidLogger
import org.signal.core.util.logging.Log
import org.thoughtcrime.video.app.playback.PlaybackTestActivity
import org.thoughtcrime.video.app.transcode.TranscodeTestActivity
import org.thoughtcrime.video.app.ui.composables.LabeledButton
import org.thoughtcrime.video.app.ui.theme.SignalTheme
/**
* Main activity for this sample app.
*/
class MainActivity : AppCompatActivity() {
companion object {
private val TAG = Log.tag(MainActivity::class.java)
private var appLaunch = true
}
private val sharedPref: SharedPreferences by lazy {
getSharedPreferences(
getString(R.string.preference_file_key),
Context.MODE_PRIVATE
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.initialize(AndroidLogger)
val startPlaybackScreen = { saveChoice: Boolean -> proceed(Screen.TEST_PLAYBACK, saveChoice) }
val startTranscodeScreen = { saveChoice: Boolean -> proceed(Screen.TEST_TRANSCODE, saveChoice) }
setContent {
Body(startPlaybackScreen, startTranscodeScreen)
}
refreshMediaProviderForExternalStorage(this, arrayOf("video/*"))
if (appLaunch) {
appLaunch = false
getLaunchChoice()?.let {
proceed(it, false)
}
}
}
@Composable
private fun Body(startPlaybackScreen: (Boolean) -> Unit, startTranscodeScreen: (Boolean) -> Unit) {
var rememberChoice by remember { mutableStateOf(getLaunchChoice() != null) }
SignalTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
LabeledButton("Test Playback") {
startPlaybackScreen(rememberChoice)
}
LabeledButton("Test Transcode") {
startTranscodeScreen(rememberChoice)
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = rememberChoice,
onCheckedChange = { isChecked ->
rememberChoice = isChecked
if (!isChecked) {
clearLaunchChoice()
}
}
)
Text(text = "Remember & Skip This Screen", style = MaterialTheme.typography.labelLarge)
}
}
}
}
}
private fun getLaunchChoice(): Screen? {
val screenName = sharedPref.getString(getString(R.string.preference_activity_shortcut_key), null) ?: return null
return Screen.valueOf(screenName)
}
private fun clearLaunchChoice() {
with(sharedPref.edit()) {
remove(getString(R.string.preference_activity_shortcut_key))
apply()
}
}
private fun saveLaunchChoice(choice: Screen) {
with(sharedPref.edit()) {
putString(getString(R.string.preference_activity_shortcut_key), choice.name)
apply()
}
}
private fun refreshMediaProviderForExternalStorage(context: Context, mimeTypes: Array<String>) {
val rootPath = Environment.getExternalStorageDirectory().absolutePath
MediaScannerConnection.scanFile(
context,
arrayOf<String>(rootPath),
mimeTypes
) { _, _ ->
Log.i(TAG, "Re-scan of external storage for media completed.")
}
}
private fun proceed(screen: Screen, saveChoice: Boolean) {
if (saveChoice) {
saveLaunchChoice(screen)
}
when (screen) {
Screen.TEST_PLAYBACK -> startActivity(Intent(this, PlaybackTestActivity::class.java))
Screen.TEST_TRANSCODE -> startActivity(Intent(this, TranscodeTestActivity::class.java))
}
}
private enum class Screen {
TEST_PLAYBACK,
TEST_TRANSCODE
}
@Preview
@Composable
private fun PreviewBody() {
Body({}, {})
}
}

View file

@ -0,0 +1,107 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.playback
import android.os.Bundle
import android.util.Log
import androidx.activity.compose.setContent
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.ui.PlayerView
import org.thoughtcrime.video.app.ui.composables.LabeledButton
import org.thoughtcrime.video.app.ui.theme.SignalTheme
class PlaybackTestActivity : AppCompatActivity() {
private val viewModel: PlaybackTestViewModel by viewModels()
private lateinit var exoPlayer: ExoPlayer
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.initialize(this)
exoPlayer = ExoPlayer.Builder(this).build()
setContent {
SignalTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
val videoUri = viewModel.selectedVideo
if (videoUri == null) {
LabeledButton("Select Video") { pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)) }
} else {
LabeledButton("Play Video") { viewModel.updateMediaSource(this@PlaybackTestActivity) }
LabeledButton("Play Video with slow download") { viewModel.updateMediaSourceTrickle(this@PlaybackTestActivity) }
ExoVideoView(source = viewModel.mediaSource, exoPlayer = exoPlayer)
}
}
}
}
}
}
override fun onPause() {
super.onPause()
exoPlayer.pause()
}
override fun onDestroy() {
super.onDestroy()
viewModel.releaseCache()
exoPlayer.stop()
exoPlayer.release()
}
/**
* This launches the system media picker and stores the resulting URI.
*/
private val pickMedia = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) {
Log.d("PlaybackPicker", "Selected URI: $uri")
viewModel.selectedVideo = uri
viewModel.updateMediaSource(this)
} else {
Log.d("PlaybackPicker", "No media selected")
}
}
}
@OptIn(UnstableApi::class)
@Composable
fun ExoVideoView(source: MediaSource, exoPlayer: ExoPlayer, modifier: Modifier = Modifier) {
exoPlayer.playWhenReady = false
exoPlayer.setMediaSource(source)
exoPlayer.prepare()
AndroidView(factory = { context ->
PlayerView(context).apply {
player = exoPlayer
}
}, modifier = modifier)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
SignalTheme {
LabeledButton("Preview Render") {}
}
}

View file

@ -0,0 +1,93 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.playback
import android.content.Context
import android.net.Uri
import androidx.annotation.OptIn
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.NoOpCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.source.SilenceMediaSource
import java.io.File
/**
* Main screen view model for the video sample app.
*/
@OptIn(UnstableApi::class)
class PlaybackTestViewModel : ViewModel() {
// Initialize an silent media source before the user selects a video. This is the closest I could find to an "empty" media source while still being nullsafe.
private val value by lazy {
val factory = SilenceMediaSource.Factory()
factory.setDurationUs(1000)
factory.createMediaSource()
}
private lateinit var cache: Cache
var selectedVideo: Uri? by mutableStateOf(null)
var mediaSource: MediaSource by mutableStateOf(value)
private set
/**
* Initialize the backing cache. This is a file in the app's cache directory that has a random suffix to ensure you get cache misses on a new app launch.
*
* @param context required to get the file path of the cache directory.
*/
fun initialize(context: Context) {
val cacheDir = File(context.cacheDir.absolutePath)
cache = SimpleCache(File(cacheDir, getRandomString(12)), NoOpCacheEvictor())
}
fun updateMediaSource(context: Context) {
selectedVideo?.let {
mediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context)).createMediaSource(MediaItem.fromUri(it))
}
}
/**
* Replaces the media source with one that has a latency to each read from the media source, simulating network latency.
* It stores the result in a cache (that does not have a penalty) to better mimic real-world performance:
* once a chunk is downloaded from the network, it will not have to be re-fetched.
*
* @param context
*/
fun updateMediaSourceTrickle(context: Context) {
selectedVideo?.let {
val cacheFactory = CacheDataSource.Factory()
.setCache(cache)
.setUpstreamDataSourceFactory(SlowDataSource.Factory(context, 10))
mediaSource = ProgressiveMediaSource.Factory(cacheFactory).createMediaSource(MediaItem.fromUri(it))
}
}
fun releaseCache() {
cache.release()
}
/**
* Get random string. Will always return at least one character.
*
* @param length length of the returned string.
* @return a string composed of random alphanumeric characters of the specified length (minimum of 1).
*/
private fun getRandomString(length: Int): String {
val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9')
return (1..length.coerceAtLeast(1))
.map { allowedChars.random() }
.joinToString("")
}
}

View file

@ -0,0 +1,55 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.playback
import android.content.Context
import android.net.Uri
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.TransferListener
/**
* This wraps a [DefaultDataSource] and adds [latency] to each read. This is intended to approximate a slow/shoddy network connection that drip-feeds in data.
*
* @property latency the amount, in milliseconds, that each read should be delayed. A good proxy for network ping.
* @constructor
*
* @param context used to initialize the underlying [DefaultDataSource.Factory]
*/
@OptIn(UnstableApi::class)
class SlowDataSource(context: Context, private val latency: Long) : DataSource {
private val internalDataSource: DataSource = DefaultDataSource.Factory(context).createDataSource()
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
Thread.sleep(latency)
return internalDataSource.read(buffer, offset, length)
}
override fun addTransferListener(transferListener: TransferListener) {
internalDataSource.addTransferListener(transferListener)
}
override fun open(dataSpec: DataSpec): Long {
return internalDataSource.open(dataSpec)
}
override fun getUri(): Uri? {
return internalDataSource.uri
}
override fun close() {
return internalDataSource.close()
}
class Factory(private val context: Context, private val latency: Long) : DataSource.Factory {
override fun createDataSource(): DataSource {
return SlowDataSource(context, latency)
}
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.transcode
/**
* A dumping ground for constants that should be referenced across the sample app.
*/
internal const val MIN_VIDEO_MEGABITRATE = 0.5f
internal const val MAX_VIDEO_MEGABITRATE = 4f
internal val OPTIONS_AUDIO_KILOBITRATES = listOf(64, 96, 128, 160, 192)
enum class VideoResolution(val longEdge: Int, val shortEdge: Int) {
SD(854, 480),
HD(1280, 720),
FHD(1920, 1080),
WQHD(2560, 1440),
UHD(3840, 2160);
fun getContentDescription(): String {
return "Resolution with a long edge of $longEdge and a short edge of $shortEdge."
}
}

View file

@ -0,0 +1,141 @@
/*
* Copyright 2024 Signal Messenger, LLC
* 2SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.transcode
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.ViewGroup
import androidx.activity.compose.setContent
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.video.app.R
import org.thoughtcrime.video.app.transcode.composables.ConfigureEncodingParameters
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
/**
* Visual entry point for testing transcoding in the video sample app.
*/
class TranscodeTestActivity : AppCompatActivity() {
private val TAG = "TranscodeTestActivity"
private val viewModel: TranscodeTestViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.initialize(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = applicationContext.getString(R.string.channel_name)
val descriptionText = applicationContext.getString(R.string.channel_description)
val importance = NotificationManager.IMPORTANCE_DEFAULT
val mChannel = NotificationChannel(getString(R.string.notification_channel_id), name, importance)
mChannel.description = descriptionText
val notificationManager = applicationContext.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(mChannel)
}
setContent {
SignalTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
val transcodingJobs = viewModel.getTranscodingJobsAsState().collectAsStateWithLifecycle(emptyList())
if (transcodingJobs.value.isNotEmpty()) {
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) {
SelectOutput { outputDirRequest.launch(null) }
} else {
ConfigureEncodingParameters(
modifier = Modifier
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
viewModel = viewModel
)
}
}
}
}
getComposeView()?.keepScreenOn = true
if (Build.VERSION.SDK_INT >= 33) {
val notificationPermissionStatus = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
Log.v(TAG, "Notification permission status: $notificationPermissionStatus")
if (notificationPermissionStatus != PackageManager.PERMISSION_GRANTED) {
if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.POST_NOTIFICATIONS)) {
showPermissionRationaleDialog { _, _ -> requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) }
} else {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
}
private fun showPermissionRationaleDialog(okListener: DialogInterface.OnClickListener) {
MaterialAlertDialogBuilder(this)
.setTitle("The system will request the notification permission.")
.setMessage("This permission is required to show the transcoding progress in the notification tray.")
.setPositiveButton("Ok", okListener)
.show()
}
/**
* This launches the system media picker and stores the resulting URI.
*/
private val pickMedia = registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris: List<Uri> ->
if (uris.isNotEmpty()) {
Log.d(TAG, "Selected URI: $uris")
viewModel.selectedVideos = uris
viewModel.resetOutputDirectory()
} else {
Log.d(TAG, "No media selected")
}
}
private val outputDirRequest = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
uri?.let {
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
viewModel.setOutputDirectoryAndCleanFailedTranscodes(this, it)
}
}
private val requestPermissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
Log.d(TAG, "Notification permission allowed: $isGranted")
}
private fun getComposeView(): ComposeView? {
return window.decorView
.findViewById<ViewGroup>(android.R.id.content)
.getChildAt(0) as? ComposeView
}
}

View file

@ -0,0 +1,114 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.transcode
import android.content.Context
import android.net.Uri
import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import org.thoughtcrime.securesms.video.TranscodingPreset
import java.util.UUID
import kotlin.math.absoluteValue
import kotlin.random.Random
/**
* Repository to perform various transcoding functions.
*/
class TranscodeTestRepository(context: Context) {
private val workManager = WorkManager.getInstance(context)
private val usedNotificationIds = emptySet<Int>()
private fun transcode(selectedVideos: List<Uri>, outputDirectory: Uri, forceSequentialProcessing: Boolean, transcodingPreset: TranscodingPreset? = null, customTranscodingOptions: CustomTranscodingOptions? = null): Map<UUID, Uri> {
if (customTranscodingOptions == null && transcodingPreset == null) {
throw IllegalArgumentException("Must define either custom options or transcoding preset!")
} else if (customTranscodingOptions != null && transcodingPreset != null) {
throw IllegalArgumentException("Cannot define both custom options and transcoding preset!")
}
if (selectedVideos.isEmpty()) {
return emptyMap()
}
val urisAndRequests = selectedVideos.map {
var notificationId = Random.nextInt().absoluteValue
while (usedNotificationIds.contains(notificationId)) {
notificationId = Random.nextInt().absoluteValue
}
val inputData = Data.Builder()
.putString(TranscodeWorker.KEY_INPUT_URI, it.toString())
.putString(TranscodeWorker.KEY_OUTPUT_URI, outputDirectory.toString())
.putInt(TranscodeWorker.KEY_NOTIFICATION_ID, notificationId)
if (transcodingPreset != null) {
inputData.putString(TranscodeWorker.KEY_TRANSCODING_PRESET_NAME, transcodingPreset.name)
} else if (customTranscodingOptions != null) {
inputData.putString(TranscodeWorker.KEY_VIDEO_CODEC, customTranscodingOptions.videoCodec)
inputData.putInt(TranscodeWorker.KEY_LONG_EDGE, customTranscodingOptions.videoResolution.longEdge)
inputData.putInt(TranscodeWorker.KEY_SHORT_EDGE, customTranscodingOptions.videoResolution.shortEdge)
inputData.putInt(TranscodeWorker.KEY_VIDEO_BIT_RATE, customTranscodingOptions.videoBitrate)
inputData.putInt(TranscodeWorker.KEY_AUDIO_BIT_RATE, customTranscodingOptions.audioBitrate)
inputData.putBoolean(TranscodeWorker.KEY_ENABLE_FASTSTART, customTranscodingOptions.enableFastStart)
inputData.putBoolean(TranscodeWorker.KEY_ENABLE_AUDIO_REMUX, customTranscodingOptions.enableAudioRemux)
}
val transcodeRequest = OneTimeWorkRequestBuilder<TranscodeWorker>()
.setInputData(inputData.build())
.addTag(TRANSCODING_WORK_TAG)
.build()
it to transcodeRequest
}
val idsToUris = urisAndRequests.associateBy({ it.second.id }, { it.first })
val requests = urisAndRequests.map { it.second }
if (forceSequentialProcessing) {
var continuation = workManager.beginWith(requests.first())
for (request in requests.drop(1)) {
continuation = continuation.then(request)
}
continuation.enqueue()
} else {
workManager.enqueue(requests)
}
return idsToUris
}
fun transcodeWithCustomOptions(selectedVideos: List<Uri>, outputDirectory: Uri, forceSequentialProcessing: Boolean, customTranscodingOptions: CustomTranscodingOptions?): Map<UUID, Uri> {
return transcode(selectedVideos, outputDirectory, forceSequentialProcessing, customTranscodingOptions = customTranscodingOptions)
}
fun transcodeWithPresetOptions(selectedVideos: List<Uri>, outputDirectory: Uri, forceSequentialProcessing: Boolean, transcodingPreset: TranscodingPreset): Map<UUID, Uri> {
return transcode(selectedVideos, outputDirectory, forceSequentialProcessing, transcodingPreset)
}
fun getTranscodingJobsAsFlow(jobIds: List<UUID>): Flow<MutableList<WorkInfo>> {
if (jobIds.isEmpty()) {
return emptyFlow()
}
return workManager.getWorkInfosFlow(WorkQuery.fromIds(jobIds))
}
fun cancelAllTranscodes() {
workManager.cancelAllWorkByTag(TRANSCODING_WORK_TAG)
workManager.pruneWork()
}
fun cleanPrivateStorage(context: Context) {
context.filesDir.listFiles()?.forEach {
it.delete()
}
}
data class CustomTranscodingOptions(val videoCodec: String, val videoResolution: VideoResolution, val videoBitrate: Int, val audioBitrate: Int, val enableFastStart: Boolean, val enableAudioRemux: Boolean)
companion object {
private const val TAG = "TranscodingTestRepository"
const val TRANSCODING_WORK_TAG = "transcoding"
}
}

View file

@ -0,0 +1,133 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.transcode
import android.content.Context
import android.net.Uri
import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.work.WorkInfo
import kotlinx.coroutines.flow.Flow
import org.thoughtcrime.securesms.video.TranscodingPreset
import org.thoughtcrime.securesms.video.TranscodingQuality
import org.thoughtcrime.securesms.video.videoconverter.MediaConverter
import java.util.UUID
import kotlin.math.roundToInt
/**
* ViewModel for the transcoding screen of the video sample app. See [TranscodeTestActivity].
*/
class TranscodeTestViewModel : ViewModel() {
private lateinit var repository: TranscodeTestRepository
private var backPressedRunnable = {}
private var transcodingJobs: Map<UUID, Uri> = emptyMap()
var transcodingPreset by mutableStateOf(TranscodingPreset.LEVEL_2)
private set
var outputDirectory: Uri? by mutableStateOf(null)
private set
var selectedVideos: List<Uri> by mutableStateOf(emptyList())
var videoMegaBitrate by mutableFloatStateOf(calculateVideoMegaBitrateFromPreset(transcodingPreset))
var videoResolution by mutableStateOf(convertPresetToVideoResolution(transcodingPreset))
var audioKiloBitrate by mutableIntStateOf(calculateAudioKiloBitrateFromPreset(transcodingPreset))
var useHevc by mutableStateOf(false)
var useAutoTranscodingSettings by mutableStateOf(true)
var enableFastStart by mutableStateOf(true)
var enableAudioRemux by mutableStateOf(true)
var forceSequentialQueueProcessing by mutableStateOf(false)
fun initialize(context: Context) {
repository = TranscodeTestRepository(context)
backPressedRunnable = { Toast.makeText(context, "Cancelling all transcoding jobs!", Toast.LENGTH_LONG).show() }
}
fun transcode() {
val output = outputDirectory ?: throw IllegalStateException("No output directory selected!")
transcodingJobs = if (useAutoTranscodingSettings) {
repository.transcodeWithPresetOptions(
selectedVideos,
output,
forceSequentialQueueProcessing,
transcodingPreset
)
} else {
repository.transcodeWithCustomOptions(
selectedVideos,
output,
forceSequentialQueueProcessing,
TranscodeTestRepository.CustomTranscodingOptions(
if (useHevc) MediaConverter.VIDEO_CODEC_H265 else MediaConverter.VIDEO_CODEC_H264,
videoResolution,
(videoMegaBitrate * MEGABIT).roundToInt(),
audioKiloBitrate * KILOBIT,
enableAudioRemux,
enableFastStart
)
)
}
}
fun updateTranscodingPreset(preset: TranscodingPreset) {
transcodingPreset = preset
videoResolution = convertPresetToVideoResolution(preset)
videoMegaBitrate = calculateVideoMegaBitrateFromPreset(preset)
audioKiloBitrate = calculateAudioKiloBitrateFromPreset(preset)
}
fun getTranscodingJobsAsState(): Flow<MutableList<WorkInfo>> {
return repository.getTranscodingJobsAsFlow(transcodingJobs.keys.toList())
}
fun setOutputDirectoryAndCleanFailedTranscodes(context: Context, folderUri: Uri) {
outputDirectory = folderUri
repository.cleanPrivateStorage(context)
}
fun reset() {
cancelAllTranscodes()
resetOutputDirectory()
selectedVideos = emptyList()
}
private fun cancelAllTranscodes() {
repository.cancelAllTranscodes()
transcodingJobs = emptyMap()
}
fun resetOutputDirectory() {
outputDirectory = null
}
companion object {
private const val MEGABIT = 1000000
private const val KILOBIT = 1000
@JvmStatic
private fun calculateVideoMegaBitrateFromPreset(preset: TranscodingPreset): Float {
val quality = TranscodingQuality.createFromPreset(preset, -1)
return quality.targetVideoBitRate.toFloat() / MEGABIT
}
@JvmStatic
private fun calculateAudioKiloBitrateFromPreset(preset: TranscodingPreset): Int {
val quality = TranscodingQuality.createFromPreset(preset, -1)
return quality.targetAudioBitRate / KILOBIT
}
@JvmStatic
private fun convertPresetToVideoResolution(preset: TranscodingPreset) = when (preset) {
TranscodingPreset.LEVEL_3 -> VideoResolution.HD
else -> VideoResolution.SD
}
}
}

View file

@ -0,0 +1,266 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.transcode
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.TaskStackBuilder
import androidx.documentfile.provider.DocumentFile
import androidx.media3.common.util.UnstableApi
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import org.signal.core.util.readLength
import org.thoughtcrime.securesms.video.StreamingTranscoder
import org.thoughtcrime.securesms.video.TranscodingPreset
import org.thoughtcrime.securesms.video.postprocessing.Mp4FaststartPostProcessor
import org.thoughtcrime.securesms.video.videoconverter.MediaConverter.VideoCodec
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
/**
* A WorkManager worker to transcode videos in the background. This utilizes [StreamingTranscoder].
*/
class TranscodeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
private var lastProgress = 0
@UnstableApi
override suspend fun doWork(): Result {
val logPrefix = "[Job ${id.toString().takeLast(4)}]"
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
Log.w(TAG, "$logPrefix Transcoder is only supported on API 26+!")
return Result.failure()
}
val inputParams = InputParams(inputData)
val inputFilename = DocumentFile.fromSingleUri(applicationContext, inputParams.inputUri)?.name?.removeFileExtension()
if (inputFilename == null) {
Log.w(TAG, "$logPrefix Could not read input file name!")
return Result.failure()
}
val filenameBase = "transcoded-${Instant.now()}-$inputFilename"
val tempFilename = "$filenameBase$TEMP_FILE_EXTENSION"
val finalFilename = "$filenameBase$OUTPUT_FILE_EXTENSION"
setForeground(createForegroundInfo(-1, inputParams.notificationId))
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) {
if (inputParams.videoCodec == null) {
Log.w(TAG, "$logPrefix Video codec was null!")
return Result.failure()
}
Log.d(TAG, "$logPrefix Initializing StreamingTranscoder with custom parameters: CODEC:${inputParams.videoCodec} B:V=${inputParams.videoBitrate}, B:A=${inputParams.audioBitrate}, res=${inputParams.resolution}, audioRemux=${inputParams.audioRemux}")
StreamingTranscoder.createManuallyForTesting(datasource, null, inputParams.videoCodec, 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, inputParams.notificationId))
}
}, outputStream, { isStopped })
}
Log.v(TAG, "$logPrefix Initial transcode completed successfully!")
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.openFileInput(tempFilename).use { tempFileStream ->
if (tempFileStream == null) {
Log.w(TAG, "$logPrefix Could not open temp file for I/O!")
return Result.failure()
}
tempFileLength = tempFileStream.readLength()
}
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.openFileInput(tempFilename) ?: throw IOException("Could not open temp file for reading!") }
val bytesCopied = Mp4FaststartPostProcessor(inputStreamFactory).processAndWriteTo(finalFileStream)
if (bytesCopied != tempFileLength) {
Log.w(TAG, "$logPrefix Postprocessing failed! Original transcoded filesize ($tempFileLength) did not match postprocessed filesize ($bytesCopied)")
return Result.failure()
}
Log.v(TAG, "$logPrefix Faststart postprocess successful.")
}
val tempFile = File(applicationContext.filesDir, tempFilename)
if (!tempFile.delete()) {
Log.w(TAG, "$logPrefix Failed to delete temp file after processing!")
return Result.failure()
}
}
Log.v(TAG, "$logPrefix Overall transcode job successful.")
return Result.success()
}
private fun createForegroundInfo(progress: Int, notificationId: Int): ForegroundInfo {
val id = applicationContext.getString(R.string.notification_channel_id)
val title = applicationContext.getString(R.string.notification_title)
val cancel = applicationContext.getString(R.string.cancel_transcode)
val intent = WorkManager.getInstance(applicationContext)
.createCancelPendingIntent(getId())
val transcodeActivityIntent = Intent(applicationContext, TranscodeTestActivity::class.java)
val pendingIntent: PendingIntent? = TaskStackBuilder.create(applicationContext).run {
addNextIntentWithParentStack(transcodeActivityIntent)
getPendingIntent(
0,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
val notification = NotificationCompat.Builder(applicationContext, id)
.setContentTitle(title)
.setTicker(title)
.setProgress(100, progress, progress <= 0)
.setSmallIcon(R.drawable.ic_work_notification)
.setOngoing(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_LOW)
.addAction(android.R.drawable.ic_delete, cancel, intent)
.build()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(notificationId, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
ForegroundInfo(notificationId, notification)
}
}
private fun createFile(treeUri: Uri, filename: String): DocumentFile? {
return DocumentFile.fromTreeUri(applicationContext, treeUri)?.createFile(VideoConstants.VIDEO_MIME_TYPE, filename)
}
private fun String.removeFileExtension(): String {
val lastDot = this.lastIndexOf('.')
return if (lastDot != -1) {
this.substring(0, lastDot)
} else {
this
}
}
private class WorkerMediaDataSource(private val file: File) : InputStreamMediaDataSource() {
private val size = file.length()
private var inputStream: InputStream? = null
override fun close() {
inputStream?.close()
}
override fun getSize(): Long {
return size
}
override fun createInputStream(position: Long): InputStream {
inputStream?.close()
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) }
@VideoCodec val videoCodec: String? = inputData.getString(KEY_VIDEO_CODEC)
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"
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"
const val KEY_TRANSCODING_PRESET_NAME = "transcoding_quality_preset"
const val KEY_PROGRESS = "progress"
const val KEY_VIDEO_CODEC = "video_codec"
const val KEY_LONG_EDGE = "resolution_long_edge"
const val KEY_SHORT_EDGE = "resolution_short_edge"
const val KEY_VIDEO_BIT_RATE = "video_bit_rate"
const val KEY_AUDIO_BIT_RATE = "audio_bit_rate"
const val KEY_ENABLE_AUDIO_REMUX = "audio_remux"
const val KEY_ENABLE_FASTSTART = "video_enable_faststart"
const val KEY_NOTIFICATION_ID = "notification_id"
}
}

View file

@ -0,0 +1,325 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.transcode.composables
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import org.thoughtcrime.securesms.video.TranscodingPreset
import org.thoughtcrime.securesms.video.videoconverter.utils.DeviceCapabilities
import org.thoughtcrime.video.app.transcode.MAX_VIDEO_MEGABITRATE
import org.thoughtcrime.video.app.transcode.MIN_VIDEO_MEGABITRATE
import org.thoughtcrime.video.app.transcode.OPTIONS_AUDIO_KILOBITRATES
import org.thoughtcrime.video.app.transcode.TranscodeTestViewModel
import org.thoughtcrime.video.app.transcode.VideoResolution
import org.thoughtcrime.video.app.ui.composables.LabeledButton
import kotlin.math.roundToInt
/**
* A view that shows the queue of video URIs to encode, and allows you to change the encoding options.
*/
@Composable
fun ConfigureEncodingParameters(
hevcCapable: Boolean = DeviceCapabilities.canEncodeHevc(),
modifier: Modifier = Modifier,
viewModel: TranscodeTestViewModel = viewModel()
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
Text(
text = "Selected videos:",
modifier = Modifier
.padding(horizontal = 8.dp)
.align(Alignment.Start)
)
viewModel.selectedVideos.forEach {
Text(
text = it.toString(),
fontSize = 8.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier
.padding(horizontal = 8.dp)
.align(Alignment.Start)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
) {
Checkbox(
checked = viewModel.forceSequentialQueueProcessing,
onCheckedChange = { viewModel.forceSequentialQueueProcessing = it }
)
Text(text = "Force Sequential Queue Processing", style = MaterialTheme.typography.bodySmall)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
) {
Checkbox(
checked = viewModel.useAutoTranscodingSettings,
onCheckedChange = { viewModel.useAutoTranscodingSettings = it }
)
Text(
text = "Match Signal App Transcoding Settings",
style = MaterialTheme.typography.bodySmall
)
}
if (viewModel.useAutoTranscodingSettings) {
PresetPicker(
viewModel.transcodingPreset,
viewModel::updateTranscodingPreset,
modifier = Modifier.padding(vertical = 16.dp)
)
} else {
CustomSettings(
selectedResolution = viewModel.videoResolution,
onResolutionSelected = { viewModel.videoResolution = it },
useHevc = viewModel.useHevc,
onUseHevcSettingChanged = { viewModel.useHevc = it },
fastStartChecked = viewModel.enableFastStart,
onFastStartSettingCheckChanged = { viewModel.enableFastStart = it },
audioRemuxChecked = viewModel.enableAudioRemux,
onAudioRemuxCheckChanged = { viewModel.enableAudioRemux = it },
videoSliderPosition = viewModel.videoMegaBitrate,
updateVideoSliderPosition = { viewModel.videoMegaBitrate = it },
audioSliderPosition = viewModel.audioKiloBitrate,
updateAudioSliderPosition = { viewModel.audioKiloBitrate = it.roundToInt() },
hevcCapable = hevcCapable,
modifier = Modifier.padding(vertical = 16.dp)
)
}
LabeledButton(
buttonLabel = "Transcode",
onClick = {
viewModel.transcode()
viewModel.selectedVideos = emptyList()
viewModel.resetOutputDirectory()
},
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
@Composable
private fun PresetPicker(
selectedTranscodingPreset: TranscodingPreset,
onPresetSelected: (TranscodingPreset) -> Unit,
modifier: Modifier = Modifier
) {
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = modifier
.fillMaxWidth()
.selectableGroup()
) {
TranscodingPreset.entries.forEach {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.selectable(
selected = selectedTranscodingPreset == it,
onClick = {
onPresetSelected(it)
},
role = Role.RadioButton
)
) {
RadioButton(
selected = selectedTranscodingPreset == it,
onClick = null,
modifier = Modifier.semantics { contentDescription = it.name }
)
Text(
text = it.name,
textAlign = TextAlign.Center
)
}
}
}
}
@Composable
private fun CustomSettings(
selectedResolution: VideoResolution,
onResolutionSelected: (VideoResolution) -> Unit,
useHevc: Boolean,
onUseHevcSettingChanged: (Boolean) -> Unit,
fastStartChecked: Boolean,
onFastStartSettingCheckChanged: (Boolean) -> Unit,
audioRemuxChecked: Boolean,
onAudioRemuxCheckChanged: (Boolean) -> Unit,
videoSliderPosition: Float,
updateVideoSliderPosition: (Float) -> Unit,
audioSliderPosition: Int,
updateAudioSliderPosition: (Float) -> Unit,
hevcCapable: Boolean,
modifier: Modifier = Modifier
) {
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = modifier
.fillMaxWidth()
.selectableGroup()
) {
VideoResolution.entries.forEach {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.selectable(
selected = selectedResolution == it,
onClick = { onResolutionSelected(it) },
role = Role.RadioButton
)
.padding(start = 16.dp)
) {
RadioButton(
selected = selectedResolution == it,
onClick = null,
modifier = Modifier.semantics { contentDescription = it.getContentDescription() }
)
Text(
text = "${it.shortEdge}p",
textAlign = TextAlign.Center
)
}
}
}
VideoBitrateSlider(videoSliderPosition, updateVideoSliderPosition)
AudioBitrateSlider(audioSliderPosition, updateAudioSliderPosition)
if (hevcCapable) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = 8.dp, horizontal = 8.dp)
.fillMaxWidth()
) {
Checkbox(
checked = useHevc,
onCheckedChange = { onUseHevcSettingChanged(it) }
)
Text(text = "Use HEVC encoder", style = MaterialTheme.typography.bodySmall)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = 8.dp, horizontal = 8.dp)
.fillMaxWidth()
) {
Checkbox(
checked = audioRemuxChecked,
onCheckedChange = { onAudioRemuxCheckChanged(it) }
)
Text(text = "Allow audio remuxing", style = MaterialTheme.typography.bodySmall)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = 8.dp, horizontal = 8.dp)
.fillMaxWidth()
) {
Checkbox(
checked = fastStartChecked,
onCheckedChange = { onFastStartSettingCheckChanged(it) }
)
Text(text = "Perform Mp4San Postprocessing", style = MaterialTheme.typography.bodySmall)
}
}
@Composable
private fun VideoBitrateSlider(
videoSliderPosition: Float,
updateSliderPosition: (Float) -> Unit,
modifier: Modifier = Modifier
) {
Slider(
value = videoSliderPosition,
onValueChange = updateSliderPosition,
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.secondary,
activeTrackColor = MaterialTheme.colorScheme.secondary,
inactiveTrackColor = MaterialTheme.colorScheme.secondaryContainer
),
valueRange = MIN_VIDEO_MEGABITRATE..MAX_VIDEO_MEGABITRATE,
modifier = modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp)
)
Text(text = String.format("Video: %.2f Mbit/s", videoSliderPosition))
}
@Composable
private fun AudioBitrateSlider(
audioSliderPosition: Int,
updateSliderPosition: (Float) -> Unit,
modifier: Modifier = Modifier
) {
val minValue = OPTIONS_AUDIO_KILOBITRATES.first().toFloat()
val maxValue = OPTIONS_AUDIO_KILOBITRATES.last().toFloat()
val steps = OPTIONS_AUDIO_KILOBITRATES.size - 2
Slider(
value = audioSliderPosition.toFloat(),
onValueChange = updateSliderPosition,
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.secondary,
activeTrackColor = MaterialTheme.colorScheme.secondary,
inactiveTrackColor = MaterialTheme.colorScheme.secondaryContainer
),
valueRange = minValue..maxValue,
steps = steps,
modifier = modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp)
)
Text(text = String.format("Audio: %d Kbit/s", audioSliderPosition))
}
@Preview(showBackground = true)
@Composable
private fun ConfigurationScreenPreviewChecked() {
val vm: TranscodeTestViewModel = viewModel()
vm.selectedVideos = listOf(Uri.parse("content://1"), Uri.parse("content://2"))
vm.forceSequentialQueueProcessing = true
ConfigureEncodingParameters()
}
@Preview(showBackground = true)
@Composable
private fun ConfigurationScreenPreviewUnchecked() {
val vm: TranscodeTestViewModel = viewModel()
vm.selectedVideos = listOf(Uri.parse("content://1"), Uri.parse("content://2"))
vm.useAutoTranscodingSettings = false
ConfigureEncodingParameters(hevcCapable = true)
}

View file

@ -0,0 +1,33 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.transcode.composables
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import org.thoughtcrime.video.app.ui.composables.LabeledButton
/**
* A view that prompts you to select input videos for transcoding.
*/
@Composable
fun SelectInput(modifier: Modifier = Modifier, onClick: () -> Unit) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
LabeledButton("Select Videos", onClick = onClick, modifier = modifier)
}
}
@Preview
@Composable
private fun InputSelectionPreview() {
SelectInput { }
}

View file

@ -0,0 +1,33 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.transcode.composables
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import org.thoughtcrime.video.app.ui.composables.LabeledButton
/**
* A view that prompts you to select an output directory that transcoded videos will be saved to.
*/
@Composable
fun SelectOutput(modifier: Modifier = Modifier, onClick: () -> Unit) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
LabeledButton("Select Output Directory", onClick = onClick, modifier = modifier)
}
}
@Preview
@Composable
private fun OutputSelectionPreview() {
SelectOutput { }
}

View file

@ -0,0 +1,78 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.transcode.composables
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.work.WorkInfo
import org.thoughtcrime.video.app.transcode.TranscodeWorker
import org.thoughtcrime.video.app.ui.composables.LabeledButton
/**
* A view that shows the current encodes in progress.
*/
@Composable
fun TranscodingJobProgress(transcodingJobs: List<WorkState>, resetButtonOnClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
transcodingJobs.forEach { workInfo ->
val currentProgress = workInfo.progress
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.padding(horizontal = 16.dp)
) {
val progressIndicatorModifier = Modifier.weight(3f)
Text(
text = "Job ${workInfo.id.takeLast(4)}",
modifier = Modifier
.padding(end = 16.dp)
.weight(1f)
)
if (workInfo.state.isFinished) {
Text(text = workInfo.state.toString(), textAlign = TextAlign.Center, modifier = progressIndicatorModifier)
} else if (currentProgress >= 0) {
LinearProgressIndicator(progress = currentProgress / 100f, modifier = progressIndicatorModifier)
} else {
LinearProgressIndicator(modifier = progressIndicatorModifier)
}
}
}
LabeledButton("Reset/Cancel", onClick = 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(
listOf(
WorkState("abcde", WorkInfo.State.RUNNING, 47),
WorkState("fghij", WorkInfo.State.ENQUEUED, -1),
WorkState("klmnop", WorkInfo.State.FAILED, -1)
),
resetButtonOnClick = {}
)
}

View file

@ -0,0 +1,18 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.ui.composables
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun LabeledButton(buttonLabel: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
Button(onClick = onClick, modifier = modifier) {
Text(buttonLabel)
}
}

View file

@ -0,0 +1,11 @@
package org.thoughtcrime.video.app.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View file

@ -0,0 +1,75 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun SignalTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View file

@ -0,0 +1,39 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<group android:scaleX="0.92"
android:scaleY="0.92"
android:translateX="0.96"
android:translateY="0.96">
<path
android:pathData="M17.6,11.48 L19.44,8.3a0.63,0.63 0,0 0,-1.09 -0.63l-1.88,3.24a11.43,11.43 0,0 0,-8.94 0L5.65,7.67a0.63,0.63 0,0 0,-1.09 0.63L6.4,11.48A10.81,10.81 0,0 0,1 20L23,20A10.81,10.81 0,0 0,17.6 11.48ZM7,17.25A1.25,1.25 0,1 1,8.25 16,1.25 1.25,0 0,1 7,17.25ZM17,17.25A1.25,1.25 0,1 1,18.25 16,1.25 1.25,0 0,1 17,17.25Z"
android:fillColor="#FF000000"/>
</group>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

View file

@ -0,0 +1,35 @@
<!--
~ Copyright 2023 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 B

View file

@ -0,0 +1,175 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2023 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2023 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2023 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2023 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -0,0 +1,15 @@
<!--
~ Copyright 2023 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<resources>
<string name="app_name">Video Framework Tester</string>
<string name="notification_channel_id">transcode-progress</string>
<string name="notification_title">Encoding video…</string>
<string name="cancel_transcode">Cancel</string>
<string name="channel_name">Transcoding progress updates.</string>
<string name="channel_description">Persistent notifications that allow the transcode job to complete when the app is in the background.</string>
<string name="preference_file_key">settings</string>
<string name="preference_activity_shortcut_key">activity_shortcut</string>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2023 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<resources>
<style name="Theme.Signal" parent="Theme.AppCompat.DayNight" />
</resources>

View file

@ -0,0 +1,21 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}