android: Voice messages refactoring (#1511)

* android: Voice messages refactoring

* Different way to block text field from editing while recording voice

* Limited voice record max duration

* Better end of recording when it reaches timeout

* New way of doing things

* Change

* Change

* Stop event refactor

* Stopped state

* Replaced some helpers

* Replaced calls in when()

* Comments

* Change

* Change

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
Stanislav Dmitrenko
2022-12-12 12:36:37 +03:00
committed by GitHub
parent 7b4710d198
commit 741b3e8848
7 changed files with 294 additions and 234 deletions
@@ -135,7 +135,7 @@ fun TerminalLayout(
topBar = { CloseSheetBar(close) },
bottomBar = {
Box(Modifier.padding(horizontal = 8.dp)) {
SendMsgView(composeState, false, false, false, sendCommand, ::onMessageChange, { _, _, _ -> }, {}, {}, textStyle)
SendMsgView(composeState, false, mutableStateOf(RecordingState.NotStarted), false, false, false, {}, sendCommand, ::onMessageChange, textStyle)
}
},
modifier = Modifier.navigationBarsWithImePadding()
@@ -19,8 +19,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Reply
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
@@ -41,6 +40,8 @@ import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.views.chat.item.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
@@ -118,6 +119,15 @@ data class ComposeState(
}
}
sealed class RecordingState {
object NotStarted: RecordingState()
class Started(val filePath: String, val progressMs: Int = 0): RecordingState()
class Finished(val filePath: String, val durationMs: Int): RecordingState()
val filePathNullable: String?
get() = (this as? Started)?.filePath
}
fun chatItemPreview(chatItem: ChatItem): ComposePreview {
return when (val mc = chatItem.content.msgContent) {
is MsgContent.MCText -> ComposePreview.NoPreview
@@ -233,6 +243,7 @@ fun ComposeView(
val galleryLauncher = rememberLauncherForActivityResult(contract = PickMultipleFromGallery()) { processPickedImage(it, null) }
val galleryLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) }
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
LaunchedEffect(attachmentOption.value) {
when (attachmentOption.value) {
@@ -340,6 +351,7 @@ fun ComposeView(
fun clearState() {
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
recState.value = RecordingState.NotStarted
textStyle.value = smallFont
chosenContent.value = emptyList()
chosenAudio.value = null
@@ -395,6 +407,7 @@ fun ComposeView(
val file = chosenAudioVal.first.toFile().name
files.add((file))
chatModel.filesToDelete.remove(chosenAudioVal.first.toFile())
AudioPlayer.stop(chosenAudioVal.first.toFile().absolutePath)
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) cs.message else "", chosenAudioVal.second / 1000))
}
}
@@ -466,18 +479,6 @@ fun ComposeView(
}
}
fun showDisabledVoiceAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.voice_messages_prohibited),
text = generalGetString(
if (chat.chatInfo is ChatInfo.Direct)
R.string.ask_your_contact_to_enable_voice
else
R.string.only_group_owners_can_enable_voice
)
)
}
fun cancelLinkPreview() {
val uri = composeState.value.linkPreview?.uri
if (uri != null) {
@@ -493,7 +494,14 @@ fun ComposeView(
}
fun cancelVoice() {
val filePath = recState.value.filePathNullable
recState.value = RecordingState.NotStarted
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
withBGApi {
RecorderNative.stopRecording?.invoke()
AudioPlayer.stop(filePath)
filePath?.let { File(it).delete() }
}
chosenAudio.value = null
}
@@ -576,34 +584,43 @@ fun ComposeView(
)
}
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.voiceMessageAllowed }
val needToAllowVoiceToContact = remember(chat.chatInfo) {
when (chat.chatInfo) {
is ChatInfo.Direct -> with(chat.chatInfo.contact.mergedPreferences.voice) {
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
contactPreference.allow == FeatureAllowed.YES
}
else -> false
}
}
LaunchedEffect(allowedVoiceByPrefs) {
if (!allowedVoiceByPrefs && chosenAudio.value != null) {
// Voice was disabled right when this user records it, just cancel it
cancelVoice()
}
}
val needToAllowVoiceToContact = remember(chat.chatInfo) {
chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) {
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
contactPreference.allow == FeatureAllowed.YES
}
}
LaunchedEffect(Unit) {
snapshotFlow { recState.value }
.distinctUntilChanged()
.collect {
when(it) {
is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false)
is RecordingState.Finished -> onAudioAdded(it.filePath, it.durationMs, true)
is RecordingState.NotStarted -> {}
}
}
}
SendMsgView(
composeState,
showVoiceRecordIcon = true,
allowedVoiceByPrefs = allowedVoiceByPrefs,
needToAllowVoiceToContact = needToAllowVoiceToContact,
recState,
chat.chatInfo is ChatInfo.Direct,
needToAllowVoiceToContact,
allowedVoiceByPrefs,
allowVoiceToContact = ::allowVoiceToContact,
sendMessage = {
sendMessage()
resetLinkPreview()
},
::onMessageChange,
::onAudioAdded,
::allowVoiceToContact,
::showDisabledVoiceAlert,
textStyle
)
}
@@ -46,7 +46,7 @@ fun ComposeVoiceView(
val endTime = when {
finishedRecording -> duration.value
audioPlaying.value -> recordedDurationMs
else -> MAX_VOICE_MILLIS_FOR_SENDING.toInt()
else -> MAX_VOICE_MILLIS_FOR_SENDING
}
val to = ((startTime.toDouble() / endTime) * maxWidth.value).dp
progressBarWidth.animateTo(to.value, audioProgressBarAnimationSpec())
@@ -11,6 +11,7 @@ import android.view.ViewGroup
import android.view.inputmethod.*
import android.widget.EditText
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
@@ -18,10 +19,10 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
@@ -35,185 +36,56 @@ import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.widget.*
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.ChatItem
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.*
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.*
import java.io.*
@Composable
fun SendMsgView(
composeState: MutableState<ComposeState>,
showVoiceRecordIcon: Boolean,
allowedVoiceByPrefs: Boolean,
recState: MutableState<RecordingState>,
isDirectChat: Boolean,
needToAllowVoiceToContact: Boolean,
allowedVoiceByPrefs: Boolean,
allowVoiceToContact: () -> Unit,
sendMessage: () -> Unit,
onMessageChange: (String) -> Unit,
onAudioAdded: (String, Int, Boolean) -> Unit,
allowVoiceToContact: () -> Unit,
showDisabledVoiceAlert: () -> Unit,
textStyle: MutableState<TextStyle>
) {
Column(Modifier.padding(vertical = 8.dp)) {
Box {
val cs = composeState.value
val attachEnabled = !composeState.value.editing
val filePath = rememberSaveable { mutableStateOf(null as String?) }
var recordingTimeRange by rememberSaveable(saver = LongRange.saver) { mutableStateOf(0L..0L) } // since..to
val showVoiceButton = ((cs.message.isEmpty() || recordingTimeRange.first > 0L) && showVoiceRecordIcon && attachEnabled && cs.preview is ComposePreview.NoPreview) || filePath.value != null
Box(if (recordingTimeRange.first == 0L)
Modifier
else
Modifier.clickable(false, onClick = {})
) {
NativeKeyboard(composeState, textStyle, onMessageChange)
}
Box(Modifier.align(Alignment.BottomEnd)) {
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
if (cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.VoicePreview || cs.preview is ComposePreview.FilePreview)) {
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
} else if (!showVoiceButton) {
IconButton(sendMessage, Modifier.size(36.dp), enabled = cs.sendEnabled()) {
Icon(
icon,
stringResource(R.string.icon_descr_send_message),
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.clip(CircleShape)
.background(color)
)
}
} else {
val permissionsState = rememberMultiplePermissionsState(
permissions = listOf(
Manifest.permission.RECORD_AUDIO,
)
)
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
val recordingInProgress: State<Boolean> = remember { rec.recordingInProgress }
var now by remember { mutableStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (isActive) {
now = System.currentTimeMillis()
if (recordingTimeRange.first != 0L && recordingInProgress.value && composeState.value.preview is ComposePreview.VoicePreview) {
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
}
delay(100)
}
}
val stopRecordingAndAddAudio: () -> Unit = {
rec.stop()
recordingTimeRange = recordingTimeRange.first..System.currentTimeMillis()
filePath.value?.let { onAudioAdded(it, (recordingTimeRange.last - recordingTimeRange.first).toInt(), true) }
}
val startStopRecording: () -> Unit = {
when {
needToAllowVoiceToContact -> {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.allow_voice_messages_question),
text = generalGetString(R.string.you_need_to_allow_to_send_voice),
confirmText = generalGetString(R.string.allow_verb),
dismissText = generalGetString(R.string.cancel_verb),
onConfirm = allowVoiceToContact,
)
}
!allowedVoiceByPrefs -> showDisabledVoiceAlert()
!permissionsState.allPermissionsGranted -> permissionsState.launchMultiplePermissionRequest()
recordingInProgress.value -> stopRecordingAndAddAudio()
filePath.value == null -> {
recordingTimeRange = System.currentTimeMillis()..0L
filePath.value = rec.start(stopRecordingAndAddAudio)
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
}
}
}
var stopRecOnNextClick by remember { mutableStateOf(false) }
val context = LocalContext.current
DisposableEffect(stopRecOnNextClick) {
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
if (stopRecOnNextClick) {
// Lock orientation to current orientation because screen rotation will break the recording
activity.requestedOrientation = if (activity.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT)
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
// Unlock orientation
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
}
val cleanUp = { remove: Boolean ->
rec.stop()
AudioPlayer.stop(filePath.value)
if (remove) filePath.value?.let { File(it).delete() }
filePath.value = null
stopRecOnNextClick = false
recordingTimeRange = 0L..0L
}
LaunchedEffect(cs.preview) {
if (cs.preview !is ComposePreview.VoicePreview && filePath.value != null) {
// Pressed on X icon in preview
cleanUp(true)
}
}
val interactionSource = interactionSourceWithTapDetection(
// It's just a key for triggering dropping a state in the compose function. Without it
// nothing will react on changed params like needToAllowVoiceToContact or allowedVoiceByPrefs
needToAllowVoiceToContact.toString() + allowedVoiceByPrefs.toString(),
onPress = {
if (filePath.value == null) startStopRecording()
},
onClick = {
// Voice not allowed or not granted voice record permission for the app
if (!allowedVoiceByPrefs || !permissionsState.allPermissionsGranted) return@interactionSourceWithTapDetection
if (!recordingInProgress.value && filePath.value != null) {
sendMessage()
cleanUp(false)
} else if (stopRecOnNextClick) {
stopRecordingAndAddAudio()
stopRecOnNextClick = false
} else {
// tapped and didn't hold a finger
stopRecOnNextClick = true
}
},
onCancel = startStopRecording,
onRelease = startStopRecording
)
val sendButtonModifier = if (recordingTimeRange.last != 0L)
Modifier.clip(CircleShape).background(color)
else
Modifier
IconButton({}, Modifier.size(36.dp), enabled = !cs.inProgress, interactionSource = interactionSource) {
Icon(
Box(Modifier.padding(vertical = 8.dp)) {
val cs = composeState.value
val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
(cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
NativeKeyboard(composeState, textStyle, onMessageChange)
// Disable clicks on text field
if (cs.preview is ComposePreview.VoicePreview) {
Box(Modifier.matchParentSize().clickable(enabled = false, onClick = { }))
}
Box(Modifier.align(Alignment.BottomEnd)) {
val permissionsState = rememberMultiplePermissionsState(listOf(Manifest.permission.RECORD_AUDIO))
when {
showProgress -> ProgressIndicator()
showVoiceButton -> when {
needToAllowVoiceToContact || !allowedVoiceByPrefs || !permissionsState.allPermissionsGranted -> {
DisallowedVoiceButton {
when {
recordingTimeRange.last != 0L -> Icons.Outlined.ArrowUpward
stopRecOnNextClick -> Icons.Filled.Stop
allowedVoiceByPrefs -> Icons.Filled.KeyboardVoice
else -> Icons.Outlined.KeyboardVoice
},
stringResource(R.string.icon_descr_record_voice_message),
tint = when {
recordingTimeRange.last != 0L -> Color.White
stopRecOnNextClick -> MaterialTheme.colors.primary
allowedVoiceByPrefs -> MaterialTheme.colors.primary
else -> HighOrLowlight
},
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.then(sendButtonModifier)
)
}
DisposableEffect(Unit) {
onDispose {
rec.stop()
needToAllowVoiceToContact -> showNeedToAllowVoiceAlert(allowVoiceToContact)
!allowedVoiceByPrefs -> showDisabledVoiceAlert(isDirectChat)
else -> permissionsState.launchMultiplePermissionRequest()
}
}
}
else -> RecordVoiceView(recState)
}
else -> {
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
SendTextButton(icon, color, cs.sendEnabled(), sendMessage)
}
}
}
@@ -312,6 +184,158 @@ private fun NativeKeyboard(
}
}
@Composable
private fun RecordVoiceView(recState: MutableState<RecordingState>) {
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
DisposableEffect(Unit) { onDispose { rec.stop() } }
val stopRecordingAndAddAudio: () -> Unit = {
recState.value.filePathNullable?.let {
recState.value = RecordingState.Finished(it, rec.stop())
}
}
var stopRecOnNextClick by remember { mutableStateOf(false) }
if (stopRecOnNextClick) {
LaunchedEffect(recState.value) {
if (recState.value is RecordingState.NotStarted) {
stopRecOnNextClick = false
}
}
// Lock orientation to current orientation because screen rotation will break the recording
LockToCurrentOrientationUntilDispose()
StopRecordButton(stopRecordingAndAddAudio)
} else {
val startRecording: () -> Unit = {
recState.value = RecordingState.Started(
filePath = rec.start { progress: Int?, finished: Boolean ->
val state = recState.value
if (state is RecordingState.Started && progress != null) {
recState.value = if (!finished)
RecordingState.Started(state.filePath, progress)
else
RecordingState.Finished(state.filePath, progress)
}
},
)
}
val interactionSource = interactionSourceWithTapDetection(
onPress = { if (recState.value is RecordingState.NotStarted) startRecording() },
onClick = {
if (stopRecOnNextClick) {
stopRecordingAndAddAudio()
} else {
// tapped and didn't hold a finger
stopRecOnNextClick = true
}
},
onCancel = stopRecordingAndAddAudio,
onRelease = stopRecordingAndAddAudio
)
RecordVoiceButton(interactionSource)
}
}
@Composable
private fun DisallowedVoiceButton(onClick: () -> Unit) {
IconButton(onClick, Modifier.size(36.dp)) {
Icon(
Icons.Outlined.KeyboardVoice,
stringResource(R.string.icon_descr_record_voice_message),
tint = HighOrLowlight,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
)
}
}
@Composable
private fun LockToCurrentOrientationUntilDispose() {
val context = LocalContext.current
DisposableEffect(Unit) {
val activity = context as Activity
activity.requestedOrientation = when (activity.display?.rotation) {
android.view.Surface.ROTATION_90 -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
android.view.Surface.ROTATION_180 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
android.view.Surface.ROTATION_270 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
// Unlock orientation
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
}
}
@Composable
private fun StopRecordButton(onClick: () -> Unit) {
IconButton(onClick, Modifier.size(36.dp)) {
Icon(
Icons.Filled.Stop,
stringResource(R.string.icon_descr_record_voice_message),
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
)
}
}
@Composable
private fun RecordVoiceButton(interactionSource: MutableInteractionSource) {
IconButton({}, Modifier.size(36.dp), interactionSource = interactionSource) {
Icon(
Icons.Filled.KeyboardVoice,
stringResource(R.string.icon_descr_record_voice_message),
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
)
}
}
@Composable
private fun ProgressIndicator() {
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
}
@Composable
private fun SendTextButton(icon: ImageVector, backgroundColor: Color, enabled: Boolean, sendMessage: () -> Unit) {
IconButton(sendMessage, Modifier.size(36.dp), enabled = enabled) {
Icon(
icon,
stringResource(R.string.icon_descr_send_message),
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.clip(CircleShape)
.background(backgroundColor)
)
}
}
private fun showNeedToAllowVoiceAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.allow_voice_messages_question),
text = generalGetString(R.string.you_need_to_allow_to_send_voice),
confirmText = generalGetString(R.string.allow_verb),
dismissText = generalGetString(R.string.cancel_verb),
onConfirm = onConfirm,
)
}
private fun showDisabledVoiceAlert(isDirectChat: Boolean) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.voice_messages_prohibited),
text = generalGetString(
if (isDirectChat)
R.string.ask_your_contact_to_enable_voice
else
R.string.only_group_owners_can_enable_voice
)
)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -326,13 +350,13 @@ fun PreviewSendMsgView() {
SendMsgView(
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
showVoiceRecordIcon = false,
allowedVoiceByPrefs = false,
recState = mutableStateOf(RecordingState.NotStarted),
isDirectChat = true,
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true,
allowVoiceToContact = {},
sendMessage = {},
onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
allowVoiceToContact = {},
showDisabledVoiceAlert = {},
textStyle = textStyle
)
}
@@ -353,13 +377,13 @@ fun PreviewSendMsgViewEditing() {
SendMsgView(
composeState = remember { mutableStateOf(composeStateEditing) },
showVoiceRecordIcon = false,
allowedVoiceByPrefs = false,
recState = mutableStateOf(RecordingState.NotStarted),
isDirectChat = true,
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true,
allowVoiceToContact = {},
sendMessage = {},
onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
allowVoiceToContact = {},
showDisabledVoiceAlert = {},
textStyle = textStyle
)
}
@@ -380,13 +404,13 @@ fun PreviewSendMsgViewInProgress() {
SendMsgView(
composeState = remember { mutableStateOf(composeStateInProgress) },
showVoiceRecordIcon = false,
allowedVoiceByPrefs = false,
recState = mutableStateOf(RecordingState.NotStarted),
isDirectChat = true,
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = true,
allowVoiceToContact = {},
sendMessage = {},
onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
allowVoiceToContact = {},
showDisabledVoiceAlert = {},
textStyle = textStyle
)
}
@@ -214,8 +214,8 @@ fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit)
}
@Composable
fun interactionSourceWithTapDetection(key: Any, onPress: () -> Unit, onClick: () -> Unit, onCancel: () -> Unit, onRelease: ()-> Unit): MutableInteractionSource {
val interactionSource = remember(key) { MutableInteractionSource() }
fun interactionSourceWithTapDetection(onPress: () -> Unit, onClick: () -> Unit, onCancel: () -> Unit, onRelease: ()-> Unit): MutableInteractionSource {
val interactionSource = remember { MutableInteractionSource() }
LaunchedEffect(interactionSource) {
var firstTapTime = 0L
interactionSource.interactions.collect { interaction ->
@@ -3,6 +3,7 @@ package chat.simplex.app.views.helpers
import android.content.Context
import android.media.*
import android.media.AudioManager.AudioPlaybackCallback
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED
import android.os.Build
import android.util.Log
@@ -10,16 +11,15 @@ import androidx.compose.runtime.*
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.ChatItem
import chat.simplex.app.views.helpers.AudioPlayer.duration
import kotlinx.coroutines.*
import java.io.*
import java.text.SimpleDateFormat
import java.util.*
interface Recorder {
val recordingInProgress: MutableState<Boolean>
fun start(onStop: () -> Unit): String
fun stop()
fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>)
fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String
fun stop(): Int
}
class RecorderNative(private val recordedBytesLimit: Long): Recorder {
@@ -27,8 +27,10 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
// Allows to stop the recorder from outside without having the recorder in a variable
var stopRecording: (() -> Unit)? = null
}
override val recordingInProgress = mutableStateOf(false)
private var recorder: MediaRecorder? = null
private var progressJob: Job? = null
private var filePath: String? = null
private var recStartedAt: Long? = null
private fun initRecorder() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(SimplexApp.context)
@@ -36,9 +38,8 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
MediaRecorder()
}
override fun start(onStop: () -> Unit): String {
override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String {
AudioPlayer.stop()
recordingInProgress.value = true
val rec: MediaRecorder
recorder = initRecorder().also { rec = it }
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
@@ -47,28 +48,37 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
rec.setAudioChannels(1)
rec.setAudioSamplingRate(16000)
rec.setAudioEncodingBitRate(16000)
rec.setMaxDuration(-1) // TODO set limit
rec.setMaxDuration(MAX_VOICE_MILLIS_FOR_SENDING)
rec.setMaxFileSize(recordedBytesLimit)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val filePath = getAppFilePath(SimplexApp.context, uniqueCombine(SimplexApp.context, getAppFilePath(SimplexApp.context, "voice_${timestamp}.m4a")))
rec.setOutputFile(filePath)
val path = getAppFilePath(SimplexApp.context, uniqueCombine(SimplexApp.context, getAppFilePath(SimplexApp.context, "voice_${timestamp}.m4a")))
filePath = path
rec.setOutputFile(path)
rec.prepare()
rec.start()
rec.setOnInfoListener { mr, what, extra ->
if (what == MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) {
stop()
onStop()
recStartedAt = System.currentTimeMillis()
progressJob = CoroutineScope(Dispatchers.Default).launch {
while(isActive) {
onProgressUpdate(progress(), false)
delay(50)
}
}.apply {
invokeOnCompletion {
onProgressUpdate(realDuration(path), true)
}
}
stopRecording = { stop(); onStop() }
return filePath
rec.setOnInfoListener { _, what, _ ->
if (what == MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED || what == MEDIA_RECORDER_INFO_MAX_DURATION_REACHED) {
stop()
}
}
stopRecording = { stop() }
return path
}
override fun stop() {
if (!recordingInProgress.value) return
override fun stop(): Int {
val path = filePath ?: return 0
stopRecording = null
recordingInProgress.value = false
recorder?.metrics?.
runCatching {
recorder?.stop()
}
@@ -76,16 +86,25 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder {
recorder?.reset()
}
runCatching {
// release all resources
recorder?.release()
}
// Await coroutine finishes in order to send real duration to it's listener
runBlocking {
progressJob?.cancelAndJoin()
}
progressJob = null
filePath = null
recorder = null
return (realDuration(path) ?: 0).also { recStartedAt = null }
}
override fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>) {
stop()
runCatching { File(filePath).delete() }.getOrElse { Log.d(TAG, "Unable to delete a file: ${it.stackTraceToString()}") }
}
private fun progress(): Int? = recStartedAt?.let { (System.currentTimeMillis() - it).toInt() }
/**
* Return real duration from [AudioPlayer] if it's possible (should always be possible).
* As a fallback, return internally counted duration
* */
private fun realDuration(path: String): Int? = duration(path) ?: progress()
}
object AudioPlayer {
@@ -251,8 +270,8 @@ object AudioPlayer {
audioPlaying.value = false
}
fun duration(filePath: String): Int {
var res = 0
fun duration(filePath: String): Int? {
var res: Int? = null
kotlin.runCatching {
helperPlayer.setDataSource(filePath)
helperPlayer.prepare()
@@ -228,7 +228,7 @@ const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE
const val MAX_VOICE_SIZE_FOR_SENDING: Long = 94680 // 6 chunks * 15780 bytes per chunk
const val MAX_VOICE_MILLIS_FOR_SENDING: Long = 43_000 // approximately is ok
const val MAX_VOICE_MILLIS_FOR_SENDING: Int = 43_000
const val MAX_FILE_SIZE: Long = 8000000