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.
This commit is contained in:
Greyson Parrelli
2026-02-09 12:24:45 -05:00
parent cbcbe3f564
commit 8d44640377
4 changed files with 45 additions and 1 deletions

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.mediasend
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -139,6 +140,7 @@ class CameraXFragment : ComposeFragment(), CameraFragment {
selectedMediaCount = selectedMediaCount.intValue, selectedMediaCount = selectedMediaCount.intValue,
onCheckPermissions = { checkPermissions(isVideoEnabled) }, onCheckPermissions = { checkPermissions(isVideoEnabled) },
hasCameraPermission = { hasCameraPermission() }, hasCameraPermission = { hasCameraPermission() },
onRequestMicPermission = { requestMicPermission() },
createVideoFileDescriptor = { createVideoFileDescriptor() }, createVideoFileDescriptor = { createVideoFileDescriptor() },
getMaxVideoDurationInSeconds = { getMaxVideoDurationInSeconds() }, getMaxVideoDurationInSeconds = { getMaxVideoDurationInSeconds() },
cameraDisplay = CameraDisplay.getDisplay(requireActivity()) cameraDisplay = CameraDisplay.getDisplay(requireActivity())
@@ -242,6 +244,16 @@ class CameraXFragment : ComposeFragment(), CameraFragment {
return Permissions.hasAll(requireContext(), Manifest.permission.CAMERA) 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? { private fun createVideoFileDescriptor(): ParcelFileDescriptor? {
if (Build.VERSION.SDK_INT < 26) { if (Build.VERSION.SDK_INT < 26) {
throw IllegalStateException("Video capture requires API 26 or higher") throw IllegalStateException("Video capture requires API 26 or higher")
@@ -287,6 +299,7 @@ private fun CameraXScreen(
selectedMediaCount: Int, selectedMediaCount: Int,
onCheckPermissions: () -> Unit, onCheckPermissions: () -> Unit,
hasCameraPermission: () -> Boolean, hasCameraPermission: () -> Boolean,
onRequestMicPermission: () -> Unit,
createVideoFileDescriptor: () -> ParcelFileDescriptor?, createVideoFileDescriptor: () -> ParcelFileDescriptor?,
getMaxVideoDurationInSeconds: () -> Int, getMaxVideoDurationInSeconds: () -> Int,
cameraDisplay: CameraDisplay, cameraDisplay: CameraDisplay,
@@ -400,6 +413,7 @@ private fun CameraXScreen(
modifier = Modifier.padding(bottom = hudBottomPaddingInsideViewport), modifier = Modifier.padding(bottom = hudBottomPaddingInsideViewport),
maxRecordingDurationMs = getMaxVideoDurationInSeconds() * 1000L, maxRecordingDurationMs = getMaxVideoDurationInSeconds() * 1000L,
mediaSelectionCount = selectedMediaCount, mediaSelectionCount = selectedMediaCount,
hasAudioPermission = { context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED },
emitter = { event -> emitter = { event ->
handleHudEvent( handleHudEvent(
event = event, event = event,
@@ -407,6 +421,7 @@ private fun CameraXScreen(
cameraViewModel = cameraViewModel, cameraViewModel = cameraViewModel,
controller = controller, controller = controller,
isVideoEnabled = isVideoEnabled, isVideoEnabled = isVideoEnabled,
onRequestMicPermission = onRequestMicPermission,
createVideoFileDescriptor = createVideoFileDescriptor createVideoFileDescriptor = createVideoFileDescriptor
) )
}, },
@@ -470,6 +485,7 @@ private fun handleHudEvent(
cameraViewModel: CameraScreenViewModel, cameraViewModel: CameraScreenViewModel,
controller: CameraFragment.Controller?, controller: CameraFragment.Controller?,
isVideoEnabled: Boolean, isVideoEnabled: Boolean,
onRequestMicPermission: () -> Unit,
createVideoFileDescriptor: () -> ParcelFileDescriptor? createVideoFileDescriptor: () -> ParcelFileDescriptor?
) { ) {
when (event) { when (event) {
@@ -528,6 +544,10 @@ private fun handleHudEvent(
is StandardCameraHudEvents.SetZoomLevel -> { is StandardCameraHudEvents.SetZoomLevel -> {
cameraViewModel.onEvent(CameraScreenEvents.LinearZoom(event.zoomLevel)) cameraViewModel.onEvent(CameraScreenEvents.LinearZoom(event.zoomLevel))
} }
is StandardCameraHudEvents.AudioPermissionRequired -> {
onRequestMicPermission()
}
} }
} }
@@ -579,6 +599,7 @@ private fun CameraXScreenPreview_20_9() {
selectedMediaCount = 0, selectedMediaCount = 0,
onCheckPermissions = {}, onCheckPermissions = {},
hasCameraPermission = { true }, hasCameraPermission = { true },
onRequestMicPermission = { },
createVideoFileDescriptor = { null }, createVideoFileDescriptor = { null },
getMaxVideoDurationInSeconds = { 60 }, getMaxVideoDurationInSeconds = { 60 },
cameraDisplay = CameraDisplay.DISPLAY_20_9, cameraDisplay = CameraDisplay.DISPLAY_20_9,
@@ -604,6 +625,7 @@ private fun CameraXScreenPreview_19_9() {
selectedMediaCount = 0, selectedMediaCount = 0,
onCheckPermissions = {}, onCheckPermissions = {},
hasCameraPermission = { true }, hasCameraPermission = { true },
onRequestMicPermission = { },
createVideoFileDescriptor = { null }, createVideoFileDescriptor = { null },
getMaxVideoDurationInSeconds = { 60 }, getMaxVideoDurationInSeconds = { 60 },
cameraDisplay = CameraDisplay.DISPLAY_19_9, cameraDisplay = CameraDisplay.DISPLAY_19_9,
@@ -629,6 +651,7 @@ private fun CameraXScreenPreview_18_9() {
selectedMediaCount = 0, selectedMediaCount = 0,
onCheckPermissions = {}, onCheckPermissions = {},
hasCameraPermission = { true }, hasCameraPermission = { true },
onRequestMicPermission = { },
createVideoFileDescriptor = { null }, createVideoFileDescriptor = { null },
getMaxVideoDurationInSeconds = { 60 }, getMaxVideoDurationInSeconds = { 60 },
cameraDisplay = CameraDisplay.DISPLAY_18_9, cameraDisplay = CameraDisplay.DISPLAY_18_9,
@@ -654,6 +677,7 @@ private fun CameraXScreenPreview_16_9() {
selectedMediaCount = 0, selectedMediaCount = 0,
onCheckPermissions = {}, onCheckPermissions = {},
hasCameraPermission = { true }, hasCameraPermission = { true },
onRequestMicPermission = { },
createVideoFileDescriptor = { null }, createVideoFileDescriptor = { null },
getMaxVideoDurationInSeconds = { 60 }, getMaxVideoDurationInSeconds = { 60 },
cameraDisplay = CameraDisplay.DISPLAY_16_9, cameraDisplay = CameraDisplay.DISPLAY_16_9,
@@ -679,6 +703,7 @@ private fun CameraXScreenPreview_6_5() {
selectedMediaCount = 0, selectedMediaCount = 0,
onCheckPermissions = {}, onCheckPermissions = {},
hasCameraPermission = { true }, hasCameraPermission = { true },
onRequestMicPermission = { },
createVideoFileDescriptor = { null }, createVideoFileDescriptor = { null },
getMaxVideoDurationInSeconds = { 60 }, getMaxVideoDurationInSeconds = { 60 },
cameraDisplay = CameraDisplay.DISPLAY_6_5, cameraDisplay = CameraDisplay.DISPLAY_6_5,

View File

@@ -147,6 +147,9 @@ fun MainScreen(
is StandardCameraHudEvents.MediaSelectionClick -> { is StandardCameraHudEvents.MediaSelectionClick -> {
// Doesn't need to be handled // Doesn't need to be handled
} }
is StandardCameraHudEvents.AudioPermissionRequired -> {
// Doesn't need to be handled
}
} }
} }
) )

View File

@@ -104,6 +104,7 @@ fun BoxScope.StandardCameraHud(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
maxRecordingDurationMs: Long = DEFAULT_MAX_RECORDING_DURATION_MS, maxRecordingDurationMs: Long = DEFAULT_MAX_RECORDING_DURATION_MS,
mediaSelectionCount: Int = 0, mediaSelectionCount: Int = 0,
hasAudioPermission: () -> Boolean = { true },
stringResources: StringResources = StringResources(0, 0) stringResources: StringResources = StringResources(0, 0)
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -131,6 +132,7 @@ fun BoxScope.StandardCameraHud(
modifier = modifier, modifier = modifier,
maxRecordingDurationMs = maxRecordingDurationMs, maxRecordingDurationMs = maxRecordingDurationMs,
mediaSelectionCount = mediaSelectionCount, mediaSelectionCount = mediaSelectionCount,
hasAudioPermission = hasAudioPermission,
stringResources = stringResources stringResources = stringResources
) )
} }
@@ -142,6 +144,7 @@ private fun BoxScope.StandardCameraHudContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
maxRecordingDurationMs: Long = DEFAULT_MAX_RECORDING_DURATION_MS, maxRecordingDurationMs: Long = DEFAULT_MAX_RECORDING_DURATION_MS,
mediaSelectionCount: Int = 0, mediaSelectionCount: Int = 0,
hasAudioPermission: () -> Boolean = { true },
stringResources: StringResources = StringResources() stringResources: StringResources = StringResources()
) { ) {
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
@@ -177,6 +180,7 @@ private fun BoxScope.StandardCameraHudContent(
}, },
mediaSelectionCount = mediaSelectionCount, mediaSelectionCount = mediaSelectionCount,
emitter = emitter, emitter = emitter,
hasAudioPermission = hasAudioPermission,
stringResources = stringResources, stringResources = stringResources,
modifier = modifier.align(if (isLandscape) Alignment.CenterEnd else Alignment.BottomCenter) modifier = modifier.align(if (isLandscape) Alignment.CenterEnd else Alignment.BottomCenter)
) )
@@ -209,6 +213,7 @@ private fun CameraControls(
recordingProgress: Float, recordingProgress: Float,
mediaSelectionCount: Int, mediaSelectionCount: Int,
emitter: (StandardCameraHudEvents) -> Unit, emitter: (StandardCameraHudEvents) -> Unit,
hasAudioPermission: () -> Boolean,
stringResources: StringResources, stringResources: StringResources,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -229,7 +234,13 @@ private fun CameraControls(
isRecording = isRecording, isRecording = isRecording,
recordingProgress = recordingProgress, recordingProgress = recordingProgress,
onTap = { emitter(StandardCameraHudEvents.PhotoCaptureTriggered) }, onTap = { emitter(StandardCameraHudEvents.PhotoCaptureTriggered) },
onLongPressStart = { emitter(StandardCameraHudEvents.VideoCaptureStarted) }, onLongPressStart = {
if (hasAudioPermission()) {
emitter(StandardCameraHudEvents.VideoCaptureStarted)
} else {
emitter(StandardCameraHudEvents.AudioPermissionRequired)
}
},
onLongPressEnd = { emitter(StandardCameraHudEvents.VideoCaptureStopped) }, onLongPressEnd = { emitter(StandardCameraHudEvents.VideoCaptureStopped) },
onZoomChange = { emitter(StandardCameraHudEvents.SetZoomLevel(it)) } onZoomChange = { emitter(StandardCameraHudEvents.SetZoomLevel(it)) }
) )

View File

@@ -37,4 +37,9 @@ sealed interface StandardCameraHudEvents {
* Emitted when a capture error should be cleared (after displaying to user). * Emitted when a capture error should be cleared (after displaying to user).
*/ */
data object ClearCaptureError : StandardCameraHudEvents data object ClearCaptureError : StandardCameraHudEvents
/**
* Emitted when the user attempts to start video recording but audio permission has not been granted.
*/
data object AudioPermissionRequired : StandardCameraHudEvents
} }