diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index c512df703d..33163ecc1c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -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() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index b5decc4909..fcda0cdcdd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -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 = 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 ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt index c434b8eafb..64bd3f43bb 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt @@ -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()) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 246356aa8e..942593cfa3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -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, showVoiceRecordIcon: Boolean, - allowedVoiceByPrefs: Boolean, + recState: MutableState, + isDirectChat: Boolean, needToAllowVoiceToContact: Boolean, + allowedVoiceByPrefs: Boolean, + allowVoiceToContact: () -> Unit, sendMessage: () -> Unit, onMessageChange: (String) -> Unit, - onAudioAdded: (String, Int, Boolean) -> Unit, - allowVoiceToContact: () -> Unit, - showDisabledVoiceAlert: () -> Unit, textStyle: MutableState ) { - 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 = 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) { + 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 ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt index 633d53c37d..41d6c73190 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt @@ -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 -> diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt index bf686a71d8..5b8a198983 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt @@ -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 - fun start(onStop: () -> Unit): String - fun stop() - fun cancel(filePath: String, recordingInProgress: MutableState) + 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) { - 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() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index 15cb2ee774..a620b6725e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -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