android: add in-chat circle recorder with live camera preview
Some checks failed
Android CI / android (push) Failing after 34s
Android Release / release (push) Failing after 36s
CI / test (push) Failing after 3m38s

This commit is contained in:
2026-03-11 22:32:39 +03:00
parent 2fa006747d
commit cf53123724
2 changed files with 328 additions and 20 deletions

View File

@@ -106,6 +106,11 @@ dependencies {
implementation("androidx.media3:media3-exoplayer:1.4.1") implementation("androidx.media3:media3-exoplayer:1.4.1")
implementation("androidx.media3:media3-datasource:1.4.1") implementation("androidx.media3:media3-datasource:1.4.1")
implementation("androidx.media3:media3-datasource-okhttp:1.4.1") implementation("androidx.media3:media3-datasource-okhttp:1.4.1")
implementation("androidx.camera:camera-core:1.4.2")
implementation("androidx.camera:camera-camera2:1.4.2")
implementation("androidx.camera:camera-lifecycle:1.4.2")
implementation("androidx.camera:camera-video:1.4.2")
implementation("androidx.camera:camera-view:1.4.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")

View File

@@ -115,6 +115,7 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@@ -183,6 +184,19 @@ import java.util.Locale
import java.net.URLEncoder import java.net.URLEncoder
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.math.roundToInt import kotlin.math.roundToInt
import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.FileOutputOptions
import androidx.camera.video.PendingRecording
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
import androidx.camera.video.VideoRecordEvent
import androidx.camera.view.PreviewView
import androidx.compose.ui.window.Dialog
@Composable @Composable
fun ChatRoute( fun ChatRoute(
@@ -191,7 +205,9 @@ fun ChatRoute(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val voiceRecorder = remember(context) { VoiceRecorder(context) } val voiceRecorder = remember(context) { VoiceRecorder(context) }
var showCircleRecorder by remember { mutableStateOf(false) }
var hasAudioPermission by remember { var hasAudioPermission by remember {
mutableStateOf( mutableStateOf(
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
@@ -200,7 +216,16 @@ fun ChatRoute(
) == PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
) )
} }
var hasCameraPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA,
) == PackageManager.PERMISSION_GRANTED
)
}
var startRecordingAfterPermissionGrant by remember { mutableStateOf(false) } var startRecordingAfterPermissionGrant by remember { mutableStateOf(false) }
var openCircleAfterPermissionGrant by remember { mutableStateOf(false) }
val startVoiceRecording: () -> Unit = { val startVoiceRecording: () -> Unit = {
val started = voiceRecorder.start() val started = voiceRecorder.start()
if (started) { if (started) {
@@ -219,6 +244,18 @@ fun ChatRoute(
} }
startRecordingAfterPermissionGrant = false startRecordingAfterPermissionGrant = false
} }
val circlePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(),
) { grants ->
hasCameraPermission = grants[Manifest.permission.CAMERA] == true ||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
hasAudioPermission = grants[Manifest.permission.RECORD_AUDIO] == true ||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
if (hasCameraPermission && openCircleAfterPermissionGrant) {
showCircleRecorder = true
}
openCircleAfterPermissionGrant = false
}
DisposableEffect(voiceRecorder) { DisposableEffect(voiceRecorder) {
onDispose { onDispose {
voiceRecorder.cancel() voiceRecorder.cancel()
@@ -247,26 +284,18 @@ fun ChatRoute(
} }
var pendingCaptureVideoUri by remember { mutableStateOf<Uri?>(null) } var pendingCaptureVideoUri by remember { mutableStateOf<Uri?>(null) }
var pendingCaptureVideoFile by remember { mutableStateOf<File?>(null) } var pendingCaptureVideoFile by remember { mutableStateOf<File?>(null) }
var pendingCaptureCircle by remember { mutableStateOf(false) }
val captureVideoLauncher = rememberLauncherForActivityResult( val captureVideoLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CaptureVideo(), contract = ActivityResultContracts.CaptureVideo(),
) { success -> ) { success ->
val uri = pendingCaptureVideoUri val uri = pendingCaptureVideoUri
val file = pendingCaptureVideoFile val file = pendingCaptureVideoFile
val asCircle = pendingCaptureCircle
pendingCaptureVideoUri = null pendingCaptureVideoUri = null
pendingCaptureVideoFile = null pendingCaptureVideoFile = null
pendingCaptureCircle = false
if (success && uri != null) { if (success && uri != null) {
val picked = uri.readMediaPayload(context) val picked = uri.readMediaPayload(context)
if (picked != null) { if (picked != null) {
val fileName = if (asCircle) {
"circle_${System.currentTimeMillis()}.mp4"
} else {
"camera_video_${System.currentTimeMillis()}.mp4"
}
viewModel.onMediaPicked( viewModel.onMediaPicked(
fileName = fileName, fileName = "camera_video_${System.currentTimeMillis()}.mp4",
mimeType = if (picked.mimeType.startsWith("video/")) picked.mimeType else "video/mp4", mimeType = if (picked.mimeType.startsWith("video/")) picked.mimeType else "video/mp4",
bytes = picked.bytes, bytes = picked.bytes,
) )
@@ -302,22 +331,22 @@ fun ChatRoute(
"${context.packageName}.fileprovider", "${context.packageName}.fileprovider",
file, file,
) )
pendingCaptureCircle = false
pendingCaptureVideoFile = file pendingCaptureVideoFile = file
pendingCaptureVideoUri = uri pendingCaptureVideoUri = uri
captureVideoLauncher.launch(uri) captureVideoLauncher.launch(uri)
}, },
onCaptureCircleVideo = { onCaptureCircleVideo = {
val file = createTempCaptureFile(context = context, prefix = "circle_", suffix = ".mp4") if (hasCameraPermission) {
val uri = FileProvider.getUriForFile( showCircleRecorder = true
context, } else {
"${context.packageName}.fileprovider", openCircleAfterPermissionGrant = true
file, circlePermissionLauncher.launch(
arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO,
),
) )
pendingCaptureCircle = true }
pendingCaptureVideoFile = file
pendingCaptureVideoUri = uri
captureVideoLauncher.launch(uri)
}, },
onSendRemoteMedia = { fileName, mimeType, bytes -> onSendRemoteMedia = { fileName, mimeType, bytes ->
viewModel.onMediaPicked( viewModel.onMediaPicked(
@@ -372,6 +401,20 @@ fun ChatRoute(
onTransferOwnership = viewModel::transferOwnership, onTransferOwnership = viewModel::transferOwnership,
onUnbanMember = viewModel::unbanMember, onUnbanMember = viewModel::unbanMember,
) )
if (showCircleRecorder) {
CircleVideoRecorderDialog(
lifecycleOwner = lifecycleOwner,
onDismiss = { showCircleRecorder = false },
onSend = { payload ->
viewModel.onMediaPicked(
fileName = payload.fileName,
mimeType = payload.mimeType,
bytes = payload.bytes,
)
showCircleRecorder = false
},
)
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -2234,6 +2277,266 @@ private data class PickedMediaPayload(
val bytes: ByteArray, val bytes: ByteArray,
) )
private enum class CircleFinalizeAction {
Send,
Cancel,
}
@Composable
private fun CircleVideoRecorderDialog(
lifecycleOwner: androidx.lifecycle.LifecycleOwner,
onDismiss: () -> Unit,
onSend: (PickedMediaPayload) -> Unit,
) {
val context = LocalContext.current
val mainExecutor = remember(context) { ContextCompat.getMainExecutor(context) }
val previewView = remember {
PreviewView(context).apply {
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
scaleType = PreviewView.ScaleType.FILL_CENTER
}
}
var videoCapture by remember { mutableStateOf<VideoCapture<Recorder>?>(null) }
var activeRecording by remember { mutableStateOf<Recording?>(null) }
var activeFile by remember { mutableStateOf<File?>(null) }
var isRecording by remember { mutableStateOf(false) }
var isLocked by remember { mutableStateOf(false) }
var durationMs by remember { mutableStateOf(0L) }
var finalizeAction by remember { mutableStateOf<CircleFinalizeAction?>(null) }
LaunchedEffect(isRecording) {
while (isRecording) {
delay(100L)
durationMs += 100L
}
}
DisposableEffect(lifecycleOwner, previewView) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
val listener = Runnable {
val provider = runCatching { cameraProviderFuture.get() }.getOrNull() ?: return@Runnable
val preview = Preview.Builder().build().apply {
setSurfaceProvider(previewView.surfaceProvider)
}
val recorder = Recorder.Builder()
.setQualitySelector(QualitySelector.from(Quality.SD))
.build()
val capture = VideoCapture.withOutput(recorder)
runCatching {
provider.unbindAll()
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_FRONT_CAMERA,
preview,
capture,
)
videoCapture = capture
}
}
cameraProviderFuture.addListener(listener, mainExecutor)
onDispose {
runCatching { activeRecording?.stop() }
activeRecording = null
isRecording = false
val provider = runCatching { cameraProviderFuture.get() }.getOrNull()
runCatching { provider?.unbindAll() }
runCatching { activeFile?.delete() }
activeFile = null
}
}
fun stopWith(action: CircleFinalizeAction) {
val recording = activeRecording ?: return
finalizeAction = action
recording.stop()
activeRecording = null
isRecording = false
}
fun startRecording() {
if (isRecording) return
val capture = videoCapture ?: return
val file = createTempCaptureFile(context = context, prefix = "circle_", suffix = ".mp4")
activeFile = file
durationMs = 0L
isLocked = false
val output = FileOutputOptions.Builder(file).build()
var pending = capture.output.prepareRecording(context, output)
if (
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
) {
pending = pending.withAudioEnabled()
}
activeRecording = pending.start(mainExecutor) { event ->
when (event) {
is VideoRecordEvent.Start -> {
isRecording = true
}
is VideoRecordEvent.Finalize -> {
isRecording = false
val completedFile = activeFile
val action = finalizeAction
finalizeAction = null
activeFile = null
activeRecording = null
val hasError = event.hasError()
if (hasError || completedFile == null || !completedFile.exists()) {
runCatching { completedFile?.delete() }
return@start
}
when (action) {
CircleFinalizeAction.Send -> {
val bytes = runCatching { completedFile.readBytes() }.getOrNull()
if (bytes != null && bytes.isNotEmpty()) {
onSend(
PickedMediaPayload(
fileName = "circle_${System.currentTimeMillis()}.mp4",
mimeType = "video/mp4",
bytes = bytes,
),
)
}
runCatching { completedFile.delete() }
onDismiss()
}
CircleFinalizeAction.Cancel, null -> {
runCatching { completedFile.delete() }
}
}
}
else -> Unit
}
}
}
Dialog(onDismissRequest = {
if (!isRecording) {
onDismiss()
} else {
stopWith(CircleFinalizeAction.Cancel)
}
}) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(260.dp)
.clip(CircleShape)
.background(Color.Black),
) {
AndroidView(
factory = { previewView },
modifier = Modifier.fillMaxSize(),
)
}
Text(
text = if (isRecording) "Recording ${formatDuration(durationMs.toInt())}" else "Circle video",
style = MaterialTheme.typography.titleMedium,
)
if (!isRecording) {
CircleHoldToRecordButton(
onStart = { startRecording() },
onLock = { isLocked = true },
onCancel = { stopWith(CircleFinalizeAction.Cancel) },
onRelease = { stopWith(CircleFinalizeAction.Send) },
)
TextButton(onClick = onDismiss) { Text(stringResource(id = R.string.common_cancel)) }
} else {
if (isLocked) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
) {
TextButton(onClick = { stopWith(CircleFinalizeAction.Cancel) }) {
Text(stringResource(id = R.string.common_cancel))
}
Button(onClick = { stopWith(CircleFinalizeAction.Send) }) {
Text(stringResource(id = R.string.common_send))
}
}
} else {
CircleHoldToRecordButton(
onStart = {},
onLock = { isLocked = true },
onCancel = { stopWith(CircleFinalizeAction.Cancel) },
onRelease = { stopWith(CircleFinalizeAction.Send) },
)
}
}
}
}
}
}
@Composable
private fun CircleHoldToRecordButton(
onStart: () -> Unit,
onLock: () -> Unit,
onCancel: () -> Unit,
onRelease: () -> Unit,
) {
Box(
modifier = Modifier
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
onStart()
var cancelled = false
var locked = false
while (true) {
val event = awaitPointerEvent()
val change = event.changes.first()
val delta = change.position - down.position
if (!locked && delta.y < -120f) {
locked = true
onLock()
break
}
if (!locked && delta.x < -150f) {
cancelled = true
onCancel()
break
}
if (!change.pressed) {
if (!cancelled && !locked) onRelease()
break
}
if (change.positionChange() != Offset.Zero) {
change.consume()
}
}
}
},
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f),
) {
Row(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(imageVector = Icons.Filled.RadioButtonChecked, contentDescription = null)
Text("Slide up to lock, left to cancel")
}
}
}
}
private fun Bitmap.toJpegPayload(quality: Int = 92): PickedMediaPayload? { private fun Bitmap.toJpegPayload(quality: Int = 92): PickedMediaPayload? {
val stream = java.io.ByteArrayOutputStream() val stream = java.io.ByteArrayOutputStream()
val ok = compress(Bitmap.CompressFormat.JPEG, quality.coerceIn(1, 100), stream) val ok = compress(Bitmap.CompressFormat.JPEG, quality.coerceIn(1, 100), stream)