mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-05 21:07:49 +01:00
Allow recording in-app videos encrypted to disk.
This commit is contained in:
committed by
Alex Hart
parent
b812bf3e5f
commit
607115b49e
+5
@@ -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
|
||||
|
||||
+4
-2
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+7
-3
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
+136
@@ -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() })
|
||||
}
|
||||
}
|
||||
+106
@@ -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
|
||||
|
||||
+4
-7
@@ -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
@@ -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
|
||||
|
||||
-1
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user