From a4be68f4bd087f33a85d696a45369ac1ea67271c Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 18 Nov 2022 20:02:24 +0300 Subject: [PATCH] android: Audio messages (#1070) * Audio messages testing * Without Vorbis * Naming * Voice message auto-receive, voice message composing * Experiments with audio * More recording features * Unused code * Merge master * UI * Stability * Size limitation * Tap and hold && tap and wait and click logics * Deleted unused lib * Voice type * Refactoring * Refactoring * Adapting to the latest changes * Mini player in preview * Different UI for some elements * send msg view style * *** in translation * Animation * Fixes animation performance * Smaller font for recording time * File names * Renaming * No edit possible for audio messages * Prevent adding text to edittext * Bubble layout * Layout * Refactor * Paddings * No crash, please * Draw progress as a ring * Padding * Faster status updates while listening voice * Faster status updates while listening voice * Quote * backend comment * Align * Stability * Review * Strings * Just better * Sync of recorder and players * Replaced Icon's with ImageButton's * Icons size * Error processing * Update apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt * rename composable Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- .../java/chat/simplex/app/MainActivity.kt | 7 + .../java/chat/simplex/app/model/ChatModel.kt | 29 ++ .../java/chat/simplex/app/model/NtfManager.kt | 2 +- .../java/chat/simplex/app/model/SimpleXAPI.kt | 3 + .../chat/simplex/app/views/TerminalView.kt | 2 +- .../chat/simplex/app/views/chat/ChatView.kt | 2 +- .../simplex/app/views/chat/ComposeView.kt | 68 +++- .../app/views/chat/ComposeVoiceView.kt | 124 +++++++ .../simplex/app/views/chat/SendMsgView.kt | 325 +++++++++++++----- .../app/views/chat/item/CIVoiceView.kt | 267 ++++++++++++++ .../app/views/chat/item/ChatItemView.kt | 5 +- .../app/views/chat/item/FramedItemView.kt | 27 +- .../app/views/helpers/AnimationUtils.kt | 2 + .../app/views/helpers/GestureDetector.kt | 18 + .../simplex/app/views/helpers/RecAndPlay.kt | 199 +++++++++++ .../chat/simplex/app/views/helpers/Util.kt | 13 + .../app/src/main/res/values-de/strings.xml | 5 + .../app/src/main/res/values-ru/strings.xml | 5 + .../app/src/main/res/values/strings.xml | 5 + src/Simplex/Chat.hs | 2 + 20 files changed, 990 insertions(+), 120 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index 18817568e0..9a2c4139ae 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -357,6 +357,13 @@ fun MainPage( .collect { if (it != null) currentChatId = it else onComposed() + + // Deletes files that were not sent but already stored in files directory. + // Currently, it's voice records only + if (it == null && chatModel.filesToDelete.isNotEmpty()) { + chatModel.filesToDelete.forEach { it.delete() } + chatModel.filesToDelete.clear() + } } } launch { diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 811e35f968..7fa5a6ac94 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -20,7 +20,11 @@ import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* +import java.io.File +/* + * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it + * */ @Stable class ChatModel(val controller: ChatController) { val onboardingStage = mutableStateOf(null) @@ -71,6 +75,8 @@ class ChatModel(val controller: ChatController) { // working with external intents val sharedContent = mutableStateOf(null as SharedContent?) + val filesToDelete = mutableSetOf() + fun updateUserProfile(profile: LocalProfile) { val user = currentUser.value if (user != null) { @@ -219,6 +225,7 @@ class ChatModel(val controller: ChatController) { if (chatId.value == cInfo.id) { val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { + AudioPlayer.stop(chatItems[itemIndex]) chatItems.removeAt(itemIndex) } } @@ -1030,6 +1037,9 @@ data class ChatItem ( val text: String get() = when { + content.text == "" && file != null && content.msgContent is MsgContent.MCVoice -> { + (content.msgContent as MsgContent.MCVoice).toTextWithDuration(false) + } content.text == "" && file != null -> file.fileName else -> content.text } @@ -1302,6 +1312,8 @@ class CIFile( CIFileStatus.RcvComplete -> true } + val audioInfo: MutableState by lazy { mutableStateOf(ProgressAndDuration()) } + companion object { fun getSample( fileId: Long = 1, @@ -1335,6 +1347,7 @@ sealed class MsgContent { @Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent() + @Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent() @@ -1342,11 +1355,17 @@ sealed class MsgContent { is MCText -> "text $text" is MCLink -> "json ${json.encodeToString(this)}" is MCImage -> "json ${json.encodeToString(this)}" + is MCVoice-> "json ${json.encodeToString(this)}" is MCFile -> "json ${json.encodeToString(this)}" is MCUnknown -> "json $json" } } +fun MsgContent.MCVoice.toTextWithDuration(short: Boolean): String { + val time = String.format("%02d:%02d", duration / 60, duration % 60) + return if (short) time else generalGetString(R.string.voice_message) + " ($time)" +} + @Serializable class CIGroupInvitation ( val groupId: Long, @@ -1415,6 +1434,10 @@ object MsgContentSerializer : KSerializer { val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format" MsgContent.MCImage(text, image) } + "voice" -> { + val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0 + MsgContent.MCVoice(text, duration) + } "file" -> MsgContent.MCFile(text) else -> MsgContent.MCUnknown(t, text, json) } @@ -1446,6 +1469,12 @@ object MsgContentSerializer : KSerializer { put("text", value.text) put("image", value.image) } + is MsgContent.MCVoice -> + buildJsonObject { + put("type", "voice") + put("text", value.text) + put("duration", value.duration) + } is MsgContent.MCFile -> buildJsonObject { put("type", "file") diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt index 014ffe3dff..834f0ae19d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt @@ -212,7 +212,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference if (cItem.content.text != "") { cItem.content.text } else { - cItem.file?.fileName ?: "" + if (cItem.content.msgContent is MsgContent.MCVoice) generalGetString(R.string.voice_message) else cItem.file?.fileName ?: "" } } else { var res = "" diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index df33fe0187..9ee2656e38 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -1014,6 +1014,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a val file = cItem.file if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) { withApi { receiveFile(file.fileId) } + } else if (cItem.content.msgContent is MsgContent.MCVoice && file != null && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) { + withApi { receiveFile(file.fileId) } } if (!cItem.chatDir.sent && !cItem.isCall && !cItem.isMutedMemberEvent && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) { ntfManager.notifyMessageReceived(cInfo, cItem) @@ -1039,6 +1041,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a chatModel.removeChatItem(cInfo, cItem) } else { // currently only broadcast deletion of rcv message can be received, and only this case should happen + AudioPlayer.stop(cItem) chatModel.upsertChatItem(cInfo, cItem) } } 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 33e882f26b..26bf53be20 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, sendCommand, ::onMessageChange, textStyle) + SendMsgView(composeState, false, sendCommand, ::onMessageChange, { _, _, _ -> }, textStyle) } }, modifier = Modifier.navigationBarsWithImePadding() 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 ad9b8fd371..62e5f2f985 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 @@ -94,7 +94,6 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { chatModel.chatId.value = null } else { val chat = activeChat.value!! - BackHandler { chatModel.chatId.value = null } // We need to have real unreadCount value for displaying it inside top right button // Having activeChat reloaded on every change in it is inefficient (UI lags) val unreadCount = remember { @@ -123,6 +122,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { chatModelIncognito = chatModel.incognito.value, back = { hideKeyboard(view) + AudioPlayer.stop() chatModel.chatId.value = null }, info = { 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 f6a13eda21..7d207bac73 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 @@ -1,5 +1,6 @@ package chat.simplex.app.views.chat +import ComposeVoiceView import ComposeFileView import android.Manifest import android.content.* @@ -14,11 +15,9 @@ import android.util.Log import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContract -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme +import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Edit @@ -29,26 +28,30 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import androidx.core.net.toFile +import androidx.core.net.toUri import chat.simplex.app.* import chat.simplex.app.R import chat.simplex.app.model.* +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.runBlocking import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString +import java.io.File @Serializable sealed class ComposePreview { @Serializable object NoPreview: ComposePreview() @Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview() @Serializable class ImagePreview(val images: List): ComposePreview() + @Serializable class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview() @Serializable class FilePreview(val fileName: String): ComposePreview() } @@ -84,6 +87,7 @@ data class ComposeState( get() = { val hasContent = when (preview) { is ComposePreview.ImagePreview -> true + is ComposePreview.VoicePreview -> true is ComposePreview.FilePreview -> true else -> message.isNotEmpty() } @@ -93,6 +97,7 @@ data class ComposeState( get() = when (preview) { is ComposePreview.ImagePreview -> false + is ComposePreview.VoicePreview -> false is ComposePreview.FilePreview -> false else -> useLinkPreviews } @@ -118,11 +123,12 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview { is MsgContent.MCText -> ComposePreview.NoPreview is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview) is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image)) + is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = chatItem.file?.fileName ?: "", mc.duration / 1000, true) is MsgContent.MCFile -> { val fileName = chatItem.file?.fileName ?: "" ComposePreview.FilePreview(fileName) } - else -> ComposePreview.NoPreview + is MsgContent.MCUnknown, null -> ComposePreview.NoPreview } } @@ -144,6 +150,11 @@ fun ComposeView( val textStyle = remember { mutableStateOf(smallFont) } // attachments val chosenContent = rememberSaveable { mutableStateOf>(emptyList()) } + val audioSaver = Saver?>, Pair> ( + save = { it.value.let { if (it == null) null else it.first.toString() to it.second } }, + restore = { mutableStateOf(Uri.parse(it.first) to it.second) } + ) + val chosenAudio = rememberSaveable(saver = audioSaver) { mutableStateOf(null) } val chosenFile = rememberSaveable { mutableStateOf(null) } val cameraLauncher = rememberCameraLauncher { uri: Uri? -> if (uri != null) { @@ -321,6 +332,7 @@ fun ComposeView( is MsgContent.MCText -> checkLinkPreview() is MsgContent.MCLink -> checkLinkPreview() is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image) + is MsgContent.MCVoice -> MsgContent.MCVoice(cs.message, duration = msgContent.duration) is MsgContent.MCFile -> MsgContent.MCFile(cs.message) is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json) } @@ -330,6 +342,7 @@ fun ComposeView( composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) textStyle.value = smallFont chosenContent.value = emptyList() + chosenAudio.value = null chosenFile.value = null linkUrl.value = null prevLinkUrl.value = null @@ -376,6 +389,15 @@ fun ComposeView( } } } + is ComposePreview.VoicePreview -> { + val chosenAudioVal = chosenAudio.value + if (chosenAudioVal != null) { + val file = chosenAudioVal.first.toFile().name + files.add((file)) + chatModel.filesToDelete.remove(chosenAudioVal.first.toFile()) + msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) cs.message else "", chosenAudioVal.second / 1000)) + } + } is ComposePreview.FilePreview -> { val chosenFileVal = chosenFile.value if (chosenFileVal != null) { @@ -426,6 +448,13 @@ fun ComposeView( } } + fun onAudioAdded(filePath: String, durationMs: Int, finished: Boolean) { + val file = File(filePath) + chosenAudio.value = file.toUri() to durationMs + chatModel.filesToDelete.add(file) + composeState.value = composeState.value.copy(preview = ComposePreview.VoicePreview(filePath, durationMs, finished)) + } + fun cancelLinkPreview() { val uri = composeState.value.linkPreview?.uri if (uri != null) { @@ -440,6 +469,11 @@ fun ComposeView( chosenContent.value = emptyList() } + fun cancelVoice() { + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + chosenContent.value = emptyList() + } + fun cancelFile() { composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) chosenFile.value = null @@ -455,6 +489,13 @@ fun ComposeView( ::cancelImages, cancelEnabled = !composeState.value.editing ) + is ComposePreview.VoicePreview -> ComposeVoiceView( + preview.voice, + preview.durationMs, + preview.finished, + cancelEnabled = !composeState.value.editing, + ::cancelVoice + ) is ComposePreview.FilePreview -> ComposeFileView( preview.fileName, ::cancelFile, @@ -489,37 +530,34 @@ fun ComposeView( Column { contextItemView() when { + composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {} composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {} else -> previewView() } Row( - modifier = Modifier.padding(start = 4.dp, end = 8.dp), + modifier = Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(2.dp) ) { - val attachEnabled = !composeState.value.editing - Box(Modifier.padding(bottom = 12.dp)) { + val attachEnabled = !composeState.value.editing && composeState.value.preview !is ComposePreview.VoicePreview + IconButton(showChooseAttachment, enabled = attachEnabled) { Icon( Icons.Filled.AttachFile, contentDescription = stringResource(R.string.attach), - tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray, + tint = if (attachEnabled) MaterialTheme.colors.primary else HighOrLowlight, modifier = Modifier .size(28.dp) .clip(CircleShape) - .clickable { - if (attachEnabled) { - showChooseAttachment() - } - } ) } SendMsgView( composeState, + allowVoiceRecord = true, sendMessage = { sendMessage() resetLinkPreview() }, ::onMessageChange, + ::onAudioAdded, 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 new file mode 100644 index 0000000000..1e46b3ed0f --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt @@ -0,0 +1,124 @@ +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.Close +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.chat.item.AudioInfoUpdater +import chat.simplex.app.views.chat.item.SentColorLight +import chat.simplex.app.views.helpers.* +import kotlinx.coroutines.flow.distinctUntilChanged + +@Composable +fun ComposeVoiceView(filePath: String, durationMs: Int, finished: Boolean, cancelEnabled: Boolean, cancelVoice: () -> Unit) { + BoxWithConstraints(Modifier + .fillMaxWidth() + ) { + val audioPlaying = rememberSaveable { mutableStateOf(false) } + val audioInfo = rememberSaveable(saver = ProgressAndDuration.Saver) { + mutableStateOf(ProgressAndDuration(durationMs = durationMs)) + } + LaunchedEffect(durationMs) { + audioInfo.value = audioInfo.value.copy(durationMs = durationMs) + } + val progressBarWidth = remember { Animatable(0f) } + LaunchedEffect(durationMs, finished) { + snapshotFlow { audioInfo.value } + .distinctUntilChanged() + .collect { + val number = if (audioPlaying.value) audioInfo.value.progressMs else if (!finished) durationMs else 0 + val new = if (audioPlaying.value || finished) + ((number.toDouble() / durationMs) * maxWidth.value).dp + else + (((number.toDouble()) / MAX_VOICE_MILLIS_FOR_SENDING) * maxWidth.value).dp + progressBarWidth.animateTo(new.value, audioProgressBarAnimationSpec()) + } + } + Spacer( + Modifier + .requiredWidth(progressBarWidth.value.dp) + .padding(top = 58.dp) + .height(2.dp) + .background(MaterialTheme.colors.primary) + ) + Row( + Modifier + .height(60.dp) + .fillMaxWidth() + .padding(top = 8.dp) + .background(SentColorLight), + verticalAlignment = Alignment.CenterVertically + ) { + val play = play@{ + audioPlaying.value = AudioPlayer.start(filePath, audioInfo.value.progressMs) { + audioPlaying.value = false + } + } + val pause = { + audioInfo.value = ProgressAndDuration(AudioPlayer.pause(), audioInfo.value.durationMs) + audioPlaying.value = false + } + AudioInfoUpdater(filePath, audioPlaying, audioInfo) + + IconButton({ if (!audioPlaying.value) play() else pause() }, enabled = finished) { + Icon( + if (audioPlaying.value) Icons.Filled.Pause else Icons.Filled.PlayArrow, + stringResource(R.string.icon_descr_file), + Modifier + .padding(start = 4.dp, end = 2.dp) + .size(36.dp), + tint = if (finished) MaterialTheme.colors.primary else HighOrLowlight + ) + } + val numberInText = remember(durationMs, audioInfo.value) { + derivedStateOf { if (audioPlaying.value) audioInfo.value.progressMs / 1000 else durationMs / 1000 } + } + val text = "%02d:%02d".format(numberInText.value / 60, numberInText.value % 60) + Text( + text, + fontSize = 18.sp, + color = HighOrLowlight, + ) + Spacer(Modifier.weight(1f)) + if (cancelEnabled) { + IconButton( + onClick = { + AudioPlayer.stop(filePath) + cancelVoice() + }, + modifier = Modifier.padding(0.dp) + ) { + Icon( + Icons.Outlined.Close, + contentDescription = stringResource(R.string.icon_descr_cancel_file_preview), + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.dp) + ) + } + } + } + } +} + +@Preview +@Composable +fun PreviewComposeAudioView() { + SimpleXTheme { + ComposeFileView( + "test.txt", + cancelFile = {}, + cancelEnabled = true + ) + } +} 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 436419f761..486b9ea277 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 @@ -1,7 +1,10 @@ package chat.simplex.app.views.chat +import android.Manifest import android.annotation.SuppressLint +import android.app.Activity import android.content.Context +import android.content.pm.ActivityInfo import android.content.res.Configuration import android.text.InputType import android.view.ViewGroup @@ -12,38 +15,198 @@ 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.Check -import androidx.compose.material.icons.outlined.ArrowUpward +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.material.ripple.rememberRipple 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.platform.* import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.InputConnectionCompat -import androidx.core.widget.doOnTextChanged +import androidx.core.widget.* import chat.simplex.app.R import chat.simplex.app.SimplexApp import chat.simplex.app.model.ChatItem import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.SharedContent -import kotlinx.coroutines.delay +import chat.simplex.app.views.helpers.* +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import kotlinx.coroutines.* +import java.io.* @Composable fun SendMsgView( composeState: MutableState, + allowVoiceRecord: Boolean, sendMessage: () -> Unit, onMessageChange: (String) -> Unit, + onAudioAdded: (String, Int, Boolean) -> 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) && allowVoiceRecord && 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 { + !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() + 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( + onPress = { + if (filePath.value == null) startStopRecording() + }, + onClick = { + 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( + if (recordingTimeRange.last != 0L) Icons.Outlined.ArrowUpward else if (stopRecOnNextClick) Icons.Default.Stop else Icons.Default.Mic, + stringResource(R.string.icon_descr_record_voice_message), + tint = if (recordingTimeRange.last != 0L) Color.White else if (!cs.inProgress) MaterialTheme.colors.primary else HighOrLowlight, + modifier = Modifier + .size(36.dp) + .padding(4.dp) + .then(sendButtonModifier) + ) + } + DisposableEffect(Unit) { + onDispose { + rec.stop() + } + } + } + } + } + } +} + +@Composable +private fun NativeKeyboard( + composeState: MutableState, + textStyle: MutableState, + onMessageChange: (String) -> Unit ) { val cs = composeState.value + val textColor = MaterialTheme.colors.onBackground + val tintColor = MaterialTheme.colors.secondary + val padding = PaddingValues(12.dp, 7.dp, 45.dp, 0.dp) + val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() } + val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() } + val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() } + val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() } + var showKeyboard by remember { mutableStateOf(false) } LaunchedEffect(cs.contextItem) { when (cs.contextItem) { @@ -58,99 +221,69 @@ fun SendMsgView( } } } - val textColor = MaterialTheme.colors.onBackground - val tintColor = MaterialTheme.colors.secondary - val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() } - val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() } - val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() } - val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() } - Column(Modifier.padding(vertical = 8.dp)) { - Box { - AndroidView(modifier = Modifier, factory = { - val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) { - override fun setOnReceiveContentListener( - mimeTypes: Array?, - listener: android.view.OnReceiveContentListener? - ) { - super.setOnReceiveContentListener(mimeTypes, listener) - } - override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection { - val connection = super.onCreateInputConnection(editorInfo) - EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*")) - val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ -> - try { - inputContentInfo.requestPermission() - } catch (e: Exception) { - return@OnCommitContentListener false - } - SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri)) - true - } - return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit) - } - } - editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) - editText.maxLines = 16 - editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType - editText.setTextColor(textColor.toArgb()) - editText.textSize = textStyle.value.fontSize.value - val drawable = it.getDrawable(R.drawable.send_msg_view_background)!! - DrawableCompat.setTint(drawable, tintColor.toArgb()) - editText.background = drawable - editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom) - editText.setText(cs.message) - editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) } - editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) } - editText - }) { - it.setTextColor(textColor.toArgb()) - it.textSize = textStyle.value.fontSize.value - DrawableCompat.setTint(it.background, tintColor.toArgb()) - if (cs.message != it.text.toString()) { - it.setText(cs.message) - // Set cursor to the end of the text - it.setSelection(it.text.length) - } - if (showKeyboard) { - it.requestFocus() - val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT) - showKeyboard = false - } + AndroidView(modifier = Modifier, factory = { + val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) { + override fun setOnReceiveContentListener( + mimeTypes: Array?, + listener: android.view.OnReceiveContentListener? + ) { + super.setOnReceiveContentListener(mimeTypes, listener) } - 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.FilePreview) - ) { - CircularProgressIndicator( - Modifier - .size(36.dp) - .padding(4.dp), - color = HighOrLowlight, - strokeWidth = 3.dp - ) - } else { - Icon( - icon, - stringResource(R.string.icon_descr_send_message), - tint = Color.White, - modifier = Modifier - .size(36.dp) - .padding(4.dp) - .clip(CircleShape) - .background(color) - .clickable { - if (cs.sendEnabled()) { - sendMessage() - } - } - ) + override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection { + val connection = super.onCreateInputConnection(editorInfo) + EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*")) + val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ -> + try { + inputContentInfo.requestPermission() + } catch (e: Exception) { + return@OnCommitContentListener false + } + SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri)) + true } + return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit) } } + editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + editText.maxLines = 16 + editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType + editText.setTextColor(textColor.toArgb()) + editText.textSize = textStyle.value.fontSize.value + val drawable = it.getDrawable(R.drawable.send_msg_view_background)!! + DrawableCompat.setTint(drawable, tintColor.toArgb()) + editText.background = drawable + editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom) + editText.setText(cs.message) + editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) } + editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) } + editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") } + editText + }) { + it.setTextColor(textColor.toArgb()) + it.textSize = textStyle.value.fontSize.value + DrawableCompat.setTint(it.background, tintColor.toArgb()) + it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview + it.isFocusableInTouchMode = it.isFocusable + if (cs.message != it.text.toString()) { + it.setText(cs.message) + // Set cursor to the end of the text + it.setSelection(it.text.length) + } + if (showKeyboard) { + it.requestFocus() + val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT) + showKeyboard = false + } + } + if (composeState.value.preview is ComposePreview.VoicePreview) { + Text( + generalGetString(R.string.voice_message_send_text), + Modifier.padding(padding), + color = HighOrLowlight, + style = textStyle.value.copy(fontStyle = FontStyle.Italic) + ) } } @@ -167,8 +300,10 @@ fun PreviewSendMsgView() { SimpleXTheme { SendMsgView( composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, + allowVoiceRecord = false, sendMessage = {}, onMessageChange = { _ -> }, + onAudioAdded = { _, _, _ -> }, textStyle = textStyle ) } @@ -188,8 +323,10 @@ fun PreviewSendMsgViewEditing() { SimpleXTheme { SendMsgView( composeState = remember { mutableStateOf(composeStateEditing) }, + allowVoiceRecord = false, sendMessage = {}, onMessageChange = { _ -> }, + onAudioAdded = { _, _, _ -> }, textStyle = textStyle ) } @@ -209,8 +346,10 @@ fun PreviewSendMsgViewInProgress() { SimpleXTheme { SendMsgView( composeState = remember { mutableStateOf(composeStateInProgress) }, + allowVoiceRecord = false, sendMessage = {}, onMessageChange = { _ -> }, + onAudioAdded = { _, _, _ -> }, textStyle = textStyle ) } 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 new file mode 100644 index 0000000000..cf287c42f2 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt @@ -0,0 +1,267 @@ +package chat.simplex.app.views.chat.item + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +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.* +import chat.simplex.app.views.helpers.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +@Composable +fun CIVoiceView( + durationSec: Int, + file: CIFile?, + edited: Boolean, + sent: Boolean, + hasText: Boolean, + ci: ChatItem, + metaColor: Color +) { + Row( + Modifier.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (file != null) { + val context = LocalContext.current + val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(context, file) } + var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) } + val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) } + val audioInfo = remember(file.filePath) { + file.audioInfo.value = file.audioInfo.value.copy(durationMs = durationSec * 1000) + file.audioInfo + } + val play = play@{ + audioPlaying.value = AudioPlayer.start(filePath ?: return@play, audioInfo.value.progressMs) { + // If you want to preserve the position after switching a track, remove this line + audioInfo.value = ProgressAndDuration(0, audioInfo.value.durationMs) + audioPlaying.value = false + } + brokenAudio = !audioPlaying.value + } + val pause = { + audioInfo.value = ProgressAndDuration(AudioPlayer.pause(), audioInfo.value.durationMs) + audioPlaying.value = false + } + AudioInfoUpdater(filePath, audioPlaying, audioInfo) + + val time = if (audioPlaying.value) audioInfo.value.progressMs else audioInfo.value.durationMs + val minWidth = with(LocalDensity.current) { 45.sp.toDp() } + val text = String.format("%02d:%02d", time / 1000 / 60, time / 1000 % 60) + if (hasText) { + VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause) + 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, audioInfo, brokenAudio, play, pause) + Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) { + CIMetaView(ci, metaColor) + } + } + } + } else { + Row { + Column { + VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause) + 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)) + } + } + } + } + } else { + VoiceMsgIndicator(null, false, sent, hasText, null, false, {}, {}) + val metaReserve = if (edited) + " " + else + " " + Text(metaReserve) + } + } +} + +@Composable +private fun PlayPauseButton( + audioPlaying: Boolean, + sent: Boolean, + angle: Float, + strokeWidth: Float, + strokeColor: Color, + enabled: Boolean, + error: Boolean, + play: () -> Unit, + pause: () -> Unit +) { + Surface( + onClick = { if (!audioPlaying) play() else pause() }, + Modifier.drawRingModifier(angle, strokeColor, strokeWidth), + color = if (sent) SentColorLight else ReceivedColorLight, + shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50)) + ) { + Box( + Modifier + .defaultMinSize(minWidth = 56.dp, minHeight = 56.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = null, + Modifier.size(36.dp), + tint = if (error) WarningOrange else if (!enabled) HighOrLowlight else MaterialTheme.colors.primary + ) + } + } +} + +@Composable +private fun VoiceMsgIndicator( + file: CIFile?, + audioPlaying: Boolean, + sent: Boolean, + hasText: Boolean, + audioInfo: State?, + error: Boolean, + play: () -> Unit, + pause: () -> Unit +) { + val strokeWidth = with(LocalDensity.current){ 3.dp.toPx() } + val strokeColor = MaterialTheme.colors.primary + if (file != null && file.loaded && audioInfo != null) { + val angle = 360f * (audioInfo.value.progressMs.toDouble() / audioInfo.value.durationMs).toFloat() + if (hasText) { + IconButton({ if (!audioPlaying) play() else pause() }, Modifier.drawRingModifier(angle, strokeColor, strokeWidth)) { + Icon( + imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = null, + Modifier.size(36.dp), + tint = MaterialTheme.colors.primary + ) + } + } else { + PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause) + } + } else { + if (file?.fileStatus == CIFileStatus.RcvInvitation + || file?.fileStatus == CIFileStatus.RcvTransfer + || file?.fileStatus == CIFileStatus.RcvAccepted) { + Box( + Modifier + .size(56.dp) + .clip(RoundedCornerShape(4.dp)), + contentAlignment = Alignment.Center + ) { + ProgressIndicator() + } + } else { + PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, false, false, {}, {}) + } + } +} + +private fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = drawWithCache { + val brush = Brush.linearGradient( + 0f to Color.Transparent, + 0f to color, + start = Offset(0f, 0f), + end = Offset(strokeWidth, strokeWidth), + tileMode = TileMode.Clamp + ) + onDrawWithContent { + drawContent() + drawArc( + brush = brush, + startAngle = -90f, + sweepAngle = angle, + useCenter = false, + topLeft = Offset(strokeWidth / 2, strokeWidth / 2), + size = Size(size.width - strokeWidth, size.height - strokeWidth), + style = Stroke(width = strokeWidth, cap = StrokeCap.Square) + ) + } +} + +@Composable +private fun ProgressIndicator() { + CircularProgressIndicator( + Modifier.size(32.dp), + color = if (isInDarkTheme()) FileDark else FileLight, + strokeWidth = 4.dp + ) +} + +@Composable +fun AudioInfoUpdater( + filePath: String?, + audioPlaying: MutableState, + audioInfo: MutableState +) { + LaunchedEffect(filePath) { + if (filePath != null && audioInfo.value.durationMs == 0) { + audioInfo.value = ProgressAndDuration(audioInfo.value.progressMs, AudioPlayer.duration(filePath)) + } + } + LaunchedEffect(audioPlaying.value) { + while (isActive && audioPlaying.value) { + audioInfo.value = AudioPlayer.progressAndDurationOrEnded() + if (audioInfo.value.progressMs == audioInfo.value.durationMs) { + audioInfo.value = ProgressAndDuration(0, audioInfo.value.durationMs) + audioPlaying.value = false + } + delay(50) + } + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 557399de6b..2e491cdbc4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -100,20 +100,21 @@ fun ChatItemView( copyText(context, cItem.content.text) showMenu.value = false }) - if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) { + if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) { val filePath = getLoadedFilePath(context, cItem.file) if (filePath != null) { ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = { when (cItem.content.msgContent) { is MsgContent.MCImage -> saveImage(context, cItem.file) is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName) + is MsgContent.MCVoice -> saveFileLauncher.launch(cItem.file?.fileName) else -> {} } showMenu.value = false }) } } - if (cItem.meta.editable) { + if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice) { ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = { composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) showMenu.value = false 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 61bb6651fb..2af711c9c3 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 @@ -6,6 +6,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.InsertDriveFile +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -56,8 +57,12 @@ fun FramedItemView( Modifier.padding(vertical = 6.dp, horizontal = 12.dp), contentAlignment = Alignment.TopStart ) { + val text = if (qi.content is MsgContent.MCVoice && qi.text.isEmpty()) + qi.content.toTextWithDuration(true) + else + qi.text MarkdownText( - qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3, + text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3, style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface) ) } @@ -87,13 +92,13 @@ fun FramedItemView( modifier = Modifier.size(68.dp).clipToBounds() ) } - is MsgContent.MCFile -> { + is MsgContent.MCFile, is MsgContent.MCVoice -> { Box(Modifier.fillMaxWidth().weight(1f)) { ciQuotedMsgView(qi) } Icon( - Icons.Filled.InsertDriveFile, - stringResource(R.string.icon_descr_file), + if (qi.content is MsgContent.MCFile) Icons.Filled.InsertDriveFile else Icons.Filled.PlayArrow, + if (qi.content is MsgContent.MCFile) stringResource(R.string.icon_descr_file) else stringResource(R.string.voice_message), Modifier .padding(top = 6.dp, end = 4.dp) .size(22.dp), @@ -105,7 +110,7 @@ fun FramedItemView( } } - val transparentBackground = ci.content.msgContent is MsgContent.MCImage && ci.content.text.isEmpty() && ci.quotedItem == null + val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVoice) && ci.content.text.isEmpty() && ci.quotedItem == null Box(Modifier .clip(RoundedCornerShape(18.dp)) .background( @@ -142,6 +147,12 @@ fun FramedItemView( CIMarkdownText(ci, showMember, uriHandler) } } + is MsgContent.MCVoice -> { + CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, mc.text != "" || ci.quotedItem != null, ci, metaColor) + if (mc.text != "") { + CIMarkdownText(ci, showMember, uriHandler) + } + } is MsgContent.MCFile -> { CIFileView(ci.file, ci.meta.itemEdited, receiveFile) if (mc.text != "") { @@ -157,8 +168,10 @@ fun FramedItemView( } } } - Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) { - CIMetaView(ci, metaColor) + if (ci.content.msgContent !is MsgContent.MCVoice || ci.content.text.isNotEmpty()) { + 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/AnimationUtils.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AnimationUtils.kt index 200cbaa6d7..15f507af62 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AnimationUtils.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AnimationUtils.kt @@ -5,3 +5,5 @@ import androidx.compose.animation.core.* fun chatListAnimationSpec() = tween(durationMillis = 250, easing = FastOutSlowInEasing) fun newChatSheetAnimSpec() = tween(256, 0, LinearEasing) + +fun audioProgressBarAnimationSpec() = tween(durationMillis = 30, easing = LinearEasing) 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 dcfcf6e7c6..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 @@ -213,6 +213,24 @@ fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit) return interactionSource } +@Composable +fun interactionSourceWithTapDetection(onPress: () -> Unit, onClick: () -> Unit, onCancel: () -> Unit, onRelease: ()-> Unit): MutableInteractionSource { + val interactionSource = remember { MutableInteractionSource() } + LaunchedEffect(interactionSource) { + var firstTapTime = 0L + interactionSource.interactions.collect { interaction -> + when (interaction) { + is PressInteraction.Press -> { + firstTapTime = System.currentTimeMillis(); onPress() + } + is PressInteraction.Release -> if (firstTapTime + 1000L < System.currentTimeMillis()) onRelease() else onClick() + is PressInteraction.Cancel -> onCancel() + } + } + } + return interactionSource +} + suspend fun PointerInputScope.detectTransformGestures( allowIntercept: () -> Boolean, panZoomLock: Boolean = false, 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 new file mode 100644 index 0000000000..89c4d35ae8 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt @@ -0,0 +1,199 @@ +package chat.simplex.app.views.helpers + +import android.media.* +import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED +import android.os.Build +import android.util.Log +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.Saver +import chat.simplex.app.* +import chat.simplex.app.R +import chat.simplex.app.model.ChatItem +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) +} + +data class ProgressAndDuration( + val progressMs: Int = 0, + val durationMs: Int = 0 +) { + companion object { + val Saver + get() = Saver, Pair>( + save = { it.value.progressMs to it.value.durationMs }, + restore = { mutableStateOf(ProgressAndDuration(it.first, it.second)) } + ) + } +} + +class RecorderNative(private val recordedBytesLimit: Long): Recorder { + companion object { + // 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 fun initRecorder() = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(SimplexApp.context) + } else { + MediaRecorder() + } + + override fun start(onStop: () -> Unit): String { + AudioPlayer.stop() + recordingInProgress.value = true + val rec: MediaRecorder + recorder = initRecorder().also { rec = it } + rec.setAudioSource(MediaRecorder.AudioSource.MIC) + rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + rec.setAudioChannels(1) + rec.setAudioSamplingRate(16000) + rec.setAudioEncodingBitRate(16000) + rec.setMaxDuration(-1) + 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) + rec.prepare() + rec.start() + rec.setOnInfoListener { mr, what, extra -> + if (what == MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) { + stop() + onStop() + } + } + stopRecording = { stop(); onStop() } + return filePath + } + + override fun stop() { + if (!recordingInProgress.value) return + stopRecording = null + recordingInProgress.value = false + recorder?.metrics?. + runCatching { + recorder?.stop() + } + runCatching { + recorder?.reset() + } + runCatching { + // release all resources + recorder?.release() + } + recorder = null + } + + override fun cancel(filePath: String, recordingInProgress: MutableState) { + stop() + runCatching { File(filePath).delete() }.getOrElse { Log.d(TAG, "Unable to delete a file: ${it.stackTraceToString()}") } + } +} + +object AudioPlayer { + private val player = MediaPlayer().apply { + setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + ) + } + private val helperPlayer: MediaPlayer = MediaPlayer().apply { + setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + ) + } + // Filepath: String, onStop: () -> Unit + private val currentlyPlaying: MutableState Unit>?> = mutableStateOf(null) + + fun start(filePath: String, seek: Int? = null, onStop: () -> Unit): Boolean { + if (!File(filePath).exists()) { + Log.e(TAG, "No such file: $filePath") + return false + } + + RecorderNative.stopRecording?.invoke() + val current = currentlyPlaying.value + if (current == null || current.first != filePath) { + player.reset() + // Notify prev audio listener about stop + current?.second?.invoke() + runCatching { + player.setDataSource(filePath) + }.onFailure { + Log.e(TAG, it.stackTraceToString()) + AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message) + return false + } + runCatching { player.prepare() }.onFailure { + // Can happen when audio file is broken + Log.e(TAG, it.stackTraceToString()) + AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message) + return false + } + } + if (seek != null) player.seekTo(seek) + player.start() + // Repeated calls to play/pause on the same track will not recompose all dependent views + if (currentlyPlaying.value?.first != filePath) { + currentlyPlaying.value = filePath to onStop + } + return true + } + + fun pause(): Int { + player.pause() + return player.currentPosition + } + + fun stop() { + if (!player.isPlaying) return + // Notify prev audio listener about stop + currentlyPlaying.value?.second?.invoke() + currentlyPlaying.value = null + player.stop() + } + + fun stop(item: ChatItem) = stop(item.file?.fileName) + + // FileName or filePath are ok + fun stop(fileName: String?) { + if (fileName != null && currentlyPlaying.value?.first?.endsWith(fileName) == true) { + stop() + } + } + + /** + * If player starts playing at 2637 ms in a track 2816 ms long (these numbers are just an example), + * it will stop immediately after start but will not change currentPosition, so it will not be equal to duration. + * However, it sets isPlaying to false. Let's do it ourselves in order to prevent endless waiting loop + * */ + fun progressAndDurationOrEnded(): ProgressAndDuration = + ProgressAndDuration(if (player.isPlaying) player.currentPosition else player.duration, player.duration) + + fun duration(filePath: String): Int { + var res = 0 + kotlin.runCatching { + helperPlayer.setDataSource(filePath) + helperPlayer.prepare() + helperPlayer.start() + helperPlayer.stop() + res = helperPlayer.duration + helperPlayer.reset() + } + return res + } +} 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 6272dc4f1e..28ba569159 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 @@ -19,6 +19,7 @@ import android.view.ViewTreeObserver import android.view.inputmethod.InputMethodManager import androidx.annotation.StringRes import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.Saver import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.* import androidx.compose.ui.text.* @@ -27,6 +28,7 @@ import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.* import androidx.core.content.FileProvider +import androidx.core.net.toFile import androidx.core.text.HtmlCompat import chat.simplex.app.* import chat.simplex.app.model.CIFile @@ -220,6 +222,11 @@ private fun spannableStringToAnnotatedString( // maximum image file size to be auto-accepted const val MAX_IMAGE_SIZE: Long = 236700 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_FILE_SIZE: Long = 8000000 fun getFilesDirectory(context: Context): String { @@ -449,3 +456,9 @@ fun Color.darker(factor: Float = 0.1f): Color = fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT) fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT) + +val LongRange.Companion.saver + get() = Saver, Pair>( + save = { it.value.first to it.value.last }, + restore = { mutableStateOf(it.first..it.second) } + ) diff --git a/apps/android/app/src/main/res/values-de/strings.xml b/apps/android/app/src/main/res/values-de/strings.xml index 52df645698..89646d8c1b 100644 --- a/apps/android/app/src/main/res/values-de/strings.xml +++ b/apps/android/app/src/main/res/values-de/strings.xml @@ -208,6 +208,10 @@ Datei nicht gefunden Fehler beim Speichern der Datei + + ***Voice message + ***Voice message… + Benachrichtigungen @@ -225,6 +229,7 @@ Nachricht senden + ***Record voice message Zurück diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 27e6d64a6b..ae96bf780a 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -208,6 +208,10 @@ Файл не найден Ошибка сохранения файла + + Голосовое сообщение + Голосовое сообщение… + Уведомления @@ -225,6 +229,7 @@ Отправить сообщение + Записать голосовое сообщение Назад diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index c251ad1867..06dbb3555b 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -208,6 +208,10 @@ File not found Error saving file + + Voice message + Voice message… + Notifications @@ -225,6 +229,7 @@ Send Message + Record voice message Back diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e42d8174c4..3965b6087c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -386,6 +386,8 @@ processChatCommand = \case | otherwise = case qmc of MCImage _ image -> MCImage qTextOrFile image MCFile _ -> MCFile qTextOrFile + -- consider same for voice messages + -- MCVoice _ voice -> MCVoice qTextOrFile voice _ -> qmc where -- if the message we're quoting with is one of the "large" MsgContents