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-datasource: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-serialization-json:1.7.3")

View File

@@ -115,6 +115,7 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.annotation.StringRes
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.view.WindowCompat
@@ -183,6 +184,19 @@ import java.util.Locale
import java.net.URLEncoder
import kotlinx.coroutines.delay
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
fun ChatRoute(
@@ -191,7 +205,9 @@ fun ChatRoute(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val voiceRecorder = remember(context) { VoiceRecorder(context) }
var showCircleRecorder by remember { mutableStateOf(false) }
var hasAudioPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(
@@ -200,7 +216,16 @@ fun ChatRoute(
) == PackageManager.PERMISSION_GRANTED
)
}
var hasCameraPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA,
) == PackageManager.PERMISSION_GRANTED
)
}
var startRecordingAfterPermissionGrant by remember { mutableStateOf(false) }
var openCircleAfterPermissionGrant by remember { mutableStateOf(false) }
val startVoiceRecording: () -> Unit = {
val started = voiceRecorder.start()
if (started) {
@@ -219,6 +244,18 @@ fun ChatRoute(
}
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) {
onDispose {
voiceRecorder.cancel()
@@ -247,26 +284,18 @@ fun ChatRoute(
}
var pendingCaptureVideoUri by remember { mutableStateOf<Uri?>(null) }
var pendingCaptureVideoFile by remember { mutableStateOf<File?>(null) }
var pendingCaptureCircle by remember { mutableStateOf(false) }
val captureVideoLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CaptureVideo(),
) { success ->
val uri = pendingCaptureVideoUri
val file = pendingCaptureVideoFile
val asCircle = pendingCaptureCircle
pendingCaptureVideoUri = null
pendingCaptureVideoFile = null
pendingCaptureCircle = false
if (success && uri != null) {
val picked = uri.readMediaPayload(context)
if (picked != null) {
val fileName = if (asCircle) {
"circle_${System.currentTimeMillis()}.mp4"
} else {
"camera_video_${System.currentTimeMillis()}.mp4"
}
viewModel.onMediaPicked(
fileName = fileName,
fileName = "camera_video_${System.currentTimeMillis()}.mp4",
mimeType = if (picked.mimeType.startsWith("video/")) picked.mimeType else "video/mp4",
bytes = picked.bytes,
)
@@ -302,22 +331,22 @@ fun ChatRoute(
"${context.packageName}.fileprovider",
file,
)
pendingCaptureCircle = false
pendingCaptureVideoFile = file
pendingCaptureVideoUri = uri
captureVideoLauncher.launch(uri)
},
onCaptureCircleVideo = {
val file = createTempCaptureFile(context = context, prefix = "circle_", suffix = ".mp4")
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file,
if (hasCameraPermission) {
showCircleRecorder = true
} else {
openCircleAfterPermissionGrant = true
circlePermissionLauncher.launch(
arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO,
),
)
pendingCaptureCircle = true
pendingCaptureVideoFile = file
pendingCaptureVideoUri = uri
captureVideoLauncher.launch(uri)
}
},
onSendRemoteMedia = { fileName, mimeType, bytes ->
viewModel.onMediaPicked(
@@ -372,6 +401,20 @@ fun ChatRoute(
onTransferOwnership = viewModel::transferOwnership,
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)
@@ -2234,6 +2277,266 @@ private data class PickedMediaPayload(
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? {
val stream = java.io.ByteArrayOutputStream()
val ok = compress(Bitmap.CompressFormat.JPEG, quality.coerceIn(1, 100), stream)