android: add in-chat circle recorder with live camera preview
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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(
|
||||||
pendingCaptureCircle = true
|
Manifest.permission.CAMERA,
|
||||||
pendingCaptureVideoFile = file
|
Manifest.permission.RECORD_AUDIO,
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user