From c5359d698c03ee3fde7b23cec241f15fc22f3ba2 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 29 Nov 2022 16:41:04 +0300 Subject: [PATCH] android: Voice messages enhancements (#1451) * android: Vocie messages enhancements * Canceling voice record when it was disabled in prefs * Quote placement in voice message chat item * Ordering of checks * Showing progress logic was changed * Showing progress logic was changed * Update group prefs without reenter * Optimization of voice chat items * Stop audio playing and recoring when in call --- .../chat/simplex/app/views/chat/ChatView.kt | 19 ++- .../simplex/app/views/chat/ComposeView.kt | 8 +- .../app/views/chat/ComposeVoiceView.kt | 14 +- .../simplex/app/views/chat/SendMsgView.kt | 2 +- .../app/views/chat/group/GroupChatInfoView.kt | 2 +- .../app/views/chat/group/GroupPreferences.kt | 14 +- .../app/views/chat/item/CIVoiceView.kt | 143 ++++++++++-------- .../app/views/chat/item/FramedItemView.kt | 2 +- .../simplex/app/views/helpers/RecAndPlay.kt | 60 +++++--- 9 files changed, 165 insertions(+), 99 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index cf83008efc..45433b49bd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -465,7 +465,7 @@ fun BoxWithConstraintsScope.ChatItemsList( ) { val listState = rememberLazyListState() val scope = rememberCoroutineScope() - ScrollToBottom(chat.id, listState) + ScrollToBottom(chat.id, listState, chatItems) var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) } // Scroll to bottom when search value changes from something to nothing and back LaunchedEffect(searchValue.value.isEmpty()) { @@ -503,7 +503,7 @@ fun BoxWithConstraintsScope.ChatItemsList( } } LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) { - itemsIndexed(reversedChatItems) { i, cItem -> + itemsIndexed(reversedChatItems, key = { _, item -> item.id}) { i, cItem -> CompositionLocalProvider( // Makes horizontal and vertical scrolling to coexist nicely. // With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view @@ -598,7 +598,7 @@ fun BoxWithConstraintsScope.ChatItemsList( } @Composable -private fun ScrollToBottom(chatId: ChatId, listState: LazyListState) { +private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: List) { val scope = rememberCoroutineScope() // Helps to scroll to bottom after moving from Group to Direct chat // and prevents scrolling to bottom on orientation change @@ -610,6 +610,19 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState) { // Don't autoscroll next time until it will be needed shouldAutoScroll = false to chatId } + /* + * Since we use key with each item in LazyColumn, LazyColumn will not autoscroll to bottom item. We need to do it ourselves. + * When the first visible item (from bottom) is fully visible we can autoscroll to 0 item + * */ + LaunchedEffect(Unit) { + snapshotFlow { chatItems.lastOrNull()?.id } + .distinctUntilChanged() + .filter { listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 } + .filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it } + .collect { + listState.animateScrollToItem(0) + } + } } @Composable 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 c8de638bbf..47a78048f1 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 @@ -494,7 +494,7 @@ fun ComposeView( fun cancelVoice() { composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) - chosenContent.value = emptyList() + chosenAudio.value = null } fun cancelFile() { @@ -591,6 +591,12 @@ fun ComposeView( else -> false } } + LaunchedEffect(allowedVoiceByPrefs) { + if (!allowedVoiceByPrefs && chosenAudio.value != null) { + // Voice was disabled right when this user records it, just cancel it + cancelVoice() + } + } SendMsgView( composeState, showVoiceRecordIcon = true, 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 76f92c3dec..ae8c9a9022 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 @@ -39,9 +39,7 @@ fun ComposeVoiceView( .distinctUntilChanged() .collect { val startTime = when { - audioPlaying.value -> progress.value - finishedRecording && progress.value == duration.value -> progress.value - finishedRecording -> 0 + finishedRecording -> progress.value else -> recordedDurationMs } val endTime = when { @@ -71,7 +69,7 @@ fun ComposeVoiceView( IconButton( onClick = { if (!audioPlaying.value) { - AudioPlayer.play(filePath, audioPlaying, progress, duration) + AudioPlayer.play(filePath, audioPlaying, progress, duration, false) } else { AudioPlayer.pause(audioPlaying, progress) } @@ -87,7 +85,13 @@ fun ComposeVoiceView( ) } val numberInText = remember(recordedDurationMs, progress.value) { - derivedStateOf { if (audioPlaying.value) progress.value / 1000 else recordedDurationMs / 1000 } + derivedStateOf { + when { + finishedRecording && progress.value == 0 && !audioPlaying.value -> duration.value / 1000 + finishedRecording -> progress.value / 1000 + else -> recordedDurationMs / 1000 + } + } } Text( durationToString(numberInText.value), 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 894152f4aa..6d27046e0f 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 @@ -113,7 +113,6 @@ fun SendMsgView( } val startStopRecording: () -> Unit = { when { - !permissionsState.allPermissionsGranted -> permissionsState.launchMultiplePermissionRequest() needToAllowVoiceToContact -> { AlertManager.shared.showAlertDialog( title = generalGetString(R.string.allow_voice_messages_question), @@ -124,6 +123,7 @@ fun SendMsgView( ) } !allowedVoiceByPrefs -> showDisabledVoiceAlert() + !permissionsState.allPermissionsGranted -> permissionsState.launchMultiplePermissionRequest() recordingInProgress.value -> stopRecordingAndAddAudio() filePath.value == null -> { recordingTimeRange = System.currentTimeMillis()..0L diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt index 12eee95941..58ddb8a6ee 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt @@ -68,7 +68,7 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) { ModalManager.shared.showModal(true) { GroupPreferencesView( chatModel, - groupInfo + chat.id ) } }, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt index b8d7e31e6e..a6da56449f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt @@ -20,13 +20,15 @@ import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.views.helpers.* @Composable -fun GroupPreferencesView(m: ChatModel, groupInfo: GroupInfo) { - var preferences by remember { mutableStateOf(groupInfo.fullGroupPreferences) } - var currentPreferences by remember { mutableStateOf(preferences) } +fun GroupPreferencesView(m: ChatModel, chatId: String) { + val groupInfo = remember { derivedStateOf { (m.getChat(chatId)?.chatInfo as? ChatInfo.Group)?.groupInfo } } + val gInfo = groupInfo.value ?: return + var preferences by remember(gInfo) { mutableStateOf(gInfo.fullGroupPreferences) } + var currentPreferences by remember(gInfo) { mutableStateOf(preferences) } GroupPreferencesLayout( preferences, currentPreferences, - groupInfo, + gInfo, applyPrefs = { prefs -> preferences = prefs }, @@ -35,8 +37,8 @@ fun GroupPreferencesView(m: ChatModel, groupInfo: GroupInfo) { }, savePrefs = { withApi { - val gp = groupInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences()) - val gInfo = m.controller.apiUpdateGroup(groupInfo.groupId, gp) + val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences()) + val gInfo = m.controller.apiUpdateGroup(gInfo.groupId, gp) if (gInfo != null) { m.updateGroup(gInfo) currentPreferences = preferences diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt index ae912ec7c6..7b7bc849d6 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt @@ -1,6 +1,5 @@ package chat.simplex.app.views.chat.item -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CornerSize @@ -20,7 +19,6 @@ import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* @@ -38,7 +36,7 @@ fun CIVoiceView( longClick: () -> Unit, ) { Row( - Modifier.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 6.dp), + Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = 6.dp, end = 6.dp), verticalAlignment = Alignment.CenterVertically ) { if (file != null) { @@ -55,67 +53,16 @@ fun CIVoiceView( val pause = { AudioPlayer.pause(audioPlaying, progress) } - - val time = if (audioPlaying.value) progress.value else duration.value - val minWidth = with(LocalDensity.current) { 45.sp.toDp() } - val text = durationToString(time / 1000) - if (hasText) { - VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick) - Text( - text, - Modifier - .padding(start = 12.dp, end = 5.dp) - .widthIn(min = minWidth), - color = HighOrLowlight, - fontSize = 16.sp, - textAlign = TextAlign.Start, - maxLines = 1 - ) - } else { - if (sent) { - Row { - Row(verticalAlignment = Alignment.CenterVertically) { - Spacer(Modifier.height(56.dp)) - Text( - text, - Modifier - .padding(end = 12.dp) - .widthIn(min = minWidth), - color = HighOrLowlight, - fontSize = 16.sp, - maxLines = 1 - ) - } - Column { - VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick) - Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) { - CIMetaView(ci, metaColor) - } - } - } - } else { - Row { - Column { - VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick) - Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) { - CIMetaView(ci, metaColor) - } - } - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text, - Modifier - .padding(start = 12.dp) - .widthIn(min = minWidth), - color = HighOrLowlight, - fontSize = 16.sp, - maxLines = 1 - ) - Spacer(Modifier.height(56.dp)) - } + val text = remember { + derivedStateOf { + val time = when { + audioPlaying.value || progress.value != 0 -> progress.value + else -> duration.value } + durationToString(time / 1000) } } + VoiceLayout(file, ci, metaColor, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, play, pause, longClick) } else { VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick) val metaReserve = if (edited) @@ -127,6 +74,73 @@ fun CIVoiceView( } } +@Composable +private fun VoiceLayout( + file: CIFile, + ci: ChatItem, + metaColor: Color, + text: State, + audioPlaying: State, + progress: State, + duration: State, + brokenAudio: Boolean, + sent: Boolean, + hasText: Boolean, + play: () -> Unit, + pause: () -> Unit, + longClick: () -> Unit +) { + when { + hasText -> { + Spacer(Modifier.width(6.dp)) + VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick) + DurationText(text, PaddingValues(start = 12.dp)) + } + sent -> { + Row { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.height(56.dp)) + DurationText(text, PaddingValues(end = 12.dp)) + } + Column { + VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick) + Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) { + CIMetaView(ci, metaColor) + } + } + } + } + else -> { + Row { + Column { + VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick) + Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) { + CIMetaView(ci, metaColor) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + DurationText(text, PaddingValues(start = 12.dp)) + Spacer(Modifier.height(56.dp)) + } + } + } + } +} + +@Composable +private fun DurationText(text: State, padding: PaddingValues) { + val minWidth = with(LocalDensity.current) { 45.sp.toDp() } + Text( + text.value, + Modifier + .padding(padding) + .widthIn(min = minWidth), + color = HighOrLowlight, + fontSize = 16.sp, + maxLines = 1 + ) +} + @Composable private fun PlayPauseButton( audioPlaying: Boolean, @@ -177,12 +191,12 @@ private fun VoiceMsgIndicator( pause: () -> Unit, longClick: () -> Unit ) { - val strokeWidth = with(LocalDensity.current){ 3.dp.toPx() } + val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() } val strokeColor = MaterialTheme.colors.primary if (file != null && file.loaded && progress != null && duration != null) { val angle = 360f * (progress.value.toDouble() / duration.value).toFloat() if (hasText) { - IconButton({ if (!audioPlaying) play() else pause() }, Modifier.drawRingModifier(angle, strokeColor, strokeWidth)) { + IconButton({ if (!audioPlaying) play() else pause() }, Modifier.size(56.dp).drawRingModifier(angle, strokeColor, strokeWidth)) { Icon( imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, contentDescription = null, @@ -196,7 +210,8 @@ private fun VoiceMsgIndicator( } else { if (file?.fileStatus == CIFileStatus.RcvInvitation || file?.fileStatus == CIFileStatus.RcvTransfer - || file?.fileStatus == CIFileStatus.RcvAccepted) { + || file?.fileStatus == CIFileStatus.RcvAccepted + ) { Box( Modifier .size(56.dp) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index b90afe7364..06b04644e1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -175,7 +175,7 @@ fun FramedItemView( } } } - if (ci.content.msgContent !is MsgContent.MCVoice || ci.content.text.isNotEmpty()) { + if (ci.content.msgContent !is MsgContent.MCVoice || ci.content.text.isNotEmpty() || ci.quotedItem != null) { Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) { CIMetaView(ci, metaColor) } 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 76b54ddc40..bf686a71d8 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 @@ -1,6 +1,8 @@ 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_FILESIZE_REACHED import android.os.Build import android.util.Log @@ -94,6 +96,17 @@ object AudioPlayer { .setUsage(AudioAttributes.USAGE_MEDIA) .build() ) + (SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager) + .registerAudioPlaybackCallback(object: AudioPlaybackCallback() { + override fun onPlaybackConfigChanged(configs: MutableList?) { + if (configs?.any { it.audioAttributes.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION } == true) { + // In a process of making a call + RecorderNative.stopRecording?.invoke() + stop() + } + super.onPlaybackConfigChanged(configs) + } + }, null) } private val helperPlayer: MediaPlayer = MediaPlayer().apply { setAudioAttributes( @@ -104,12 +117,15 @@ object AudioPlayer { ) } // Filepath: String, onProgressUpdate - // onProgressUpdate(null) means stop - private val currentlyPlaying: MutableState Unit>?> = mutableStateOf(null) + private val currentlyPlaying: MutableState Unit>?> = mutableStateOf(null) private var progressJob: Job? = null + enum class TrackState { + PLAYING, PAUSED, REPLACED + } + // Returns real duration of the track - private fun start(filePath: String, seek: Int? = null, onProgressUpdate: (position: Int?) -> Unit): Int? { + private fun start(filePath: String, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? { if (!File(filePath).exists()) { Log.e(TAG, "No such file: $filePath") return null @@ -138,16 +154,16 @@ object AudioPlayer { player.start() currentlyPlaying.value = filePath to onProgressUpdate progressJob = CoroutineScope(Dispatchers.Default).launch { - onProgressUpdate(player.currentPosition) + onProgressUpdate(player.currentPosition, TrackState.PLAYING) while(isActive && player.isPlaying) { // Even when current position is equal to duration, the player has isPlaying == true for some time, // so help to make the playback stopped in UI immediately if (player.currentPosition == player.duration) { - onProgressUpdate(player.currentPosition) + onProgressUpdate(player.currentPosition, TrackState.PLAYING) break } delay(50) - onProgressUpdate(player.currentPosition) + onProgressUpdate(player.currentPosition, TrackState.PLAYING) } /* * Since coroutine is still NOT canceled, means player ended (no stop/no pause). But in some cases @@ -155,9 +171,9 @@ object AudioPlayer { * Let's say to a listener that the position == duration in case of coroutine finished without cancel * */ if (isActive) { - onProgressUpdate(player.duration) + onProgressUpdate(player.duration, TrackState.PAUSED) } - onProgressUpdate(null) + onProgressUpdate(null, TrackState.PAUSED) } return player.duration } @@ -170,7 +186,7 @@ object AudioPlayer { } fun stop() { - if (!player.isPlaying) return + if (currentlyPlaying.value == null) return player.stop() stopListener() } @@ -185,11 +201,21 @@ object AudioPlayer { } private fun stopListener() { + val afterCoroutineCancel: CompletionHandler = { + // Notify prev audio listener about stop + currentlyPlaying.value?.second?.invoke(null, TrackState.REPLACED) + currentlyPlaying.value = null + } + /** Preventing race by calling a code AFTER coroutine ends, so [TrackState] will be: + * [TrackState.PLAYING] -> [TrackState.PAUSED] -> [TrackState.REPLACED] (in this order) + * */ + if (progressJob != null) { + progressJob?.invokeOnCompletion(afterCoroutineCancel) + } else { + afterCoroutineCancel(null) + } progressJob?.cancel() progressJob = null - // Notify prev audio listener about stop - currentlyPlaying.value?.second?.invoke(null) - currentlyPlaying.value = null } fun play( @@ -197,21 +223,21 @@ object AudioPlayer { audioPlaying: MutableState, progress: MutableState, duration: MutableState, - resetOnStop: Boolean = false + resetOnEnd: Boolean, ) { if (progress.value == duration.value) { progress.value = 0 } - val realDuration = start(filePath ?: return, progress.value) { pro -> + val realDuration = start(filePath ?: return, progress.value) { pro, state -> if (pro != null) { progress.value = pro } if (pro == null || pro == duration.value) { audioPlaying.value = false - if (resetOnStop) { + if (pro == duration.value) { + progress.value = if (resetOnEnd) 0 else duration.value + } else if (state == TrackState.REPLACED) { progress.value = 0 - } else if (pro == duration.value) { - progress.value = duration.value } } }