Allow recording in-app videos encrypted to disk.

This commit is contained in:
Greyson Parrelli
2026-07-30 12:05:46 -04:00
committed by Alex Hart
parent b812bf3e5f
commit 607115b49e
31 changed files with 1007 additions and 119 deletions
@@ -6,10 +6,15 @@
package org.thoughtcrime.securesms.dependencies
import org.signal.camera.CameraDependencies
import org.thoughtcrime.securesms.mms.TranscodingConfigProvider
import org.thoughtcrime.securesms.stories.Stories
object CameraDependenciesProvider : CameraDependencies.Provider {
override fun isStoriesFeatureEnabled(): Boolean {
return Stories.isFeatureEnabled()
}
override fun getMaxVideoBitrateBps(): Int {
return TranscodingConfigProvider.getMaxVideoBitrateBps()
}
}
@@ -28,7 +28,7 @@ import org.signal.core.util.contentproviders.BlobProvider;
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment;
import org.thoughtcrime.securesms.util.MediaUtil;
import java.io.FileDescriptor;
import org.signal.core.util.SeekableFileDescriptor;
import java.util.Collections;
public class AvatarSelectionActivity extends AppCompatActivity implements CameraFragment.Controller, ImageEditorFragment.Controller, MediaGalleryFragment.Callbacks {
@@ -98,7 +98,7 @@ public class AvatarSelectionActivity extends AppCompatActivity implements Camera
}
@Override
public void onVideoCaptured(@NonNull FileDescriptor fd) {
public void onVideoCaptured(@NonNull SeekableFileDescriptor fd, long durationMs) {
throw new UnsupportedOperationException("Cannot set profile as video");
}
@@ -43,8 +43,10 @@ import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.livedata.Store
import java.util.Collections
import kotlin.math.max
import kotlin.time.Duration
import kotlin.time.Duration.Companion.microseconds
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* ViewModel which maintains the list of selected media and other shared values.
@@ -335,6 +337,22 @@ class MediaSelectionViewModel(
return PushMediaConstraints(null)
}
/**
* A recording is assumed to be wanted in its entirety, so if it is longer than high quality allows we fall back to
* standard quality rather than have the editor truncate it to fit.
*/
fun onVideoRecorded(duration: Duration) {
if (store.state.quality != SentMediaQuality.HIGH) {
return
}
val maxDuration = TranscodingConfigProvider.getMaxVideoDurationSeconds(SentMediaQuality.HIGH, duration).seconds
if (duration > maxDuration) {
Log.i(TAG, "Recording of $duration exceeds the $maxDuration allowed at high quality. Falling back to standard quality.")
setSentMediaQuality(SentMediaQuality.STANDARD)
}
}
fun setSentMediaQuality(sentMediaQuality: SentMediaQuality) {
if (sentMediaQuality == store.state.quality) {
return
@@ -9,6 +9,7 @@ import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.ui.permissions.Permissions
import org.signal.core.util.SeekableFileDescriptor
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.signal.mediasend.CameraFragment
@@ -23,8 +24,8 @@ import org.thoughtcrime.securesms.registration.olddevice.QuickTransferOldDeviceA
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.io.FileDescriptor
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.milliseconds
private val TAG = Log.tag(MediaCaptureFragment::class.java)
@@ -144,7 +145,8 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
viewModel.onImageCaptured(data, width, height)
}
override fun onVideoCaptured(fd: FileDescriptor) {
override fun onVideoCaptured(fd: SeekableFileDescriptor, durationMs: Long) {
sharedViewModel.onVideoRecorded(durationMs.milliseconds)
viewModel.onVideoCaptured(fd)
}
@@ -4,11 +4,12 @@ import android.content.Context
import android.net.Uri
import org.signal.core.models.media.Media
import org.signal.core.util.ContentTypeUtil
import org.signal.core.util.SeekableFileDescriptor
import org.signal.core.util.closeQuietly
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.contentproviders.BlobProvider
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants
import java.io.FileDescriptor
import java.io.FileInputStream
import java.io.IOException
@@ -35,10 +36,11 @@ class MediaCaptureRepository(context: Context) {
}
}
fun renderVideoToMedia(fileDescriptor: FileDescriptor, onMediaRendered: (Media) -> Unit, onFailedToRender: () -> Unit) {
/** Takes ownership of [fileDescriptor], closing it once the recording has been copied. */
fun renderVideoToMedia(fileDescriptor: SeekableFileDescriptor, onMediaRendered: (Media) -> Unit, onFailedToRender: () -> Unit) {
SignalExecutors.BOUNDED.execute {
val media: Media? = renderCaptureToMedia(
dataSupplier = { FileInputStream(fileDescriptor) },
dataSupplier = { FileInputStream(fileDescriptor.fileDescriptor) },
getLength = { it.channel.size() },
createBlobBuilder = BlobProvider::forData,
mimeType = VideoConstants.RECORDED_VIDEO_CONTENT_TYPE,
@@ -46,6 +48,8 @@ class MediaCaptureRepository(context: Context) {
height = 0
)
fileDescriptor.closeQuietly()
if (media != null) {
onMediaRendered(media)
} else {
@@ -10,12 +10,12 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.core.models.media.Media
import org.signal.core.util.SeekableFileDescriptor
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.data.QuickRegistrationRepository
import java.io.FileDescriptor
import java.util.concurrent.TimeUnit
class MediaCaptureViewModel(private val repository: MediaCaptureRepository) : ViewModel() {
@@ -80,7 +80,7 @@ class MediaCaptureViewModel(private val repository: MediaCaptureRepository) : Vi
repository.renderImageToMedia(data, width, height, this::onMediaRendered, this::onMediaRenderFailed)
}
fun onVideoCaptured(fd: FileDescriptor) {
fun onVideoCaptured(fd: SeekableFileDescriptor) {
repository.renderVideoToMedia(fd, this::onMediaRendered, this::onMediaRenderFailed)
}
@@ -53,6 +53,7 @@ import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionRepository
import org.thoughtcrime.securesms.mediasend.v2.MediaValidator
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.mms.PushMediaConstraints
import org.thoughtcrime.securesms.mms.TranscodingConfigProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
@@ -237,6 +238,10 @@ object MediaSendV3Repository : MediaSendRepository {
return TranscodingConfig.calculateMaxVideoUploadDurationInSeconds(config, duration).seconds.inWholeMicroseconds
}
override fun getMaxVideoRecordDurationSeconds(): Int {
return TranscodingConfigProvider.getMaxVideoDurationSeconds()
}
override fun isVideoTranscodeAvailable(): Boolean {
return MediaConstraints.isVideoTranscodeAvailable()
}
@@ -5,6 +5,8 @@ import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.video.TranscodingConfig
import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants
import kotlin.time.Duration
/**
* Gets corresponding configs depending on locale and sent media quality
@@ -16,6 +18,34 @@ object TranscodingConfigProvider {
return TranscodingConfig.getTranscodeConfig(RemoteConfig.transcodeConfig, countryCode)
}
/**
* The longest video duration allowed by any quality tier in the current config.
*/
@JvmStatic
fun getMaxVideoDurationSeconds(): Int {
val config = getAllConfigs()
return (config.standard + config.high).maxOfOrNull { it.maxDurationSec } ?: VideoConstants.DEFAULT_HIGH.maxDurationSec
}
/**
* The highest video bitrate, in bits per second, targeted by any quality tier in the current config.
*/
@JvmStatic
fun getMaxVideoBitrateBps(): Int {
val config = getAllConfigs()
val maxMbps = (config.standard + config.high).maxOfOrNull { it.videoBitrateMbps } ?: VideoConstants.DEFAULT_HIGH.videoBitrateMbps
return (maxMbps * VideoConstants.MB).toInt()
}
/**
* The longest duration, in seconds, that a video of [duration] may be sent at when using [quality]. Anything
* longer is truncated to fit.
*/
@JvmStatic
fun getMaxVideoDurationSeconds(quality: SentMediaQuality, duration: Duration): Int {
return TranscodingConfig.calculateMaxVideoUploadDurationInSeconds(getConfigsForMediaQuality(quality), duration)
}
@JvmStatic
fun getConfigsForMediaQuality(quality: SentMediaQuality): List<TranscodingConfig.QualityTier> {
val config = getAllConfigs()
@@ -7,6 +7,7 @@ import org.json.JSONException
import org.json.JSONObject
import org.signal.core.util.ByteSize
import org.signal.core.util.bytes
import org.signal.core.util.gibiBytes
import org.signal.core.util.kibiBytes
import org.signal.core.util.logging.Log
import org.signal.core.util.mebiBytes
@@ -979,7 +980,7 @@ object RemoteConfig {
@get:JvmName("maxSourceTranscodeVideoSizeBytes")
val maxSourceTranscodeVideoSizeBytes: Long by remoteLong(
key = "android.media.sourceTranscodeVideo.maxBytes",
defaultValue = 500L.mebiBytes.inWholeBytes,
defaultValue = 1.gibiBytes.inWholeBytes,
hotSwappable = true
)
@@ -0,0 +1,14 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util
import java.io.Closeable
import java.io.IOException
/** Closes this, logging and swallowing any [IOException]. */
fun Closeable.closeQuietly() {
StreamUtil.close(this)
}
@@ -0,0 +1,365 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util
import android.content.Context
import android.os.Handler
import android.os.HandlerThread
import android.os.ParcelFileDescriptor
import android.os.ProxyFileDescriptorCallback
import android.os.storage.StorageManager
import android.system.ErrnoException
import android.system.Os
import android.system.OsConstants
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import org.signal.core.util.logging.Log
import java.io.File
import java.io.FileDescriptor
import java.io.IOException
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
import java.security.GeneralSecurityException
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* A seekable [ParcelFileDescriptor] whose contents are transparently encrypted before being
* written to a backing file on disk, via [StorageManager.openProxyFileDescriptor].
*
* Uses the same encryption scheme as attachments.
*
* It's possible a device may not support this -- check [isSupported] first.
*/
@RequiresApi(26)
class EncryptedProxyFileDescriptor private constructor(
private val parcelFileDescriptor: ParcelFileDescriptor,
@get:VisibleForTesting
internal val backingFile: File
) : SeekableFileDescriptor {
companion object {
private val TAG = Log.tag(EncryptedProxyFileDescriptor::class.java)
private const val DIRECTORY = "encrypted-proxy-fd"
private const val SELF_TEST_DEBUG_NAME = "self-test"
private const val SELF_TEST_SIZE = 4096
private const val KEY_SIZE = 32
private const val BLOCK_SIZE = 16
private val activeFiles: MutableSet<String> = mutableSetOf()
@Volatile
private var cachedSelfTestResult: Boolean? = null
/**
* Whether proxy file descriptors work on this device, verified by an end-to-end self-test.
* The result is cached for the lifetime of the process.
*
* The self-test touches the filesystem, so the first call must not happen on the main thread.
*/
@JvmStatic
@WorkerThread
fun isSupported(context: Context): Boolean {
cachedSelfTestResult?.let { return it }
val result = try {
selfTest(context)
} catch (t: Throwable) {
Log.w(TAG, "Self-test failed.", t)
false
}
Log.i(TAG, "Self-test result: $result")
cachedSelfTestResult = result
return result
}
/**
* Creates a new descriptor whose backing file lives in the app's cache directory, or null if one
* could not be opened.
*
* @param debugName Used to name the backing file. Contents are encrypted regardless.
*/
@JvmStatic
fun create(context: Context, debugName: String): EncryptedProxyFileDescriptor? {
val storageManager = context.getSystemService(StorageManager::class.java)
if (storageManager == null) {
Log.w(TAG, "StorageManager is unavailable.")
return null
}
val backingFile: File
val channel: FileChannel
try {
val directory = File(context.cacheDir, DIRECTORY)
directory.mkdirs()
deleteStaleFiles(directory)
backingFile = createBackingFile(debugName, directory)
channel = RandomAccessFile(backingFile, "rw").channel
} catch (e: IOException) {
Log.w(TAG, "Failed to create backing file.", e)
return null
}
val key = ByteArray(KEY_SIZE).also { SecureRandom().nextBytes(it) }
val thread = HandlerThread("EncryptedProxyFd")
thread.start()
val callback = Callback(channel, key) {
if (!backingFile.delete()) {
Log.w(TAG, "Failed to delete backing file on release.")
}
synchronized(activeFiles) {
activeFiles -= backingFile.name
}
thread.quitSafely()
}
return try {
val parcelFileDescriptor = storageManager.openProxyFileDescriptor(
ParcelFileDescriptor.MODE_READ_WRITE,
callback,
Handler(thread.looper)
)
EncryptedProxyFileDescriptor(parcelFileDescriptor, backingFile)
} catch (e: Exception) {
Log.w(TAG, "Failed to open proxy file descriptor.", e)
callback.onRelease()
null
}
}
/**
* Creating the file and registering it must be atomic with respect to [deleteStaleFiles], which
* would otherwise be free to delete a backing file created by a concurrent [create].
*/
@Throws(IOException::class)
private fun createBackingFile(debugName: String, directory: File): File {
return synchronized(activeFiles) {
File.createTempFile(debugName, ".enc", directory).also { activeFiles += it.name }
}
}
private fun deleteStaleFiles(directory: File) {
synchronized(activeFiles) {
val stale = directory.listFiles()?.filter { it.name !in activeFiles } ?: emptyList()
for (file in stale) {
if (!file.delete()) {
Log.w(TAG, "Failed to delete stale file.")
}
}
}
}
/**
* Verifies that data written through a proxy descriptor at various offsets reads back
* intact, and that only ciphertext lands in the backing file.
*/
private fun selfTest(context: Context): Boolean {
val descriptor = create(context, SELF_TEST_DEBUG_NAME)
if (descriptor == null) {
Log.w(TAG, "Self-test: failed to create descriptor.")
return false
}
return descriptor.use { proxy ->
val random = SecureRandom()
val expected = ByteArray(SELF_TEST_SIZE).also { random.nextBytes(it) }
if (!pwriteFully(proxy.fileDescriptor, expected, 0, expected.size, 0)) {
Log.w(TAG, "Self-test: short write.")
return@use false
}
val overwrite = ByteArray(100).also { random.nextBytes(it) }
System.arraycopy(overwrite, 0, expected, 1000, overwrite.size)
if (!pwriteFully(proxy.fileDescriptor, overwrite, 0, overwrite.size, 1000)) {
Log.w(TAG, "Self-test: short overwrite.")
return@use false
}
if (Os.fstat(proxy.fileDescriptor).st_size != expected.size.toLong()) {
Log.w(TAG, "Self-test: unexpected file size.")
return@use false
}
val actual = ByteArray(expected.size)
if (!preadFully(proxy.fileDescriptor, actual, 0, actual.size, 0)) {
Log.w(TAG, "Self-test: short read.")
return@use false
}
if (!expected.contentEquals(actual)) {
Log.w(TAG, "Self-test: data mismatch.")
return@use false
}
val onDisk = proxy.backingFile.readBytes()
if (onDisk.size != expected.size || onDisk.contentEquals(expected)) {
Log.w(TAG, "Self-test: backing file does not look encrypted.")
return@use false
}
true
}
}
private fun pwriteFully(fd: FileDescriptor, data: ByteArray, byteOffset: Int, byteCount: Int, fileOffset: Long): Boolean {
var written = 0
while (written < byteCount) {
val result = Os.pwrite(fd, data, byteOffset + written, byteCount - written, fileOffset + written)
if (result <= 0) {
return false
}
written += result
}
return true
}
private fun preadFully(fd: FileDescriptor, data: ByteArray, byteOffset: Int, byteCount: Int, fileOffset: Long): Boolean {
var read = 0
while (read < byteCount) {
val result = Os.pread(fd, data, byteOffset + read, byteCount - read, fileOffset + read)
if (result <= 0) {
return false
}
read += result
}
return true
}
/**
* Encrypts or decrypts [length] bytes of [input] (starting at index 0) into [output] (also starting at
* index 0), where those bytes sit at byte [offset] of the file. In AES/CTR both directions are the same
* operation, so this one function serves reads and writes, and any byte range can be processed on its own
* as long as the offset it came from is provided.
*
* Uses the same zero-IV counter layout as ModernDecryptingPartInputStream: the block counter is written
* as a 4-byte big-endian value at the end of the IV, which supports offsets up to 64 GiB.
*/
@VisibleForTesting
internal fun encryptOrDecrypt(key: ByteArray, offset: Long, input: ByteArray, length: Int, output: ByteArray) {
require(offset >= 0) { "Offset must be non-negative, but was $offset." }
require(length <= input.size) { "Requested $length bytes from an input of ${input.size}." }
require(length <= output.size) { "Requested $length bytes into an output of ${output.size}." }
try {
val iv = ByteArray(BLOCK_SIZE)
val remainder = (offset % BLOCK_SIZE).toInt()
Conversions.longTo4ByteArray(iv, 12, offset / BLOCK_SIZE)
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv))
if (remainder > 0) {
cipher.update(ByteArray(remainder))
}
cipher.update(input, 0, length, output, 0)
} catch (e: GeneralSecurityException) {
throw AssertionError(e)
}
}
}
override val fileDescriptor: FileDescriptor
get() = parcelFileDescriptor.fileDescriptor
override val parcelFd: ParcelFileDescriptor
get() = parcelFileDescriptor
override fun close() {
parcelFileDescriptor.close()
}
@VisibleForTesting
internal class Callback(
private val channel: FileChannel,
private val key: ByteArray,
private val onReleased: () -> Unit
) : ProxyFileDescriptorCallback() {
@Throws(ErrnoException::class)
override fun onGetSize(): Long {
try {
return channel.size()
} catch (e: IOException) {
throw ErrnoException("onGetSize", OsConstants.EIO, e)
}
}
@Throws(ErrnoException::class)
override fun onRead(offset: Long, size: Int, data: ByteArray): Int {
try {
val ciphertext = ByteArray(size)
val buffer = ByteBuffer.wrap(ciphertext)
var totalRead = 0
while (totalRead < size) {
val read = channel.read(buffer, offset + totalRead)
if (read < 0) {
break
}
totalRead += read
}
if (totalRead > 0) {
encryptOrDecrypt(key, offset, ciphertext, totalRead, data)
}
return totalRead
} catch (e: IOException) {
throw ErrnoException("onRead", OsConstants.EIO, e)
}
}
@Throws(ErrnoException::class)
override fun onWrite(offset: Long, size: Int, data: ByteArray): Int {
try {
val ciphertext = ByteArray(size)
encryptOrDecrypt(key, offset, data, size, ciphertext)
val buffer = ByteBuffer.wrap(ciphertext)
while (buffer.hasRemaining()) {
channel.write(buffer, offset + buffer.position())
}
return size
} catch (e: IOException) {
throw ErrnoException("onWrite", OsConstants.EIO, e)
}
}
@Throws(ErrnoException::class)
override fun onFsync() {
try {
channel.force(true)
} catch (e: IOException) {
throw ErrnoException("onFsync", OsConstants.EIO, e)
}
}
override fun onRelease() {
key.fill(0)
try {
channel.close()
} catch (e: IOException) {
Log.w(TAG, "Failed to close backing channel", e)
}
onReleased()
}
}
}
@@ -5,7 +5,6 @@ import android.content.Context
import android.os.ParcelFileDescriptor
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.util.FileUtils
import java.io.Closeable
import java.io.FileDescriptor
import java.io.FileInputStream
import java.io.FileOutputStream
@@ -17,12 +16,12 @@ import java.util.concurrent.atomic.AtomicLong
class MemoryFileDescriptor private constructor(
private val parcelFileDescriptor: ParcelFileDescriptor,
private val sizeEstimate: AtomicLong
) : Closeable {
) : SeekableFileDescriptor {
val fileDescriptor: FileDescriptor
override val fileDescriptor: FileDescriptor
get() = parcelFileDescriptor.fileDescriptor
val parcelFd: ParcelFileDescriptor
override val parcelFd: ParcelFileDescriptor
get() = parcelFileDescriptor
@Throws(IOException::class)
@@ -0,0 +1,20 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util
import android.os.ParcelFileDescriptor
import java.io.Closeable
import java.io.FileDescriptor
/**
* A closeable handle to a real, seekable file descriptor that can be handed to platform components
* requiring one (e.g. [android.media.MediaMuxer]) without unencrypted bytes reaching persistent
* storage. Implementations differ only in where those bytes actually live.
*/
interface SeekableFileDescriptor : Closeable {
val fileDescriptor: FileDescriptor
val parcelFd: ParcelFileDescriptor
}
@@ -0,0 +1,136 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util
import android.app.Application
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
import java.io.RandomAccessFile
import java.nio.channels.FileChannel
import java.util.Random
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class EncryptedProxyFileDescriptorCallbackTest {
private val random = Random(5678)
private val key = ByteArray(32).also { random.nextBytes(it) }
private lateinit var backingFile: File
private lateinit var channel: FileChannel
private lateinit var callback: EncryptedProxyFileDescriptor.Callback
private var released = false
@Before
fun setUp() {
backingFile = File.createTempFile("callback-test", ".enc")
channel = RandomAccessFile(backingFile, "rw").channel
callback = EncryptedProxyFileDescriptor.Callback(channel, key) { released = true }
}
@After
fun tearDown() {
backingFile.delete()
}
@Test
fun `sequential write then read roundtrips`() {
val data = ByteArray(100_000).also { random.nextBytes(it) }
var written = 0
while (written < data.size) {
val chunk = minOf(4096, data.size - written)
assertEquals(chunk, callback.onWrite(written.toLong(), chunk, data.copyOfRange(written, written + chunk)))
written += chunk
}
assertEquals(data.size.toLong(), callback.onGetSize())
val readBack = ByteArray(data.size)
var read = 0
while (read < data.size) {
val buffer = ByteArray(8192)
val result = callback.onRead(read.toLong(), buffer.size, buffer)
assertTrue(result > 0)
System.arraycopy(buffer, 0, readBack, read, result)
read += result
}
assertArrayEquals(data, readBack)
}
@Test
fun `seek back and overwrite like an mp4 muxer`() {
val body = ByteArray(50_000).also { random.nextBytes(it) }
callback.onWrite(0, body.size, body)
val patchedHeader = ByteArray(16).also { random.nextBytes(it) }
callback.onWrite(8, patchedHeader.size, patchedHeader)
System.arraycopy(patchedHeader, 0, body, 8, patchedHeader.size)
val readBack = ByteArray(body.size)
assertEquals(body.size, callback.onRead(0, body.size, readBack))
assertArrayEquals(body, readBack)
}
@Test
fun `read at end of file returns zero`() {
val data = ByteArray(1000).also { random.nextBytes(it) }
callback.onWrite(0, data.size, data)
val buffer = ByteArray(100)
assertEquals(0, callback.onRead(1000, buffer.size, buffer))
}
@Test
fun `read straddling end of file returns partial data`() {
val data = ByteArray(1000).also { random.nextBytes(it) }
callback.onWrite(0, data.size, data)
val buffer = ByteArray(100)
assertEquals(50, callback.onRead(950, buffer.size, buffer))
assertArrayEquals(data.copyOfRange(950, 1000), buffer.copyOfRange(0, 50))
}
@Test
fun `backing file contains only ciphertext`() {
val data = ByteArray(10_000).also { random.nextBytes(it) }
callback.onWrite(0, data.size, data)
val onDisk = backingFile.readBytes()
assertEquals(data.size, onDisk.size)
assertFalse(onDisk.contentEquals(data))
val window = data.copyOfRange(0, 64)
for (i in 0..onDisk.size - window.size) {
if (onDisk.copyOfRange(i, i + window.size).contentEquals(window)) {
throw AssertionError("Found plaintext window in backing file at offset $i")
}
}
}
@Test
fun `release zeroes key closes channel and notifies`() {
val data = ByteArray(100).also { random.nextBytes(it) }
callback.onWrite(0, data.size, data)
callback.onRelease()
assertTrue(released)
assertFalse(channel.isOpen)
assertTrue(key.all { it == 0.toByte() })
}
}
@@ -0,0 +1,106 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
import java.util.Random
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
class EncryptedProxyFileDescriptorCryptoTest {
private val random = Random(1234)
private val key = ByteArray(32).also { random.nextBytes(it) }
private fun referenceEncrypt(plaintext: ByteArray): ByteArray {
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(ByteArray(16)))
return cipher.doFinal(plaintext)
}
@Test
fun `whole buffer at offset zero matches streaming cipher`() {
val plaintext = ByteArray(10_000).also { random.nextBytes(it) }
val output = ByteArray(plaintext.size)
EncryptedProxyFileDescriptor.encryptOrDecrypt(key, 0, plaintext, plaintext.size, output)
assertArrayEquals(referenceEncrypt(plaintext), output)
}
@Test
fun `chunks at arbitrary offsets match streaming cipher`() {
val plaintext = ByteArray(100_000).also { random.nextBytes(it) }
val expected = referenceEncrypt(plaintext)
val actual = ByteArray(plaintext.size)
val offsets = (0 until plaintext.size).shuffled(random).take(50).sorted() + plaintext.size
var start = 0
val chunks = offsets.map { end -> (start until end).also { start = end } }.filter { !it.isEmpty() }.shuffled(random)
for (range in chunks) {
val length = range.last - range.first + 1
val chunkOutput = ByteArray(length)
EncryptedProxyFileDescriptor.encryptOrDecrypt(key, range.first.toLong(), plaintext.copyOfRange(range.first, range.last + 1), length, chunkOutput)
System.arraycopy(chunkOutput, 0, actual, range.first, length)
}
assertArrayEquals(expected, actual)
}
@Test
fun `roundtrip with overwrites decrypts to final plaintext`() {
val size = 50_000
val ciphertext = ByteArray(size)
val plaintext = ByteArray(size)
EncryptedProxyFileDescriptor.encryptOrDecrypt(key, 0, plaintext, size, ciphertext)
repeat(200) {
val offset = random.nextInt(size - 1)
val length = 1 + random.nextInt(size - offset)
val data = ByteArray(length).also { random.nextBytes(it) }
val encrypted = ByteArray(length)
EncryptedProxyFileDescriptor.encryptOrDecrypt(key, offset.toLong(), data, length, encrypted)
System.arraycopy(data, 0, plaintext, offset, length)
System.arraycopy(encrypted, 0, ciphertext, offset, length)
}
val decrypted = ByteArray(size)
EncryptedProxyFileDescriptor.encryptOrDecrypt(key, 0, ciphertext, size, decrypted)
assertArrayEquals(plaintext, decrypted)
}
@Test
fun `partial length only processes requested bytes`() {
val input = ByteArray(64).also { random.nextBytes(it) }
val output = ByteArray(64)
EncryptedProxyFileDescriptor.encryptOrDecrypt(key, 32, input, 16, output)
assertArrayEquals(referenceEncrypt(ByteArray(32) + input.copyOfRange(0, 16)).copyOfRange(32, 48), output.copyOfRange(0, 16))
assertArrayEquals(ByteArray(48), output.copyOfRange(16, 64))
}
@Test
fun `ciphertext does not contain plaintext`() {
val plaintext = ByteArray(4096)
val output = ByteArray(plaintext.size)
EncryptedProxyFileDescriptor.encryptOrDecrypt(key, 0, plaintext, plaintext.size, output)
assertFalse(output.all { it == 0.toByte() })
assertEquals(plaintext.size, output.size)
}
}
@@ -4,6 +4,7 @@ import android.app.Application
import android.content.Context
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import org.signal.camera.CameraDependencies
import org.signal.core.util.logging.AndroidLogger
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.mms.RegisterGlideComponents
@@ -21,5 +22,12 @@ class CameraDemoApplication : Application() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
}
}
CameraDependencies.init(
this,
object : CameraDependencies.Provider {
override fun isStoriesFeatureEnabled(): Boolean = false
}
)
}
}
@@ -11,6 +11,9 @@ import android.app.Application
* Camera Feature Module dependencies
*/
object CameraDependencies {
/** Bitrate used when the embedder has no transcoding config of its own. Matches the highest default 720p target. */
const val DEFAULT_MAX_VIDEO_BITRATE_BPS = 4_000_000
private lateinit var _application: Application
private lateinit var _provider: Provider
@@ -31,7 +34,14 @@ object CameraDependencies {
return _provider.isStoriesFeatureEnabled()
}
fun getMaxVideoBitrateBps(): Int {
return _provider.getMaxVideoBitrateBps()
}
interface Provider {
fun isStoriesFeatureEnabled(): Boolean
/** The highest video bitrate, in bits per second, that captured video may be transcoded to. */
fun getMaxVideoBitrateBps(): Int = DEFAULT_MAX_VIDEO_BITRATE_BPS
}
}
@@ -1,6 +1,7 @@
package org.signal.camera
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
@@ -29,6 +30,9 @@ import androidx.camera.core.resolutionselector.AspectRatioStrategy
import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.core.resolutionselector.ResolutionStrategy
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.FallbackStrategy
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
import androidx.camera.video.Recorder
import androidx.camera.video.Recording
import androidx.camera.video.VideoCapture
@@ -61,6 +65,7 @@ import org.signal.core.util.throttleLatest
import java.lang.ref.WeakReference
import java.util.EnumMap
import java.util.concurrent.Executors
import kotlin.time.Duration.Companion.nanoseconds
import kotlin.time.Duration.Companion.seconds
private const val TAG = "CameraScreenViewModel"
@@ -262,7 +267,7 @@ class CameraScreenViewModel : ViewModel() {
* If flash is enabled, turns on the torch for the duration of the recording.
*/
@androidx.annotation.OptIn(markerClass = [androidx.camera.core.ExperimentalGetImage::class])
@android.annotation.SuppressLint("MissingPermission", "RestrictedApi", "NewApi")
@SuppressLint("MissingPermission", "RestrictedApi", "NewApi")
fun startRecording(
context: Context,
output: VideoOutput,
@@ -317,12 +322,13 @@ class CameraScreenViewModel : ViewModel() {
val result = if (!recordEvent.hasError()) {
Log.d(TAG, "Video recording succeeded")
val durationMs = recordEvent.recordingStats.recordedDurationNanos.nanoseconds.inWholeMilliseconds
when (output) {
is VideoOutput.FileOutput -> {
VideoCaptureResult.Success(outputFile = output.file)
VideoCaptureResult.Success(outputFile = output.file, durationMs = durationMs)
}
is VideoOutput.FileDescriptorOutput -> {
VideoCaptureResult.Success(fileDescriptor = output.fileDescriptor)
VideoCaptureResult.Success(fileDescriptor = output.fileDescriptor, durationMs = durationMs)
}
}
} else {
@@ -495,16 +501,18 @@ class CameraScreenViewModel : ViewModel() {
return false
}
@android.annotation.SuppressLint("RestrictedApi")
@SuppressLint("RestrictedApi")
private fun buildVideoCapture(): VideoCapture<Recorder> {
val recorder = Recorder.Builder()
.setAspectRatio(AspectRatio.RATIO_16_9)
.setQualitySelector(
androidx.camera.video.QualitySelector.from(
androidx.camera.video.Quality.HIGHEST,
androidx.camera.video.FallbackStrategy.higherQualityOrLowerThan(androidx.camera.video.Quality.HD)
QualitySelector.from(
Quality.HD,
FallbackStrategy.lowerQualityOrHigherThan(Quality.HD)
)
)
// Recording at the highest transcoding target means no sent-media quality tier is sourced from a lower-bitrate capture.
.setTargetVideoEncodingBitRate(CameraDependencies.getMaxVideoBitrateBps())
.build()
return VideoCapture.withOutput(recorder)
}
@@ -12,6 +12,7 @@ import android.hardware.camera2.CameraManager
import android.hardware.camera2.CameraMetadata
import android.os.Build
import androidx.annotation.RequiresApi
import org.signal.core.util.EncryptedProxyFileDescriptor
import org.signal.core.util.MemoryFileDescriptor
import org.signal.core.util.logging.Log
@@ -22,10 +23,19 @@ object CameraXUtil {
private const val VIDEO_SIZE = 10L * 1024 * 1024
@Throws(MemoryFileDescriptor.MemoryFileException::class)
fun createVideoFileDescriptor(context: Context): MemoryFileDescriptor {
fun createMemoryVideoFileDescriptor(context: Context): MemoryFileDescriptor {
return MemoryFileDescriptor.newMemoryFileDescriptor(context, VIDEO_DEBUG_LABEL, VIDEO_SIZE)
}
/**
* Creates a disk-backed, transparently-encrypted video file descriptor. Callers should gate on
* [EncryptedProxyFileDescriptor.isSupported].
*/
@RequiresApi(26)
fun createEncryptedDiskVideoFileDescriptor(context: Context): EncryptedProxyFileDescriptor? {
return EncryptedProxyFileDescriptor.create(context, VIDEO_DEBUG_LABEL)
}
private val CAMERA_HARDWARE_LEVEL_ORDERING = intArrayOf(
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY,
CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED,
@@ -30,10 +30,12 @@ sealed class VideoCaptureResult {
* Video was successfully captured and saved.
* @param outputFile The file where the video was saved (for FileOutput)
* @param fileDescriptor The file descriptor used (for FileDescriptorOutput)
* @param durationMs How long the recording ran, as reported by the recorder.
*/
data class Success(
val outputFile: File? = null,
val fileDescriptor: ParcelFileDescriptor? = null
val fileDescriptor: ParcelFileDescriptor? = null,
val durationMs: Long = 0
) : VideoCaptureResult()
/**
@@ -65,6 +65,12 @@ class CameraScreenViewModelTest {
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
CameraDependencies.init(
RuntimeEnvironment.getApplication(),
object : CameraDependencies.Provider {
override fun isStoriesFeatureEnabled(): Boolean = false
}
)
viewModel = CameraScreenViewModel()
every { mockCamera.cameraControl } returns mockCameraControl
@@ -7,7 +7,7 @@ package org.signal.mediasend;
import androidx.annotation.NonNull;
import java.io.FileDescriptor;
import org.signal.core.util.SeekableFileDescriptor;
public interface CameraFragment {
@@ -17,7 +17,12 @@ public interface CameraFragment {
interface Controller {
void onImageCaptured(@NonNull byte[] data, int width, int height);
void onVideoCaptured(@NonNull FileDescriptor fd);
/**
* The descriptor is owned by the callee, which must close it once it is finished reading the recording.
*
* @param durationMs How long the recording ran, as reported by the recorder.
*/
void onVideoCaptured(@NonNull SeekableFileDescriptor fd, long durationMs);
void onVideoCaptureError();
void onGalleryClicked();
void onCameraCloseClicked();
@@ -87,6 +87,12 @@ interface MediaSendRepository {
*/
fun getMaxVideoDurationUs(quality: SentMediaQuality, duration: Duration): Long
/**
* Gets the maximum allowed duration in seconds for in-app video recording, based on the longest
* duration allowed by any transcoding quality tier.
*/
fun getMaxVideoRecordDurationSeconds(): Int
/**
* Checks if video transcoding is available on this device.
*/
@@ -358,14 +358,16 @@ class MediaSendViewModel(
viewModelScope.launch {
val media: Media? = withContext(Dispatchers.IO) {
try {
FileInputStream(videoCaptured.fd).use { stream ->
val length = stream.channel.size()
val uri = MediaSendDependencies.blobs
.forData(stream, length)
.withMimeType(VideoConstants.RECORDED_VIDEO_CONTENT_TYPE)
.createForSingleSessionOnDisk(MediaSendDependencies.application)
videoCaptured.fd.use { descriptor ->
FileInputStream(descriptor.fileDescriptor).use { stream ->
val length = stream.channel.size()
val uri = MediaSendDependencies.blobs
.forData(stream, length)
.withMimeType(VideoConstants.RECORDED_VIDEO_CONTENT_TYPE)
.createForSingleSessionOnDisk(MediaSendDependencies.application)
buildCapturedMedia(uri, VideoConstants.RECORDED_VIDEO_CONTENT_TYPE, 0, 0, length)
buildCapturedMedia(uri, VideoConstants.RECORDED_VIDEO_CONTENT_TYPE, 0, 0, length)
}
}
} catch (e: IOException) {
null
@@ -373,6 +375,7 @@ class MediaSendViewModel(
}
if (media != null) {
onVideoRecorded(videoCaptured.durationMs.milliseconds)
onMediaRendered(media)
} else {
internalSnackbarEvents.trySend(SnackbarEvent(message = R.string.MediaSendViewModel__error_recording_video))
@@ -694,6 +697,22 @@ class MediaSendViewModel(
//region Quality
/**
* A recording is assumed to be wanted in its entirety, so if it is longer than high quality allows we fall back to
* standard quality rather than have the editor truncate it to fit.
*/
private fun onVideoRecorded(duration: Duration) {
if (state.value.sentMediaQuality != SentMediaQuality.HIGH) {
return
}
val maxDuration = repository.getMaxVideoDurationUs(SentMediaQuality.HIGH, duration).microseconds
if (duration > maxDuration) {
Log.i(TAG, "Recording of $duration exceeds the $maxDuration allowed at high quality. Falling back to standard quality.")
setSentMediaQuality(SentMediaQuality.STANDARD)
}
}
/**
* Sets the sent media quality.
*
@@ -1,25 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.video.TranscodingConfig;
import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants;
public final class VideoUtil {
private VideoUtil() { }
public static int getMaxVideoRecordDurationInSeconds(@NonNull MediaConstraints mediaConstraints) {
TranscodingConfig.QualityTier config = VideoConstants.getDEFAULT_HIGH();
int maxBytes = (int) (config.getVideoBitrateMbps() * VideoConstants.MB) / 8 + (config.getAudioBitrateKbps() * VideoConstants.KB) / 8;
long allowedSize = mediaConstraints.getCompressedVideoMaxSize();
int duration = (int) Math.floor((float) allowedSize / maxBytes);
return Math.min(duration, VideoConstants.VIDEO_MAX_RECORD_LENGTH_S);
}
}
@@ -0,0 +1,36 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend
import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants
import kotlin.math.floor
import kotlin.math.min
object VideoUtil {
/** Recordings that fall back to a RAM-backed file descriptor keep the historical duration cap. */
private const val MAX_IN_MEMORY_RECORD_DURATION_SECONDS = 60
/**
* The recording cap for a RAM-backed file descriptor, bounded by how much compressed video fits in
* the memory file.
*/
fun getMemoryBackedMaxRecordDurationSeconds(mediaConstraints: MediaConstraints): Int {
val config = VideoConstants.DEFAULT_HIGH
val bytesPerSecond = (config.videoBitrateMbps * VideoConstants.MB).toInt() / 8 + (config.audioBitrateKbps * VideoConstants.KB) / 8
val duration = floor(mediaConstraints.compressedVideoMaxSize.toFloat() / bytesPerSecond).toInt()
return min(duration, MAX_IN_MEMORY_RECORD_DURATION_SECONDS)
}
/**
* The recording cap for a disk-backed file descriptor, which is bounded only by the longest
* duration the transcoder will accept.
*/
fun getDiskBackedMaxRecordDurationSeconds(): Int {
return MediaSendDependencies.mediaSendRepository.getMaxVideoRecordDurationSeconds()
}
}
@@ -13,6 +13,7 @@ import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle
import android.os.ParcelFileDescriptor
import android.system.ErrnoException
import android.system.Os
import android.system.OsConstants
import android.widget.Toast
@@ -37,7 +38,9 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
@@ -57,9 +60,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.signal.camera.CameraCaptureMode
import org.signal.camera.CameraDependencies
import org.signal.camera.CameraDisplay
@@ -82,7 +87,9 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.permissions.PermissionDeniedBottomSheet
import org.signal.core.ui.permissions.Permissions
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.core.util.MemoryFileDescriptor
import org.signal.core.util.EncryptedProxyFileDescriptor
import org.signal.core.util.SeekableFileDescriptor
import org.signal.core.util.closeQuietly
import org.signal.core.util.logging.Log
import org.signal.mediasend.CameraFragment
import org.signal.mediasend.MediaConstraints
@@ -158,7 +165,10 @@ class CameraXFragment : ComposeFragment(), CameraFragment {
CameraXScreen(
state = state,
onEvent = { event -> controller?.onCameraXScreenEvent(event) },
maxVideoDurationSeconds = controller?.let { controller -> controller.maxVideoDuration.takeIf { it > 0 } ?: getMaxVideoDurationInSeconds(controller.mediaConstraints) } ?: 0,
videoRecordingConfig = rememberVideoRecordingConfig(
mediaConstraints = controller?.mediaConstraints,
maxDurationSecondsOverride = controller?.maxVideoDuration ?: 0
),
onCheckPermissions = { checkPermissions(state.isVideoEnabled) },
hasCameraPermission = { hasCameraPermission() },
onRequestMicPermission = { requestMicPermission() }
@@ -268,8 +278,61 @@ class CameraXFragment : ComposeFragment(), CameraFragment {
}
}
internal fun getMaxVideoDurationInSeconds(mediaConstraints: MediaConstraints): Int {
return VideoUtil.getMaxVideoRecordDurationInSeconds(mediaConstraints)
/**
* How a recording is backed on this device, together with the duration caps that follow from it. They travel
* together because they have to agree: a disk-backed recording may run far longer than a RAM-backed one, so
* recording with a shorter-lived descriptor than the cap advertises would truncate the video.
*
* @param memoryBackedMaxDurationSeconds The cap that applies whenever the RAM-backed descriptor ends up being
* used, which includes the case where creating the disk-backed one fails at record time.
*/
data class VideoRecordingConfig(
val useEncryptedDisk: Boolean = false,
val maxDurationSeconds: Int = 0,
val memoryBackedMaxDurationSeconds: Int = maxDurationSeconds
)
/**
* Resolves the recording configuration for this device. Deciding whether the encrypted disk-backed
* descriptor works runs a filesystem self-test, so it happens off the main thread; the conservative
* RAM-backed configuration applies until the answer arrives.
*
* @param maxDurationSecondsOverride A positive value replaces the derived cap, leaving the backing choice intact.
*/
@Composable
internal fun rememberVideoRecordingConfig(mediaConstraints: MediaConstraints?, maxDurationSecondsOverride: Int = 0): VideoRecordingConfig {
if (mediaConstraints == null) {
return VideoRecordingConfig(maxDurationSeconds = maxDurationSecondsOverride)
}
val context = LocalContext.current
// Keyed on the derived duration rather than the MediaConstraints instance, because implementations hand
// back a fresh object on every call and would otherwise restart resolution on every recomposition.
val memoryBackedDurationSeconds = VideoUtil.getMemoryBackedMaxRecordDurationSeconds(mediaConstraints)
val memoryBackedCap = maxDurationSecondsOverride.takeIf { it > 0 } ?: memoryBackedDurationSeconds
val memoryBacked = VideoRecordingConfig(
useEncryptedDisk = false,
maxDurationSeconds = memoryBackedCap
)
return produceState(memoryBacked, memoryBackedDurationSeconds, maxDurationSecondsOverride) {
if (Build.VERSION.SDK_INT < 26) {
return@produceState
}
value = withContext(Dispatchers.IO) {
if (EncryptedProxyFileDescriptor.isSupported(context)) {
VideoRecordingConfig(
useEncryptedDisk = true,
maxDurationSeconds = maxDurationSecondsOverride.takeIf { it > 0 } ?: VideoUtil.getDiskBackedMaxRecordDurationSeconds(),
memoryBackedMaxDurationSeconds = memoryBackedCap
)
} else {
memoryBacked
}
}
}.value
}
/**
@@ -279,7 +342,7 @@ internal fun getMaxVideoDurationInSeconds(mediaConstraints: MediaConstraints): I
private fun CameraFragment.Controller.onCameraXScreenEvent(event: CameraXScreenEvent) {
when (event) {
is CameraXScreenEvent.ImageCaptured -> onImageCaptured(event.data, event.width, event.height)
is CameraXScreenEvent.VideoCaptured -> onVideoCaptured(event.fd)
is CameraXScreenEvent.VideoCaptured -> onVideoCaptured(event.fd, event.durationMs)
is CameraXScreenEvent.QrCodeFound -> onQrCodeFound(event.data)
CameraXScreenEvent.VideoCaptureError -> onVideoCaptureError()
CameraXScreenEvent.GalleryClicked -> onGalleryClicked()
@@ -310,43 +373,74 @@ data class CameraXScreenState(
val selectedMediaCount: Int = 0
)
/** A descriptor to record into, paired with the duration cap that the descriptor actually supports. */
class ActiveRecording(val parcelFd: ParcelFileDescriptor, val maxDurationSeconds: Int)
@Stable
class VideoFileDescriptor(val context: Context) {
private var videoFileDescriptor: MemoryFileDescriptor? = null
private var videoFileDescriptor: SeekableFileDescriptor? = null
fun create(): ParcelFileDescriptor? {
/**
* Creates the descriptor to record into, reporting the cap that goes with whichever descriptor was actually
* created. A disk-backed descriptor that fails to be created falls back to the RAM-backed one and its shorter
* cap, rather than blocking recording entirely.
*/
fun create(config: VideoRecordingConfig): ActiveRecording? {
if (Build.VERSION.SDK_INT < 26) {
throw IllegalStateException("Video capture requires API 26 or higher")
}
destroy()
if (config.useEncryptedDisk) {
val encrypted = CameraXUtil.createEncryptedDiskVideoFileDescriptor(context)
if (encrypted != null) {
videoFileDescriptor = encrypted
return ActiveRecording(encrypted.parcelFd, config.maxDurationSeconds)
}
Log.w(TAG, "Failed to create encrypted disk file descriptor, falling back to memory")
}
return try {
destroy()
videoFileDescriptor = CameraXUtil.createVideoFileDescriptor(context)
videoFileDescriptor?.parcelFd
val memory = CameraXUtil.createMemoryVideoFileDescriptor(context)
videoFileDescriptor = memory
ActiveRecording(memory.parcelFd, config.memoryBackedMaxDurationSeconds)
} catch (e: IOException) {
Log.w(TAG, "Failed to create video file descriptor", e)
null
}
}
fun destroy() {
videoFileDescriptor?.let {
try {
it.close()
} catch (e: IOException) {
Log.w(TAG, "Failed to close video file descriptor", e)
}
videoFileDescriptor = null
/**
* Hands the recorded descriptor to the consumer, which becomes responsible for closing it. The copy on the
* consuming side runs asynchronously and can outlive this screen, so ownership has to travel with it.
*/
fun releaseForReading(): SeekableFileDescriptor? {
val descriptor = videoFileDescriptor ?: return null
videoFileDescriptor = null
return try {
Os.lseek(descriptor.fileDescriptor, 0, OsConstants.SEEK_SET)
descriptor
} catch (e: ErrnoException) {
Log.w(TAG, "Failed to seek video file descriptor", e)
descriptor.closeQuietly()
null
}
}
fun destroy() {
videoFileDescriptor?.closeQuietly()
videoFileDescriptor = null
}
}
@Composable
fun CameraXScreen(
state: CameraXScreenState,
onEvent: (CameraXScreenEvent) -> Unit,
maxVideoDurationSeconds: Int,
videoRecordingConfig: VideoRecordingConfig,
onCheckPermissions: () -> Unit,
hasCameraPermission: () -> Boolean,
onRequestMicPermission: () -> Unit,
@@ -375,6 +469,7 @@ fun CameraXScreen(
val cameraDisplay = CameraDisplay.rememberCameraDisplay(cameraState.isLandscape)
var hasPermission by remember { mutableStateOf(hasCameraPermission()) }
var activeRecordingMaxDurationMs by remember { mutableLongStateOf(0L) }
DisposableEffect(Unit) {
onDispose { videoFileDescriptor.destroy() }
@@ -485,7 +580,7 @@ fun CameraXScreen(
StandardCameraHud(
state = cameraState,
modifier = Modifier.padding(bottom = if (isPortraitPhone) hudBottomPaddingInsideViewport else 0.dp),
maxRecordingDurationMs = maxVideoDurationSeconds * 1000L,
maxRecordingDurationMs = activeRecordingMaxDurationMs,
hasAudioPermission = { context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED },
emitter = { event ->
handleHudEvent(
@@ -495,7 +590,12 @@ fun CameraXScreen(
onEvent = onEvent,
isVideoEnabled = captureMode != CameraCaptureMode.ImageOnly,
onRequestMicPermission = onRequestMicPermission,
createVideoFileDescriptor = { videoFileDescriptor.create() }
createVideoFileDescriptor = {
videoFileDescriptor.create(videoRecordingConfig)?.also {
activeRecordingMaxDurationMs = it.maxDurationSeconds * 1000L
}
},
releaseVideoFileDescriptor = { videoFileDescriptor.releaseForReading() }
)
},
stringResources = StringResources(
@@ -591,7 +691,8 @@ private fun handleHudEvent(
onEvent: (CameraXScreenEvent) -> Unit,
isVideoEnabled: Boolean,
onRequestMicPermission: () -> Unit,
createVideoFileDescriptor: () -> ParcelFileDescriptor?
createVideoFileDescriptor: () -> ActiveRecording?,
releaseVideoFileDescriptor: () -> SeekableFileDescriptor?
) {
when (event) {
is StandardCameraHudEvents.PhotoCaptureTriggered -> {
@@ -604,20 +705,16 @@ private fun handleHudEvent(
}
is StandardCameraHudEvents.VideoCaptureStarted -> {
if (Build.VERSION.SDK_INT >= 26 && isVideoEnabled) {
val fileDescriptor = createVideoFileDescriptor()
if (fileDescriptor != null) {
cameraViewModel.startRecording(
context = context,
output = VideoOutput.FileDescriptorOutput(fileDescriptor),
onVideoCaptured = { result ->
handleVideoCaptured(result, onEvent)
}
)
} else {
Toast.makeText(context, R.string.CameraFragment__video_recording_is_not_supported_on_your_device, Toast.LENGTH_SHORT)
.show()
}
val recording = if (Build.VERSION.SDK_INT >= 26 && isVideoEnabled) createVideoFileDescriptor() else null
if (recording != null) {
cameraViewModel.startRecording(
context = context,
output = VideoOutput.FileDescriptorOutput(recording.parcelFd),
onVideoCaptured = { result ->
handleVideoCaptured(result, releaseVideoFileDescriptor, onEvent)
}
)
} else {
Toast.makeText(context, R.string.CameraFragment__video_recording_is_not_supported_on_your_device, Toast.LENGTH_SHORT)
.show()
@@ -667,19 +764,19 @@ private fun handlePhotoCaptured(bitmap: Bitmap, onEvent: (CameraXScreenEvent) ->
onEvent(CameraXScreenEvent.ImageCaptured(data, bitmap.width, bitmap.height))
}
private fun handleVideoCaptured(result: VideoCaptureResult, onEvent: (CameraXScreenEvent) -> Unit) {
private fun handleVideoCaptured(
result: VideoCaptureResult,
releaseVideoFileDescriptor: () -> SeekableFileDescriptor?,
onEvent: (CameraXScreenEvent) -> Unit
) {
when (result) {
is VideoCaptureResult.Success -> {
result.fileDescriptor?.let { parcelFd ->
try {
// Seek to beginning before reading
Os.lseek(parcelFd.fileDescriptor, 0, OsConstants.SEEK_SET)
onEvent(CameraXScreenEvent.VideoCaptured(parcelFd.fileDescriptor))
} catch (e: Exception) {
Log.w(TAG, "Failed to seek video file descriptor", e)
onEvent(CameraXScreenEvent.VideoCaptureError)
}
} ?: onEvent(CameraXScreenEvent.VideoCaptureError)
val descriptor = releaseVideoFileDescriptor()
if (descriptor != null) {
onEvent(CameraXScreenEvent.VideoCaptured(descriptor, result.durationMs))
} else {
onEvent(CameraXScreenEvent.VideoCaptureError)
}
}
is VideoCaptureResult.Error -> {
@@ -696,7 +793,7 @@ private fun CameraXScreenPreview() {
CameraXScreen(
state = CameraXScreenState(),
onEvent = {},
maxVideoDurationSeconds = 0,
videoRecordingConfig = VideoRecordingConfig(),
onCheckPermissions = {},
hasCameraPermission = { true },
onRequestMicPermission = { },
@@ -717,7 +814,7 @@ private fun CameraXScreenPreview_19_9() {
CameraXScreen(
state = CameraXScreenState(),
onEvent = {},
maxVideoDurationSeconds = 0,
videoRecordingConfig = VideoRecordingConfig(),
onCheckPermissions = {},
hasCameraPermission = { true },
onRequestMicPermission = { },
@@ -738,7 +835,7 @@ private fun CameraXScreenPreview_18_9() {
CameraXScreen(
state = CameraXScreenState(),
onEvent = {},
maxVideoDurationSeconds = 0,
videoRecordingConfig = VideoRecordingConfig(),
onCheckPermissions = {},
hasCameraPermission = { true },
onRequestMicPermission = { },
@@ -759,7 +856,7 @@ private fun CameraXScreenPreview_16_9() {
CameraXScreen(
state = CameraXScreenState(),
onEvent = {},
maxVideoDurationSeconds = 0,
videoRecordingConfig = VideoRecordingConfig(),
onCheckPermissions = {},
hasCameraPermission = { true },
onRequestMicPermission = { },
@@ -5,11 +5,16 @@
package org.signal.mediasend.capture
import java.io.FileDescriptor
import org.signal.core.util.SeekableFileDescriptor
sealed interface CameraXScreenEvent {
class ImageCaptured(val data: ByteArray, val width: Int, val height: Int) : CameraXScreenEvent
class VideoCaptured(val fd: FileDescriptor) : CameraXScreenEvent
/**
* @param fd Owned by the consumer, which must close it once it is finished reading the recording.
* @param durationMs How long the recording ran, as reported by the recorder.
*/
class VideoCaptured(val fd: SeekableFileDescriptor, val durationMs: Long) : CameraXScreenEvent
class QrCodeFound(val data: String) : CameraXScreenEvent
data object VideoCaptureError : CameraXScreenEvent
data object GalleryClicked : CameraXScreenEvent
@@ -29,13 +29,10 @@ fun MediaCameraCaptureScreen(
)
},
onEvent = { event -> onEvent(MediaCaptureScreenEvent.Camera(event)) },
maxVideoDurationSeconds = remember(state.isStory) {
if (state.isStory) {
state.storyMaxVideoDuration.inWholeSeconds.toInt()
} else {
getMaxVideoDurationInSeconds(mediaConstraints = state.mediaConstraints)
}
},
videoRecordingConfig = rememberVideoRecordingConfig(
mediaConstraints = state.mediaConstraints,
maxDurationSecondsOverride = if (state.isStory) state.storyMaxVideoDuration.inWholeSeconds.toInt() else 0
),
onCheckPermissions = {}, // TODO [media-send]
onRequestMicPermission = {}, // TODO [media-send]
hasCameraPermission = { true }, // TODO [media-send]
+1 -1
View File
@@ -1,6 +1,6 @@
# R8 in AGP 9.x is non-deterministic, producing intermittent dex differences between
# otherwise-identical builds. Two flags work together to stabilize it:
# - -Dcom.android.tools.r8.deterministicdebugging=true run R8 compilation in a
# - -Dcom.android.tools.r8.deterministicdebugging=true runs R8 compilation in a
# single thread and without randomly shuffling the input.
# - -XX:hashCode=3 (deterministic incrementing Object.identityHashCode) removes
# IdentityHashMap iteration-order non-determinism inside R8 (e.g. in
@@ -14,7 +14,6 @@ object VideoConstants {
const val MB: Int = 1000 * KB
const val VIDEO_SHORT_EDGE_HD = 720
const val VIDEO_LONG_EDGE_HD = 1280
const val VIDEO_MAX_RECORD_LENGTH_S = 60
const val AUDIO_MIME_TYPE = MediaFormat.MIMETYPE_AUDIO_AAC
const val RECORDED_VIDEO_CONTENT_TYPE: String = "video/mp4"