diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ab59450..72a6939 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index d57f77e..e5302be 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -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(null) } var pendingCaptureVideoFile by remember { mutableStateOf(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, - ) - pendingCaptureCircle = true - pendingCaptureVideoFile = file - pendingCaptureVideoUri = uri - captureVideoLauncher.launch(uri) + if (hasCameraPermission) { + showCircleRecorder = true + } else { + openCircleAfterPermissionGrant = true + circlePermissionLauncher.launch( + arrayOf( + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO, + ), + ) + } }, 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?>(null) } + var activeRecording by remember { mutableStateOf(null) } + var activeFile by remember { mutableStateOf(null) } + var isRecording by remember { mutableStateOf(false) } + var isLocked by remember { mutableStateOf(false) } + var durationMs by remember { mutableStateOf(0L) } + var finalizeAction by remember { mutableStateOf(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)