From 607115b49e3a62334a50dd9b898cf85345a62bb2 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 30 Jul 2026 12:05:46 -0400 Subject: [PATCH] Allow recording in-app videos encrypted to disk. --- .../CameraDependenciesProvider.kt | 5 + .../mediasend/AvatarSelectionActivity.java | 4 +- .../mediasend/v2/MediaSelectionViewModel.kt | 18 + .../v2/capture/MediaCaptureFragment.kt | 6 +- .../v2/capture/MediaCaptureRepository.kt | 10 +- .../v2/capture/MediaCaptureViewModel.kt | 4 +- .../mediasend/v3/MediaSendV3Repository.kt | 5 + .../mms/TranscodingConfigProvider.kt | 30 ++ .../securesms/util/RemoteConfig.kt | 3 +- .../signal/core/util/CloseableExtensions.kt | 14 + .../core/util/EncryptedProxyFileDescriptor.kt | 365 ++++++++++++++++++ .../signal/core/util/MemoryFileDescriptor.kt | 7 +- .../core/util/SeekableFileDescriptor.kt | 20 + ...ncryptedProxyFileDescriptorCallbackTest.kt | 136 +++++++ .../EncryptedProxyFileDescriptorCryptoTest.kt | 106 +++++ .../camera/demo/CameraDemoApplication.kt | 8 + .../org/signal/camera/CameraDependencies.kt | 10 + .../signal/camera/CameraScreenViewModel.kt | 22 +- .../java/org/signal/camera/CameraXUtil.kt | 12 +- .../java/org/signal/camera/VideoOutput.kt | 4 +- .../camera/CameraScreenViewModelTest.kt | 6 + .../org/signal/mediasend/CameraFragment.java | 9 +- .../signal/mediasend/MediaSendRepository.kt | 6 + .../signal/mediasend/MediaSendViewModel.kt | 33 +- .../java/org/signal/mediasend/VideoUtil.java | 25 -- .../java/org/signal/mediasend/VideoUtil.kt | 36 ++ .../mediasend/capture/CameraXFragment.kt | 199 +++++++--- .../mediasend/capture/CameraXScreenEvent.kt | 9 +- .../capture/MediaCameraCaptureScreen.kt | 11 +- gradle.properties | 2 +- .../videoconverter/utils/VideoConstants.kt | 1 - 31 files changed, 1007 insertions(+), 119 deletions(-) create mode 100644 core/util-jvm/src/main/java/org/signal/core/util/CloseableExtensions.kt create mode 100644 core/util/src/main/java/org/signal/core/util/EncryptedProxyFileDescriptor.kt create mode 100644 core/util/src/main/java/org/signal/core/util/SeekableFileDescriptor.kt create mode 100644 core/util/src/test/java/org/signal/core/util/EncryptedProxyFileDescriptorCallbackTest.kt create mode 100644 core/util/src/test/java/org/signal/core/util/EncryptedProxyFileDescriptorCryptoTest.kt delete mode 100644 feature/media-send/src/main/java/org/signal/mediasend/VideoUtil.java create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/VideoUtil.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/CameraDependenciesProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/CameraDependenciesProvider.kt index a72c705722..38c715423b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/CameraDependenciesProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/CameraDependenciesProvider.kt @@ -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() + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java index 1916fb10d5..9cf70979c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java @@ -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"); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt index 978a553768..5fe2d9a6a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt index c94a6c6b43..ad4baf0434 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureRepository.kt index 72e0d48a0e..68a9339f59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureRepository.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureViewModel.kt index 909dfa4275..7fb0aa7b6a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureViewModel.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt index 8719fde941..aa24876557 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/TranscodingConfigProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/TranscodingConfigProvider.kt index b4ff1b90e2..5de91b19c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/TranscodingConfigProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/TranscodingConfigProvider.kt @@ -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 { val config = getAllConfigs() diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index ac9724b8a5..42e5ca3ea5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -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 ) diff --git a/core/util-jvm/src/main/java/org/signal/core/util/CloseableExtensions.kt b/core/util-jvm/src/main/java/org/signal/core/util/CloseableExtensions.kt new file mode 100644 index 0000000000..e8a09db6eb --- /dev/null +++ b/core/util-jvm/src/main/java/org/signal/core/util/CloseableExtensions.kt @@ -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) +} diff --git a/core/util/src/main/java/org/signal/core/util/EncryptedProxyFileDescriptor.kt b/core/util/src/main/java/org/signal/core/util/EncryptedProxyFileDescriptor.kt new file mode 100644 index 0000000000..1c666e64c8 --- /dev/null +++ b/core/util/src/main/java/org/signal/core/util/EncryptedProxyFileDescriptor.kt @@ -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 = 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() + } + } +} diff --git a/core/util/src/main/java/org/signal/core/util/MemoryFileDescriptor.kt b/core/util/src/main/java/org/signal/core/util/MemoryFileDescriptor.kt index 57e94e5d77..dc09f6e3e6 100644 --- a/core/util/src/main/java/org/signal/core/util/MemoryFileDescriptor.kt +++ b/core/util/src/main/java/org/signal/core/util/MemoryFileDescriptor.kt @@ -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) diff --git a/core/util/src/main/java/org/signal/core/util/SeekableFileDescriptor.kt b/core/util/src/main/java/org/signal/core/util/SeekableFileDescriptor.kt new file mode 100644 index 0000000000..41aea1c91b --- /dev/null +++ b/core/util/src/main/java/org/signal/core/util/SeekableFileDescriptor.kt @@ -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 +} diff --git a/core/util/src/test/java/org/signal/core/util/EncryptedProxyFileDescriptorCallbackTest.kt b/core/util/src/test/java/org/signal/core/util/EncryptedProxyFileDescriptorCallbackTest.kt new file mode 100644 index 0000000000..54dfc624ea --- /dev/null +++ b/core/util/src/test/java/org/signal/core/util/EncryptedProxyFileDescriptorCallbackTest.kt @@ -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() }) + } +} diff --git a/core/util/src/test/java/org/signal/core/util/EncryptedProxyFileDescriptorCryptoTest.kt b/core/util/src/test/java/org/signal/core/util/EncryptedProxyFileDescriptorCryptoTest.kt new file mode 100644 index 0000000000..c2e9834895 --- /dev/null +++ b/core/util/src/test/java/org/signal/core/util/EncryptedProxyFileDescriptorCryptoTest.kt @@ -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) + } +} diff --git a/demo/camera/src/main/java/org/signal/camera/demo/CameraDemoApplication.kt b/demo/camera/src/main/java/org/signal/camera/demo/CameraDemoApplication.kt index fde97c7370..595e83e311 100644 --- a/demo/camera/src/main/java/org/signal/camera/demo/CameraDemoApplication.kt +++ b/demo/camera/src/main/java/org/signal/camera/demo/CameraDemoApplication.kt @@ -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 + } + ) } } diff --git a/feature/camera/src/main/java/org/signal/camera/CameraDependencies.kt b/feature/camera/src/main/java/org/signal/camera/CameraDependencies.kt index f849ab5b69..64954a8fbf 100644 --- a/feature/camera/src/main/java/org/signal/camera/CameraDependencies.kt +++ b/feature/camera/src/main/java/org/signal/camera/CameraDependencies.kt @@ -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 } } diff --git a/feature/camera/src/main/java/org/signal/camera/CameraScreenViewModel.kt b/feature/camera/src/main/java/org/signal/camera/CameraScreenViewModel.kt index 4487ba10ef..6595d8ba81 100644 --- a/feature/camera/src/main/java/org/signal/camera/CameraScreenViewModel.kt +++ b/feature/camera/src/main/java/org/signal/camera/CameraScreenViewModel.kt @@ -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 { 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) } diff --git a/feature/camera/src/main/java/org/signal/camera/CameraXUtil.kt b/feature/camera/src/main/java/org/signal/camera/CameraXUtil.kt index 352ae6ec48..e0091e888f 100644 --- a/feature/camera/src/main/java/org/signal/camera/CameraXUtil.kt +++ b/feature/camera/src/main/java/org/signal/camera/CameraXUtil.kt @@ -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, diff --git a/feature/camera/src/main/java/org/signal/camera/VideoOutput.kt b/feature/camera/src/main/java/org/signal/camera/VideoOutput.kt index 32d61b7c5f..062ffc1ed9 100644 --- a/feature/camera/src/main/java/org/signal/camera/VideoOutput.kt +++ b/feature/camera/src/main/java/org/signal/camera/VideoOutput.kt @@ -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() /** diff --git a/feature/camera/src/test/java/org/signal/camera/CameraScreenViewModelTest.kt b/feature/camera/src/test/java/org/signal/camera/CameraScreenViewModelTest.kt index 4fd94badfb..c83ee1e67c 100644 --- a/feature/camera/src/test/java/org/signal/camera/CameraScreenViewModelTest.kt +++ b/feature/camera/src/test/java/org/signal/camera/CameraScreenViewModelTest.kt @@ -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 diff --git a/feature/media-send/src/main/java/org/signal/mediasend/CameraFragment.java b/feature/media-send/src/main/java/org/signal/mediasend/CameraFragment.java index b47f6d205f..97083663c6 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/CameraFragment.java +++ b/feature/media-send/src/main/java/org/signal/mediasend/CameraFragment.java @@ -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(); diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt index 94999db3ab..8463e83390 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt @@ -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. */ diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt index 824b0dcb78..47594af7f7 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt @@ -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. * diff --git a/feature/media-send/src/main/java/org/signal/mediasend/VideoUtil.java b/feature/media-send/src/main/java/org/signal/mediasend/VideoUtil.java deleted file mode 100644 index 7b51160f80..0000000000 --- a/feature/media-send/src/main/java/org/signal/mediasend/VideoUtil.java +++ /dev/null @@ -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); - } -} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/VideoUtil.kt b/feature/media-send/src/main/java/org/signal/mediasend/VideoUtil.kt new file mode 100644 index 0000000000..be2c2c322f --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/VideoUtil.kt @@ -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() + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXFragment.kt b/feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXFragment.kt index 5b7ea30233..23bf49857b 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXFragment.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXFragment.kt @@ -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 = { }, diff --git a/feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXScreenEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXScreenEvent.kt index 9af6a3510f..0427830928 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXScreenEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXScreenEvent.kt @@ -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 diff --git a/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCameraCaptureScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCameraCaptureScreen.kt index c230dc5a15..6e8ad5f883 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCameraCaptureScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCameraCaptureScreen.kt @@ -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] diff --git a/gradle.properties b/gradle.properties index d0ae140ab7..9b62abf05d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/lib/video/src/main/java/org/thoughtcrime/securesms/video/videoconverter/utils/VideoConstants.kt b/lib/video/src/main/java/org/thoughtcrime/securesms/video/videoconverter/utils/VideoConstants.kt index 6d86e0a80a..58d87a3397 100644 --- a/lib/video/src/main/java/org/thoughtcrime/securesms/video/videoconverter/utils/VideoConstants.kt +++ b/lib/video/src/main/java/org/thoughtcrime/securesms/video/videoconverter/utils/VideoConstants.kt @@ -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"