From 8d44640377a893e3736cfba053a4bb418e7c0e51 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 9 Feb 2026 12:24:45 -0500 Subject: [PATCH] Prompt for microphone permission when recording video in new camera. Previously, the new camera would silently record video without audio when microphone permission was missing. Now it shows the same rationale dialog and permanent denial flow as the old camera. --- .../securesms/mediasend/CameraXFragment.kt | 25 +++++++++++++++++++ .../camera/demo/screens/main/MainScreen.kt | 3 +++ .../signal/camera/hud/StandardCameraHud.kt | 13 +++++++++- .../camera/hud/StandardCameraHudEvents.kt | 5 ++++ 4 files changed, 45 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt index 219f78358d..222dbe7a78 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.mediasend import android.Manifest import android.content.Context import android.content.pm.ActivityInfo +import android.content.pm.PackageManager import android.graphics.Bitmap import android.os.Build import android.os.Bundle @@ -139,6 +140,7 @@ class CameraXFragment : ComposeFragment(), CameraFragment { selectedMediaCount = selectedMediaCount.intValue, onCheckPermissions = { checkPermissions(isVideoEnabled) }, hasCameraPermission = { hasCameraPermission() }, + onRequestMicPermission = { requestMicPermission() }, createVideoFileDescriptor = { createVideoFileDescriptor() }, getMaxVideoDurationInSeconds = { getMaxVideoDurationInSeconds() }, cameraDisplay = CameraDisplay.getDisplay(requireActivity()) @@ -242,6 +244,16 @@ class CameraXFragment : ComposeFragment(), CameraFragment { return Permissions.hasAll(requireContext(), Manifest.permission.CAMERA) } + private fun requestMicPermission() { + Permissions.with(this) + .request(Manifest.permission.RECORD_AUDIO) + .ifNecessary() + .withRationaleDialog(getString(R.string.CameraXFragment_allow_access_microphone), getString(R.string.CameraXFragment_to_capture_videos_with_sound), R.drawable.ic_mic_24) + .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_recording_permissions_to_capture_video), null, R.string.CameraXFragment_allow_access_microphone, R.string.CameraXFragment_to_capture_videos, parentFragmentManager) + .onAnyDenied { Toast.makeText(requireContext(), R.string.CameraXFragment_signal_needs_microphone_access_video, Toast.LENGTH_LONG).show() } + .execute() + } + private fun createVideoFileDescriptor(): ParcelFileDescriptor? { if (Build.VERSION.SDK_INT < 26) { throw IllegalStateException("Video capture requires API 26 or higher") @@ -287,6 +299,7 @@ private fun CameraXScreen( selectedMediaCount: Int, onCheckPermissions: () -> Unit, hasCameraPermission: () -> Boolean, + onRequestMicPermission: () -> Unit, createVideoFileDescriptor: () -> ParcelFileDescriptor?, getMaxVideoDurationInSeconds: () -> Int, cameraDisplay: CameraDisplay, @@ -400,6 +413,7 @@ private fun CameraXScreen( modifier = Modifier.padding(bottom = hudBottomPaddingInsideViewport), maxRecordingDurationMs = getMaxVideoDurationInSeconds() * 1000L, mediaSelectionCount = selectedMediaCount, + hasAudioPermission = { context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED }, emitter = { event -> handleHudEvent( event = event, @@ -407,6 +421,7 @@ private fun CameraXScreen( cameraViewModel = cameraViewModel, controller = controller, isVideoEnabled = isVideoEnabled, + onRequestMicPermission = onRequestMicPermission, createVideoFileDescriptor = createVideoFileDescriptor ) }, @@ -470,6 +485,7 @@ private fun handleHudEvent( cameraViewModel: CameraScreenViewModel, controller: CameraFragment.Controller?, isVideoEnabled: Boolean, + onRequestMicPermission: () -> Unit, createVideoFileDescriptor: () -> ParcelFileDescriptor? ) { when (event) { @@ -528,6 +544,10 @@ private fun handleHudEvent( is StandardCameraHudEvents.SetZoomLevel -> { cameraViewModel.onEvent(CameraScreenEvents.LinearZoom(event.zoomLevel)) } + + is StandardCameraHudEvents.AudioPermissionRequired -> { + onRequestMicPermission() + } } } @@ -579,6 +599,7 @@ private fun CameraXScreenPreview_20_9() { selectedMediaCount = 0, onCheckPermissions = {}, hasCameraPermission = { true }, + onRequestMicPermission = { }, createVideoFileDescriptor = { null }, getMaxVideoDurationInSeconds = { 60 }, cameraDisplay = CameraDisplay.DISPLAY_20_9, @@ -604,6 +625,7 @@ private fun CameraXScreenPreview_19_9() { selectedMediaCount = 0, onCheckPermissions = {}, hasCameraPermission = { true }, + onRequestMicPermission = { }, createVideoFileDescriptor = { null }, getMaxVideoDurationInSeconds = { 60 }, cameraDisplay = CameraDisplay.DISPLAY_19_9, @@ -629,6 +651,7 @@ private fun CameraXScreenPreview_18_9() { selectedMediaCount = 0, onCheckPermissions = {}, hasCameraPermission = { true }, + onRequestMicPermission = { }, createVideoFileDescriptor = { null }, getMaxVideoDurationInSeconds = { 60 }, cameraDisplay = CameraDisplay.DISPLAY_18_9, @@ -654,6 +677,7 @@ private fun CameraXScreenPreview_16_9() { selectedMediaCount = 0, onCheckPermissions = {}, hasCameraPermission = { true }, + onRequestMicPermission = { }, createVideoFileDescriptor = { null }, getMaxVideoDurationInSeconds = { 60 }, cameraDisplay = CameraDisplay.DISPLAY_16_9, @@ -679,6 +703,7 @@ private fun CameraXScreenPreview_6_5() { selectedMediaCount = 0, onCheckPermissions = {}, hasCameraPermission = { true }, + onRequestMicPermission = { }, createVideoFileDescriptor = { null }, getMaxVideoDurationInSeconds = { 60 }, cameraDisplay = CameraDisplay.DISPLAY_6_5, diff --git a/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreen.kt b/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreen.kt index 437cd04546..aa003eefad 100644 --- a/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreen.kt +++ b/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreen.kt @@ -147,6 +147,9 @@ fun MainScreen( is StandardCameraHudEvents.MediaSelectionClick -> { // Doesn't need to be handled } + is StandardCameraHudEvents.AudioPermissionRequired -> { + // Doesn't need to be handled + } } } ) diff --git a/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHud.kt b/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHud.kt index 8aa2912556..4e5c1827a9 100644 --- a/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHud.kt +++ b/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHud.kt @@ -104,6 +104,7 @@ fun BoxScope.StandardCameraHud( modifier: Modifier = Modifier, maxRecordingDurationMs: Long = DEFAULT_MAX_RECORDING_DURATION_MS, mediaSelectionCount: Int = 0, + hasAudioPermission: () -> Boolean = { true }, stringResources: StringResources = StringResources(0, 0) ) { val context = LocalContext.current @@ -131,6 +132,7 @@ fun BoxScope.StandardCameraHud( modifier = modifier, maxRecordingDurationMs = maxRecordingDurationMs, mediaSelectionCount = mediaSelectionCount, + hasAudioPermission = hasAudioPermission, stringResources = stringResources ) } @@ -142,6 +144,7 @@ private fun BoxScope.StandardCameraHudContent( modifier: Modifier = Modifier, maxRecordingDurationMs: Long = DEFAULT_MAX_RECORDING_DURATION_MS, mediaSelectionCount: Int = 0, + hasAudioPermission: () -> Boolean = { true }, stringResources: StringResources = StringResources() ) { val configuration = LocalConfiguration.current @@ -177,6 +180,7 @@ private fun BoxScope.StandardCameraHudContent( }, mediaSelectionCount = mediaSelectionCount, emitter = emitter, + hasAudioPermission = hasAudioPermission, stringResources = stringResources, modifier = modifier.align(if (isLandscape) Alignment.CenterEnd else Alignment.BottomCenter) ) @@ -209,6 +213,7 @@ private fun CameraControls( recordingProgress: Float, mediaSelectionCount: Int, emitter: (StandardCameraHudEvents) -> Unit, + hasAudioPermission: () -> Boolean, stringResources: StringResources, modifier: Modifier = Modifier ) { @@ -229,7 +234,13 @@ private fun CameraControls( isRecording = isRecording, recordingProgress = recordingProgress, onTap = { emitter(StandardCameraHudEvents.PhotoCaptureTriggered) }, - onLongPressStart = { emitter(StandardCameraHudEvents.VideoCaptureStarted) }, + onLongPressStart = { + if (hasAudioPermission()) { + emitter(StandardCameraHudEvents.VideoCaptureStarted) + } else { + emitter(StandardCameraHudEvents.AudioPermissionRequired) + } + }, onLongPressEnd = { emitter(StandardCameraHudEvents.VideoCaptureStopped) }, onZoomChange = { emitter(StandardCameraHudEvents.SetZoomLevel(it)) } ) diff --git a/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHudEvents.kt b/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHudEvents.kt index 62f8dd0492..35a81887b3 100644 --- a/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHudEvents.kt +++ b/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHudEvents.kt @@ -37,4 +37,9 @@ sealed interface StandardCameraHudEvents { * Emitted when a capture error should be cleared (after displaying to user). */ data object ClearCaptureError : StandardCameraHudEvents + + /** + * Emitted when the user attempts to start video recording but audio permission has not been granted. + */ + data object AudioPermissionRequired : StandardCameraHudEvents }