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>
This commit is contained in:
parent
0cb8f8ad82
commit
a4be68f4bd
@ -357,6 +357,13 @@ fun MainPage(
|
|||||||
.collect {
|
.collect {
|
||||||
if (it != null) currentChatId = it
|
if (it != null) currentChatId = it
|
||||||
else onComposed()
|
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 {
|
launch {
|
||||||
|
@ -20,7 +20,11 @@ import kotlinx.serialization.descriptors.*
|
|||||||
import kotlinx.serialization.encoding.Decoder
|
import kotlinx.serialization.encoding.Decoder
|
||||||
import kotlinx.serialization.encoding.Encoder
|
import kotlinx.serialization.encoding.Encoder
|
||||||
import kotlinx.serialization.json.*
|
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
|
@Stable
|
||||||
class ChatModel(val controller: ChatController) {
|
class ChatModel(val controller: ChatController) {
|
||||||
val onboardingStage = mutableStateOf<OnboardingStage?>(null)
|
val onboardingStage = mutableStateOf<OnboardingStage?>(null)
|
||||||
@ -71,6 +75,8 @@ class ChatModel(val controller: ChatController) {
|
|||||||
// working with external intents
|
// working with external intents
|
||||||
val sharedContent = mutableStateOf(null as SharedContent?)
|
val sharedContent = mutableStateOf(null as SharedContent?)
|
||||||
|
|
||||||
|
val filesToDelete = mutableSetOf<File>()
|
||||||
|
|
||||||
fun updateUserProfile(profile: LocalProfile) {
|
fun updateUserProfile(profile: LocalProfile) {
|
||||||
val user = currentUser.value
|
val user = currentUser.value
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
@ -219,6 +225,7 @@ class ChatModel(val controller: ChatController) {
|
|||||||
if (chatId.value == cInfo.id) {
|
if (chatId.value == cInfo.id) {
|
||||||
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
||||||
if (itemIndex >= 0) {
|
if (itemIndex >= 0) {
|
||||||
|
AudioPlayer.stop(chatItems[itemIndex])
|
||||||
chatItems.removeAt(itemIndex)
|
chatItems.removeAt(itemIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1030,6 +1037,9 @@ data class ChatItem (
|
|||||||
|
|
||||||
val text: String get() =
|
val text: String get() =
|
||||||
when {
|
when {
|
||||||
|
content.text == "" && file != null && content.msgContent is MsgContent.MCVoice -> {
|
||||||
|
(content.msgContent as MsgContent.MCVoice).toTextWithDuration(false)
|
||||||
|
}
|
||||||
content.text == "" && file != null -> file.fileName
|
content.text == "" && file != null -> file.fileName
|
||||||
else -> content.text
|
else -> content.text
|
||||||
}
|
}
|
||||||
@ -1302,6 +1312,8 @@ class CIFile(
|
|||||||
CIFileStatus.RcvComplete -> true
|
CIFileStatus.RcvComplete -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val audioInfo: MutableState<ProgressAndDuration> by lazy { mutableStateOf(ProgressAndDuration()) }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun getSample(
|
fun getSample(
|
||||||
fileId: Long = 1,
|
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 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 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 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 MCFile(override val text: String): MsgContent()
|
||||||
@Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): 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 MCText -> "text $text"
|
||||||
is MCLink -> "json ${json.encodeToString(this)}"
|
is MCLink -> "json ${json.encodeToString(this)}"
|
||||||
is MCImage -> "json ${json.encodeToString(this)}"
|
is MCImage -> "json ${json.encodeToString(this)}"
|
||||||
|
is MCVoice-> "json ${json.encodeToString(this)}"
|
||||||
is MCFile -> "json ${json.encodeToString(this)}"
|
is MCFile -> "json ${json.encodeToString(this)}"
|
||||||
is MCUnknown -> "json $json"
|
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
|
@Serializable
|
||||||
class CIGroupInvitation (
|
class CIGroupInvitation (
|
||||||
val groupId: Long,
|
val groupId: Long,
|
||||||
@ -1415,6 +1434,10 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
|||||||
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
|
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
|
||||||
MsgContent.MCImage(text, image)
|
MsgContent.MCImage(text, image)
|
||||||
}
|
}
|
||||||
|
"voice" -> {
|
||||||
|
val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
|
||||||
|
MsgContent.MCVoice(text, duration)
|
||||||
|
}
|
||||||
"file" -> MsgContent.MCFile(text)
|
"file" -> MsgContent.MCFile(text)
|
||||||
else -> MsgContent.MCUnknown(t, text, json)
|
else -> MsgContent.MCUnknown(t, text, json)
|
||||||
}
|
}
|
||||||
@ -1446,6 +1469,12 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
|||||||
put("text", value.text)
|
put("text", value.text)
|
||||||
put("image", value.image)
|
put("image", value.image)
|
||||||
}
|
}
|
||||||
|
is MsgContent.MCVoice ->
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "voice")
|
||||||
|
put("text", value.text)
|
||||||
|
put("duration", value.duration)
|
||||||
|
}
|
||||||
is MsgContent.MCFile ->
|
is MsgContent.MCFile ->
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("type", "file")
|
put("type", "file")
|
||||||
|
@ -212,7 +212,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
if (cItem.content.text != "") {
|
if (cItem.content.text != "") {
|
||||||
cItem.content.text
|
cItem.content.text
|
||||||
} else {
|
} else {
|
||||||
cItem.file?.fileName ?: ""
|
if (cItem.content.msgContent is MsgContent.MCVoice) generalGetString(R.string.voice_message) else cItem.file?.fileName ?: ""
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var res = ""
|
var res = ""
|
||||||
|
@ -1014,6 +1014,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
|||||||
val file = cItem.file
|
val file = cItem.file
|
||||||
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
|
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
|
||||||
withApi { receiveFile(file.fileId) }
|
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)) {
|
if (!cItem.chatDir.sent && !cItem.isCall && !cItem.isMutedMemberEvent && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
|
||||||
ntfManager.notifyMessageReceived(cInfo, cItem)
|
ntfManager.notifyMessageReceived(cInfo, cItem)
|
||||||
@ -1039,6 +1041,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
|
|||||||
chatModel.removeChatItem(cInfo, cItem)
|
chatModel.removeChatItem(cInfo, cItem)
|
||||||
} else {
|
} else {
|
||||||
// currently only broadcast deletion of rcv message can be received, and only this case should happen
|
// currently only broadcast deletion of rcv message can be received, and only this case should happen
|
||||||
|
AudioPlayer.stop(cItem)
|
||||||
chatModel.upsertChatItem(cInfo, cItem)
|
chatModel.upsertChatItem(cInfo, cItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -135,7 +135,7 @@ fun TerminalLayout(
|
|||||||
topBar = { CloseSheetBar(close) },
|
topBar = { CloseSheetBar(close) },
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
Box(Modifier.padding(horizontal = 8.dp)) {
|
Box(Modifier.padding(horizontal = 8.dp)) {
|
||||||
SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle)
|
SendMsgView(composeState, false, sendCommand, ::onMessageChange, { _, _, _ -> }, textStyle)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.navigationBarsWithImePadding()
|
modifier = Modifier.navigationBarsWithImePadding()
|
||||||
|
@ -94,7 +94,6 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
|||||||
chatModel.chatId.value = null
|
chatModel.chatId.value = null
|
||||||
} else {
|
} else {
|
||||||
val chat = activeChat.value!!
|
val chat = activeChat.value!!
|
||||||
BackHandler { chatModel.chatId.value = null }
|
|
||||||
// We need to have real unreadCount value for displaying it inside top right button
|
// 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)
|
// Having activeChat reloaded on every change in it is inefficient (UI lags)
|
||||||
val unreadCount = remember {
|
val unreadCount = remember {
|
||||||
@ -123,6 +122,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
|||||||
chatModelIncognito = chatModel.incognito.value,
|
chatModelIncognito = chatModel.incognito.value,
|
||||||
back = {
|
back = {
|
||||||
hideKeyboard(view)
|
hideKeyboard(view)
|
||||||
|
AudioPlayer.stop()
|
||||||
chatModel.chatId.value = null
|
chatModel.chatId.value = null
|
||||||
},
|
},
|
||||||
info = {
|
info = {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package chat.simplex.app.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
|
import ComposeVoiceView
|
||||||
import ComposeFileView
|
import ComposeFileView
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.*
|
import android.content.*
|
||||||
@ -14,11 +15,9 @@ import android.util.Log
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContract
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.MaterialTheme
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
import androidx.compose.material.icons.filled.Edit
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import androidx.core.net.toUri
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||||
import chat.simplex.app.views.chat.item.*
|
import chat.simplex.app.views.chat.item.*
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class ComposePreview {
|
sealed class ComposePreview {
|
||||||
@Serializable object NoPreview: ComposePreview()
|
@Serializable object NoPreview: ComposePreview()
|
||||||
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
||||||
@Serializable class ImagePreview(val images: List<String>): ComposePreview()
|
@Serializable class ImagePreview(val images: List<String>): ComposePreview()
|
||||||
|
@Serializable class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
|
||||||
@Serializable class FilePreview(val fileName: String): ComposePreview()
|
@Serializable class FilePreview(val fileName: String): ComposePreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,6 +87,7 @@ data class ComposeState(
|
|||||||
get() = {
|
get() = {
|
||||||
val hasContent = when (preview) {
|
val hasContent = when (preview) {
|
||||||
is ComposePreview.ImagePreview -> true
|
is ComposePreview.ImagePreview -> true
|
||||||
|
is ComposePreview.VoicePreview -> true
|
||||||
is ComposePreview.FilePreview -> true
|
is ComposePreview.FilePreview -> true
|
||||||
else -> message.isNotEmpty()
|
else -> message.isNotEmpty()
|
||||||
}
|
}
|
||||||
@ -93,6 +97,7 @@ data class ComposeState(
|
|||||||
get() =
|
get() =
|
||||||
when (preview) {
|
when (preview) {
|
||||||
is ComposePreview.ImagePreview -> false
|
is ComposePreview.ImagePreview -> false
|
||||||
|
is ComposePreview.VoicePreview -> false
|
||||||
is ComposePreview.FilePreview -> false
|
is ComposePreview.FilePreview -> false
|
||||||
else -> useLinkPreviews
|
else -> useLinkPreviews
|
||||||
}
|
}
|
||||||
@ -118,11 +123,12 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview {
|
|||||||
is MsgContent.MCText -> ComposePreview.NoPreview
|
is MsgContent.MCText -> ComposePreview.NoPreview
|
||||||
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
|
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
|
||||||
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image))
|
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 -> {
|
is MsgContent.MCFile -> {
|
||||||
val fileName = chatItem.file?.fileName ?: ""
|
val fileName = chatItem.file?.fileName ?: ""
|
||||||
ComposePreview.FilePreview(fileName)
|
ComposePreview.FilePreview(fileName)
|
||||||
}
|
}
|
||||||
else -> ComposePreview.NoPreview
|
is MsgContent.MCUnknown, null -> ComposePreview.NoPreview
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,6 +150,11 @@ fun ComposeView(
|
|||||||
val textStyle = remember { mutableStateOf(smallFont) }
|
val textStyle = remember { mutableStateOf(smallFont) }
|
||||||
// attachments
|
// attachments
|
||||||
val chosenContent = rememberSaveable { mutableStateOf<List<UploadContent>>(emptyList()) }
|
val chosenContent = rememberSaveable { mutableStateOf<List<UploadContent>>(emptyList()) }
|
||||||
|
val audioSaver = Saver<MutableState<Pair<Uri, Int>?>, Pair<String, Int>> (
|
||||||
|
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<Uri?>(null) }
|
val chosenFile = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||||
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
@ -321,6 +332,7 @@ fun ComposeView(
|
|||||||
is MsgContent.MCText -> checkLinkPreview()
|
is MsgContent.MCText -> checkLinkPreview()
|
||||||
is MsgContent.MCLink -> checkLinkPreview()
|
is MsgContent.MCLink -> checkLinkPreview()
|
||||||
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
|
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.MCFile -> MsgContent.MCFile(cs.message)
|
||||||
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
|
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)
|
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
||||||
textStyle.value = smallFont
|
textStyle.value = smallFont
|
||||||
chosenContent.value = emptyList()
|
chosenContent.value = emptyList()
|
||||||
|
chosenAudio.value = null
|
||||||
chosenFile.value = null
|
chosenFile.value = null
|
||||||
linkUrl.value = null
|
linkUrl.value = null
|
||||||
prevLinkUrl.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 -> {
|
is ComposePreview.FilePreview -> {
|
||||||
val chosenFileVal = chosenFile.value
|
val chosenFileVal = chosenFile.value
|
||||||
if (chosenFileVal != null) {
|
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() {
|
fun cancelLinkPreview() {
|
||||||
val uri = composeState.value.linkPreview?.uri
|
val uri = composeState.value.linkPreview?.uri
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
@ -440,6 +469,11 @@ fun ComposeView(
|
|||||||
chosenContent.value = emptyList()
|
chosenContent.value = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun cancelVoice() {
|
||||||
|
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||||
|
chosenContent.value = emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
fun cancelFile() {
|
fun cancelFile() {
|
||||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||||
chosenFile.value = null
|
chosenFile.value = null
|
||||||
@ -455,6 +489,13 @@ fun ComposeView(
|
|||||||
::cancelImages,
|
::cancelImages,
|
||||||
cancelEnabled = !composeState.value.editing
|
cancelEnabled = !composeState.value.editing
|
||||||
)
|
)
|
||||||
|
is ComposePreview.VoicePreview -> ComposeVoiceView(
|
||||||
|
preview.voice,
|
||||||
|
preview.durationMs,
|
||||||
|
preview.finished,
|
||||||
|
cancelEnabled = !composeState.value.editing,
|
||||||
|
::cancelVoice
|
||||||
|
)
|
||||||
is ComposePreview.FilePreview -> ComposeFileView(
|
is ComposePreview.FilePreview -> ComposeFileView(
|
||||||
preview.fileName,
|
preview.fileName,
|
||||||
::cancelFile,
|
::cancelFile,
|
||||||
@ -489,37 +530,34 @@ fun ComposeView(
|
|||||||
Column {
|
Column {
|
||||||
contextItemView()
|
contextItemView()
|
||||||
when {
|
when {
|
||||||
|
composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
|
||||||
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
|
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
|
||||||
else -> previewView()
|
else -> previewView()
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(start = 4.dp, end = 8.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.Bottom,
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
|
||||||
) {
|
) {
|
||||||
val attachEnabled = !composeState.value.editing
|
val attachEnabled = !composeState.value.editing && composeState.value.preview !is ComposePreview.VoicePreview
|
||||||
Box(Modifier.padding(bottom = 12.dp)) {
|
IconButton(showChooseAttachment, enabled = attachEnabled) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.AttachFile,
|
Icons.Filled.AttachFile,
|
||||||
contentDescription = stringResource(R.string.attach),
|
contentDescription = stringResource(R.string.attach),
|
||||||
tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray,
|
tint = if (attachEnabled) MaterialTheme.colors.primary else HighOrLowlight,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(28.dp)
|
.size(28.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.clickable {
|
|
||||||
if (attachEnabled) {
|
|
||||||
showChooseAttachment()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState,
|
composeState,
|
||||||
|
allowVoiceRecord = true,
|
||||||
sendMessage = {
|
sendMessage = {
|
||||||
sendMessage()
|
sendMessage()
|
||||||
resetLinkPreview()
|
resetLinkPreview()
|
||||||
},
|
},
|
||||||
::onMessageChange,
|
::onMessageChange,
|
||||||
|
::onAudioAdded,
|
||||||
textStyle
|
textStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,10 @@
|
|||||||
package chat.simplex.app.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@ -12,38 +15,198 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material.icons.outlined.ArrowUpward
|
import androidx.compose.material.icons.outlined.*
|
||||||
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.*
|
import androidx.compose.ui.*
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.*
|
import androidx.compose.ui.graphics.*
|
||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.graphics.drawable.DrawableCompat
|
import androidx.core.graphics.drawable.DrawableCompat
|
||||||
import androidx.core.view.inputmethod.EditorInfoCompat
|
import androidx.core.view.inputmethod.EditorInfoCompat
|
||||||
import androidx.core.view.inputmethod.InputConnectionCompat
|
import androidx.core.view.inputmethod.InputConnectionCompat
|
||||||
import androidx.core.widget.doOnTextChanged
|
import androidx.core.widget.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.SimplexApp
|
import chat.simplex.app.SimplexApp
|
||||||
import chat.simplex.app.model.ChatItem
|
import chat.simplex.app.model.ChatItem
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
import chat.simplex.app.views.helpers.SharedContent
|
import chat.simplex.app.views.helpers.*
|
||||||
import kotlinx.coroutines.delay
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import java.io.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SendMsgView(
|
fun SendMsgView(
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
|
allowVoiceRecord: Boolean,
|
||||||
sendMessage: () -> Unit,
|
sendMessage: () -> Unit,
|
||||||
onMessageChange: (String) -> Unit,
|
onMessageChange: (String) -> Unit,
|
||||||
|
onAudioAdded: (String, Int, Boolean) -> Unit,
|
||||||
textStyle: MutableState<TextStyle>
|
textStyle: MutableState<TextStyle>
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(vertical = 8.dp)) {
|
||||||
|
Box {
|
||||||
|
val cs = composeState.value
|
||||||
|
val attachEnabled = !composeState.value.editing
|
||||||
|
val filePath = rememberSaveable { mutableStateOf(null as String?) }
|
||||||
|
var recordingTimeRange by rememberSaveable(saver = LongRange.saver) { mutableStateOf(0L..0L) } // since..to
|
||||||
|
val showVoiceButton = ((cs.message.isEmpty() || recordingTimeRange.first > 0L) && 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<Boolean> = remember { rec.recordingInProgress }
|
||||||
|
var now by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (isActive) {
|
||||||
|
now = System.currentTimeMillis()
|
||||||
|
if (recordingTimeRange.first != 0L && recordingInProgress.value && composeState.value.preview is ComposePreview.VoicePreview) {
|
||||||
|
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
|
||||||
|
}
|
||||||
|
delay(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val stopRecordingAndAddAudio: () -> Unit = {
|
||||||
|
rec.stop()
|
||||||
|
recordingTimeRange = recordingTimeRange.first..System.currentTimeMillis()
|
||||||
|
filePath.value?.let { onAudioAdded(it, (recordingTimeRange.last - recordingTimeRange.first).toInt(), true) }
|
||||||
|
}
|
||||||
|
val startStopRecording: () -> Unit = {
|
||||||
|
when {
|
||||||
|
!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<ComposeState>,
|
||||||
|
textStyle: MutableState<TextStyle>,
|
||||||
|
onMessageChange: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val cs = composeState.value
|
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) }
|
var showKeyboard by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(cs.contextItem) {
|
LaunchedEffect(cs.contextItem) {
|
||||||
when (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)) {
|
AndroidView(modifier = Modifier, factory = {
|
||||||
Box {
|
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
|
||||||
AndroidView(modifier = Modifier, factory = {
|
override fun setOnReceiveContentListener(
|
||||||
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
|
mimeTypes: Array<out String>?,
|
||||||
override fun setOnReceiveContentListener(
|
listener: android.view.OnReceiveContentListener?
|
||||||
mimeTypes: Array<out String>?,
|
) {
|
||||||
listener: android.view.OnReceiveContentListener?
|
super.setOnReceiveContentListener(mimeTypes, listener)
|
||||||
) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Box(Modifier.align(Alignment.BottomEnd)) {
|
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
|
||||||
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
|
val connection = super.onCreateInputConnection(editorInfo)
|
||||||
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
|
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
|
||||||
if (cs.inProgress
|
val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
|
||||||
&& (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
|
try {
|
||||||
) {
|
inputContentInfo.requestPermission()
|
||||||
CircularProgressIndicator(
|
} catch (e: Exception) {
|
||||||
Modifier
|
return@OnCommitContentListener false
|
||||||
.size(36.dp)
|
}
|
||||||
.padding(4.dp),
|
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
|
||||||
color = HighOrLowlight,
|
true
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
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 {
|
SimpleXTheme {
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
|
allowVoiceRecord = false,
|
||||||
sendMessage = {},
|
sendMessage = {},
|
||||||
onMessageChange = { _ -> },
|
onMessageChange = { _ -> },
|
||||||
|
onAudioAdded = { _, _, _ -> },
|
||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -188,8 +323,10 @@ fun PreviewSendMsgViewEditing() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState = remember { mutableStateOf(composeStateEditing) },
|
composeState = remember { mutableStateOf(composeStateEditing) },
|
||||||
|
allowVoiceRecord = false,
|
||||||
sendMessage = {},
|
sendMessage = {},
|
||||||
onMessageChange = { _ -> },
|
onMessageChange = { _ -> },
|
||||||
|
onAudioAdded = { _, _, _ -> },
|
||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -209,8 +346,10 @@ fun PreviewSendMsgViewInProgress() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState = remember { mutableStateOf(composeStateInProgress) },
|
composeState = remember { mutableStateOf(composeStateInProgress) },
|
||||||
|
allowVoiceRecord = false,
|
||||||
sendMessage = {},
|
sendMessage = {},
|
||||||
onMessageChange = { _ -> },
|
onMessageChange = { _ -> },
|
||||||
|
onAudioAdded = { _, _, _ -> },
|
||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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<ProgressAndDuration>?,
|
||||||
|
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<Boolean>,
|
||||||
|
audioInfo: MutableState<ProgressAndDuration>
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -100,20 +100,21 @@ fun ChatItemView(
|
|||||||
copyText(context, cItem.content.text)
|
copyText(context, cItem.content.text)
|
||||||
showMenu.value = false
|
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)
|
val filePath = getLoadedFilePath(context, cItem.file)
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
|
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
|
||||||
when (cItem.content.msgContent) {
|
when (cItem.content.msgContent) {
|
||||||
is MsgContent.MCImage -> saveImage(context, cItem.file)
|
is MsgContent.MCImage -> saveImage(context, cItem.file)
|
||||||
is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
|
is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||||
|
is MsgContent.MCVoice -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
showMenu.value = false
|
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 = {
|
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
|
||||||
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
|
@ -6,6 +6,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@ -56,8 +57,12 @@ fun FramedItemView(
|
|||||||
Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
|
Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
|
||||||
contentAlignment = Alignment.TopStart
|
contentAlignment = Alignment.TopStart
|
||||||
) {
|
) {
|
||||||
|
val text = if (qi.content is MsgContent.MCVoice && qi.text.isEmpty())
|
||||||
|
qi.content.toTextWithDuration(true)
|
||||||
|
else
|
||||||
|
qi.text
|
||||||
MarkdownText(
|
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)
|
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -87,13 +92,13 @@ fun FramedItemView(
|
|||||||
modifier = Modifier.size(68.dp).clipToBounds()
|
modifier = Modifier.size(68.dp).clipToBounds()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is MsgContent.MCFile -> {
|
is MsgContent.MCFile, is MsgContent.MCVoice -> {
|
||||||
Box(Modifier.fillMaxWidth().weight(1f)) {
|
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||||
ciQuotedMsgView(qi)
|
ciQuotedMsgView(qi)
|
||||||
}
|
}
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.InsertDriveFile,
|
if (qi.content is MsgContent.MCFile) Icons.Filled.InsertDriveFile else Icons.Filled.PlayArrow,
|
||||||
stringResource(R.string.icon_descr_file),
|
if (qi.content is MsgContent.MCFile) stringResource(R.string.icon_descr_file) else stringResource(R.string.voice_message),
|
||||||
Modifier
|
Modifier
|
||||||
.padding(top = 6.dp, end = 4.dp)
|
.padding(top = 6.dp, end = 4.dp)
|
||||||
.size(22.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
|
Box(Modifier
|
||||||
.clip(RoundedCornerShape(18.dp))
|
.clip(RoundedCornerShape(18.dp))
|
||||||
.background(
|
.background(
|
||||||
@ -142,6 +147,12 @@ fun FramedItemView(
|
|||||||
CIMarkdownText(ci, showMember, uriHandler)
|
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 -> {
|
is MsgContent.MCFile -> {
|
||||||
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
|
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
|
||||||
if (mc.text != "") {
|
if (mc.text != "") {
|
||||||
@ -157,8 +168,10 @@ fun FramedItemView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
|
if (ci.content.msgContent !is MsgContent.MCVoice || ci.content.text.isNotEmpty()) {
|
||||||
CIMetaView(ci, metaColor)
|
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
|
||||||
|
CIMetaView(ci, metaColor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,3 +5,5 @@ import androidx.compose.animation.core.*
|
|||||||
fun <T> chatListAnimationSpec() = tween<T>(durationMillis = 250, easing = FastOutSlowInEasing)
|
fun <T> chatListAnimationSpec() = tween<T>(durationMillis = 250, easing = FastOutSlowInEasing)
|
||||||
|
|
||||||
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
|
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
|
||||||
|
|
||||||
|
fun <T> audioProgressBarAnimationSpec() = tween<T>(durationMillis = 30, easing = LinearEasing)
|
||||||
|
@ -213,6 +213,24 @@ fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit)
|
|||||||
return interactionSource
|
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(
|
suspend fun PointerInputScope.detectTransformGestures(
|
||||||
allowIntercept: () -> Boolean,
|
allowIntercept: () -> Boolean,
|
||||||
panZoomLock: Boolean = false,
|
panZoomLock: Boolean = false,
|
||||||
|
@ -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<Boolean>
|
||||||
|
fun start(onStop: () -> Unit): String
|
||||||
|
fun stop()
|
||||||
|
fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ProgressAndDuration(
|
||||||
|
val progressMs: Int = 0,
|
||||||
|
val durationMs: Int = 0
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val Saver
|
||||||
|
get() = Saver<MutableState<ProgressAndDuration>, Pair<Int, Int>>(
|
||||||
|
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<Boolean>) {
|
||||||
|
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<Pair<String, () -> 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
|
||||||
|
}
|
||||||
|
}
|
@ -19,6 +19,7 @@ import android.view.ViewTreeObserver
|
|||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
import androidx.compose.ui.text.*
|
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.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.*
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.net.toFile
|
||||||
import androidx.core.text.HtmlCompat
|
import androidx.core.text.HtmlCompat
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.model.CIFile
|
import chat.simplex.app.model.CIFile
|
||||||
@ -220,6 +222,11 @@ private fun spannableStringToAnnotatedString(
|
|||||||
// maximum image file size to be auto-accepted
|
// maximum image file size to be auto-accepted
|
||||||
const val MAX_IMAGE_SIZE: Long = 236700
|
const val MAX_IMAGE_SIZE: Long = 236700
|
||||||
const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
|
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
|
const val MAX_FILE_SIZE: Long = 8000000
|
||||||
|
|
||||||
fun getFilesDirectory(context: Context): String {
|
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 ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)
|
||||||
|
|
||||||
fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)
|
fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)
|
||||||
|
|
||||||
|
val LongRange.Companion.saver
|
||||||
|
get() = Saver<MutableState<LongRange>, Pair<Long, Long>>(
|
||||||
|
save = { it.value.first to it.value.last },
|
||||||
|
restore = { mutableStateOf(it.first..it.second) }
|
||||||
|
)
|
||||||
|
@ -208,6 +208,10 @@
|
|||||||
<string name="file_not_found">Datei nicht gefunden</string>
|
<string name="file_not_found">Datei nicht gefunden</string>
|
||||||
<string name="error_saving_file">Fehler beim Speichern der Datei</string>
|
<string name="error_saving_file">Fehler beim Speichern der Datei</string>
|
||||||
|
|
||||||
|
<!-- Voice messages -->
|
||||||
|
<string name="voice_message">***Voice message</string>
|
||||||
|
<string name="voice_message_send_text">***Voice message…</string>
|
||||||
|
|
||||||
<!-- Chat Info Settings - ChatInfoView.kt -->
|
<!-- Chat Info Settings - ChatInfoView.kt -->
|
||||||
<string name="notifications">Benachrichtigungen</string>
|
<string name="notifications">Benachrichtigungen</string>
|
||||||
|
|
||||||
@ -225,6 +229,7 @@
|
|||||||
|
|
||||||
<!-- Message Actions - SendMsgView.kt -->
|
<!-- Message Actions - SendMsgView.kt -->
|
||||||
<string name="icon_descr_send_message">Nachricht senden</string>
|
<string name="icon_descr_send_message">Nachricht senden</string>
|
||||||
|
<string name="icon_descr_record_voice_message">***Record voice message</string>
|
||||||
|
|
||||||
<!-- General Actions / Responses -->
|
<!-- General Actions / Responses -->
|
||||||
<string name="back">Zurück</string>
|
<string name="back">Zurück</string>
|
||||||
|
@ -208,6 +208,10 @@
|
|||||||
<string name="file_not_found">Файл не найден</string>
|
<string name="file_not_found">Файл не найден</string>
|
||||||
<string name="error_saving_file">Ошибка сохранения файла</string>
|
<string name="error_saving_file">Ошибка сохранения файла</string>
|
||||||
|
|
||||||
|
<!-- Voice messages -->
|
||||||
|
<string name="voice_message">Голосовое сообщение</string>
|
||||||
|
<string name="voice_message_send_text">Голосовое сообщение…</string>
|
||||||
|
|
||||||
<!-- Chat Info Settings - ChatInfoView.kt -->
|
<!-- Chat Info Settings - ChatInfoView.kt -->
|
||||||
<string name="notifications">Уведомления</string>
|
<string name="notifications">Уведомления</string>
|
||||||
|
|
||||||
@ -225,6 +229,7 @@
|
|||||||
|
|
||||||
<!-- Message Actions - SendMsgView.kt -->
|
<!-- Message Actions - SendMsgView.kt -->
|
||||||
<string name="icon_descr_send_message">Отправить сообщение</string>
|
<string name="icon_descr_send_message">Отправить сообщение</string>
|
||||||
|
<string name="icon_descr_record_voice_message">Записать голосовое сообщение</string>
|
||||||
|
|
||||||
<!-- General Actions / Responses -->
|
<!-- General Actions / Responses -->
|
||||||
<string name="back">Назад</string>
|
<string name="back">Назад</string>
|
||||||
|
@ -208,6 +208,10 @@
|
|||||||
<string name="file_not_found">File not found</string>
|
<string name="file_not_found">File not found</string>
|
||||||
<string name="error_saving_file">Error saving file</string>
|
<string name="error_saving_file">Error saving file</string>
|
||||||
|
|
||||||
|
<!-- Voice messages -->
|
||||||
|
<string name="voice_message">Voice message</string>
|
||||||
|
<string name="voice_message_send_text">Voice message…</string>
|
||||||
|
|
||||||
<!-- Chat Info Settings - ChatInfoView.kt -->
|
<!-- Chat Info Settings - ChatInfoView.kt -->
|
||||||
<string name="notifications">Notifications</string>
|
<string name="notifications">Notifications</string>
|
||||||
|
|
||||||
@ -225,6 +229,7 @@
|
|||||||
|
|
||||||
<!-- Message Actions - SendMsgView.kt -->
|
<!-- Message Actions - SendMsgView.kt -->
|
||||||
<string name="icon_descr_send_message">Send Message</string>
|
<string name="icon_descr_send_message">Send Message</string>
|
||||||
|
<string name="icon_descr_record_voice_message">Record voice message</string>
|
||||||
|
|
||||||
<!-- General Actions / Responses -->
|
<!-- General Actions / Responses -->
|
||||||
<string name="back">Back</string>
|
<string name="back">Back</string>
|
||||||
|
@ -386,6 +386,8 @@ processChatCommand = \case
|
|||||||
| otherwise = case qmc of
|
| otherwise = case qmc of
|
||||||
MCImage _ image -> MCImage qTextOrFile image
|
MCImage _ image -> MCImage qTextOrFile image
|
||||||
MCFile _ -> MCFile qTextOrFile
|
MCFile _ -> MCFile qTextOrFile
|
||||||
|
-- consider same for voice messages
|
||||||
|
-- MCVoice _ voice -> MCVoice qTextOrFile voice
|
||||||
_ -> qmc
|
_ -> qmc
|
||||||
where
|
where
|
||||||
-- if the message we're quoting with is one of the "large" MsgContents
|
-- if the message we're quoting with is one of the "large" MsgContents
|
||||||
|
Loading…
Reference in New Issue
Block a user