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
This commit is contained in:
Stanislav Dmitrenko
2022-11-29 16:41:04 +03:00
committed by GitHub
parent acd72fb269
commit c5359d698c
9 changed files with 165 additions and 99 deletions
@@ -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<ChatItem>) {
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
@@ -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,
@@ -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),
@@ -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
@@ -68,7 +68,7 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
ModalManager.shared.showModal(true) {
GroupPreferencesView(
chatModel,
groupInfo
chat.id
)
}
},
@@ -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
@@ -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<String>,
audioPlaying: State<Boolean>,
progress: State<Int>,
duration: State<Int>,
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<String>, 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)
@@ -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)
}
@@ -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<AudioPlaybackConfiguration>?) {
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<Pair<String, (position: Int?) -> Unit>?> = mutableStateOf(null)
private val currentlyPlaying: MutableState<Pair<String, (position: Int?, state: TrackState) -> 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<Boolean>,
progress: MutableState<Int>,
duration: MutableState<Int>,
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
}
}
}