Added file download

This commit is contained in:
Sylvain Berfini 2023-11-15 13:14:35 +01:00
parent a8aa3be08a
commit 2fa856e790
6 changed files with 192 additions and 101 deletions

View file

@ -53,6 +53,7 @@ import org.linphone.ui.main.contacts.model.ContactAvatarModel
import org.linphone.utils.AppUtils
import org.linphone.utils.AudioRouteUtils
import org.linphone.utils.Event
import org.linphone.utils.FileUtils
import org.linphone.utils.LinphoneUtils
import org.linphone.utils.PatternClickableSpan
import org.linphone.utils.SpannableClickedListener
@ -130,6 +131,10 @@ class ChatMessageModel @WorkerThread constructor(
val formattedVoiceRecordingDuration = MutableLiveData<String>()
val dismissLongPressMenuEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
private var voiceRecordAudioFocusRequest: AudioFocusRequestCompat? = null
private lateinit var voiceRecordPath: String
@ -140,18 +145,29 @@ class ChatMessageModel @WorkerThread constructor(
Log.i("$TAG End of file reached")
stopVoiceRecordPlayer()
}
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// End of voice record related fields
val dismissLongPressMenuEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var downloadingFileModel: FileModel? = null
private val chatMessageListener = object : ChatMessageListenerStub() {
@WorkerThread
override fun onMsgStateChanged(message: ChatMessage, messageState: ChatMessage.State?) {
statusIcon.postValue(LinphoneUtils.getChatIconResId(chatMessage.state))
if (
messageState == ChatMessage.State.FileTransferInProgress ||
messageState == ChatMessage.State.FileTransferDone ||
messageState == ChatMessage.State.FileTransferError
) {
statusIcon.postValue(LinphoneUtils.getChatIconResId(chatMessage.state))
}
if (messageState == ChatMessage.State.FileTransferDone) {
Log.i("$TAG File transfer is done")
downloadingFileModel?.downloadProgress?.postValue(-1)
downloadingFileModel = null
computeContentsList()
}
}
@WorkerThread
@ -167,6 +183,33 @@ class ChatMessageModel @WorkerThread constructor(
Log.i("$TAG A reaction was removed for chat message with ID [$id]")
updateReactionsList()
}
@WorkerThread
override fun onFileTransferProgressIndication(
message: ChatMessage,
content: Content,
offset: Int,
total: Int
) {
val model = downloadingFileModel
if (model != null) {
val percent = ((offset * 100.0) / total).toInt() // Conversion from int to double and back to int is required
model.downloadProgress.postValue(percent)
} else {
Log.w("$TAG A file is being downloaded but no downloadingFileModel set!")
val found = filesList.value.orEmpty().find {
it.fileName == content.name
}
if (found != null) {
downloadingFileModel = found
Log.i("$TAG Found matching FileModel in files list using content name")
} else {
Log.w(
"$TAG Failed to find a matching FileModel in files list with content name [${content.name}]"
)
}
}
}
}
init {
@ -176,88 +219,7 @@ class ChatMessageModel @WorkerThread constructor(
statusIcon.postValue(LinphoneUtils.getChatIconResId(chatMessage.state))
updateReactionsList()
var displayableContentFound = false
var filesContentCount = 0
val filesPath = arrayListOf<FileModel>()
val contents = chatMessage.contents
for (content in contents) {
if (content.isIcalendar) {
parseConferenceInvite(content)
displayableContentFound = true
} else if (content.isText) {
computeTextContent(content)
displayableContentFound = true
} else {
filesContentCount += 1
if (content.isFile) {
val path = content.filePath ?: ""
if (path.isNotEmpty()) {
Log.i(
"$TAG Found file ready to be displayed [$path] with MIME [${content.type}/${content.subtype}] for message [${chatMessage.messageId}]"
)
when (content.type) {
"image", "video" -> {
val fileModel = FileModel(path, content.fileSize.toLong()) { file ->
onContentClicked?.invoke(file)
}
filesPath.add(fileModel)
if (filesContentCount == 1) {
firstImage.postValue(fileModel)
}
displayableContentFound = true
}
"audio" -> {
voiceRecordPath = path
isVoiceRecord.postValue(true)
val duration = content.fileDuration
voiceRecordingDuration.postValue(duration)
val formattedDuration = SimpleDateFormat(
"mm:ss",
Locale.getDefault()
).format(duration) // duration is in ms
formattedVoiceRecordingDuration.postValue(formattedDuration)
displayableContentFound = true
}
else -> {
val fileModel = FileModel(path, content.fileSize.toLong()) { file ->
onContentClicked?.invoke(file)
}
filesPath.add(fileModel)
displayableContentFound = true
}
}
} else {
Log.e("$TAG No path found for File Content!")
}
} else if (content.isFileTransfer) {
val name = content.name ?: ""
if (name.isNotEmpty()) {
val fileModel = FileModel(name, content.fileSize.toLong(), true) { file ->
onContentClicked?.invoke(file)
}
filesPath.add(fileModel)
displayableContentFound = true
} else {
Log.e("$TAG No name found for FileTransfer Content!")
}
} else {
Log.i("$TAG Content is not a File")
}
}
}
filesList.postValue(filesPath)
if (!displayableContentFound) { // Temporary workaround to prevent empty bubbles
val describe = LinphoneUtils.getTextDescribingMessage(chatMessage)
val spannable = Spannable.Factory.getInstance().newSpannable(describe)
text.postValue(spannable)
}
computeContentsList()
}
@WorkerThread
@ -290,8 +252,6 @@ class ChatMessageModel @WorkerThread constructor(
coreContext.postOnMainThread {
onJoinConferenceClicked?.invoke(uri)
}
/*Log.i("$TAG Calling conference URI [${meetingConferenceUri.asStringUriOnly()}]")
coreContext.startCall(meetingConferenceUri)*/
}
}
}
@ -307,6 +267,111 @@ class ChatMessageModel @WorkerThread constructor(
}
}
@WorkerThread
private fun computeContentsList() {
Log.d("$TAG Computing chat message contents list")
var displayableContentFound = false
var filesContentCount = 0
val filesPath = arrayListOf<FileModel>()
val contents = chatMessage.contents
for (content in contents) {
if (content.isIcalendar) {
parseConferenceInvite(content)
displayableContentFound = true
} else if (content.isText) {
computeTextContent(content)
displayableContentFound = true
} else {
filesContentCount += 1
if (content.isFile) {
val path = content.filePath ?: ""
if (path.isNotEmpty()) {
Log.d(
"$TAG Found file ready to be displayed [$path] with MIME [${content.type}/${content.subtype}] for message [${chatMessage.messageId}]"
)
when (content.type) {
"image", "video" -> {
val fileModel = FileModel(path, content.fileSize.toLong()) { model ->
onContentClicked?.invoke(model.file)
}
filesPath.add(fileModel)
if (filesContentCount == 1) {
firstImage.postValue(fileModel)
}
displayableContentFound = true
}
"audio" -> {
voiceRecordPath = path
isVoiceRecord.postValue(true)
val duration = content.fileDuration
voiceRecordingDuration.postValue(duration)
val formattedDuration = SimpleDateFormat(
"mm:ss",
Locale.getDefault()
).format(duration) // duration is in ms
formattedVoiceRecordingDuration.postValue(formattedDuration)
displayableContentFound = true
}
else -> {
val fileModel = FileModel(path, content.fileSize.toLong()) { model ->
onContentClicked?.invoke(model.file)
}
filesPath.add(fileModel)
displayableContentFound = true
}
}
} else {
Log.e("$TAG No path found for File Content!")
}
} else if (content.isFileTransfer) {
val name = content.name ?: ""
if (name.isNotEmpty()) {
val fileModel = FileModel(name, content.fileSize.toLong(), true) { model ->
Log.i("$TAG Starting downloading content for file [${model.fileName}]")
if (content.filePath.orEmpty().isEmpty()) {
val contentName = content.name
if (contentName != null) {
val isImage = FileUtils.isExtensionImage(contentName)
val file = FileUtils.getFileStoragePath(contentName, isImage)
content.filePath = file.path
Log.i(
"$TAG File [$contentName] will be downloaded at [${content.filePath}]"
)
model.downloadProgress.postValue(0)
downloadingFileModel = model
chatMessage.downloadContent(content)
} else {
Log.e("$TAG Content name is null, can't download it!")
}
}
}
filesPath.add(fileModel)
displayableContentFound = true
} else {
Log.e("$TAG No name found for FileTransfer Content!")
}
} else {
Log.i("$TAG Content is not a File")
}
}
}
filesList.postValue(filesPath)
if (!displayableContentFound) { // Temporary workaround to prevent empty bubbles
val describe = LinphoneUtils.getTextDescribingMessage(chatMessage)
val spannable = Spannable.Factory.getInstance().newSpannable(describe)
text.postValue(spannable)
}
}
@WorkerThread
private fun updateReactionsList() {
var reactionsList = ""
@ -328,14 +393,13 @@ class ChatMessageModel @WorkerThread constructor(
}
}
Log.i("$TAG Reactions for message [$id] are [$reactionsList]")
Log.d("$TAG Reactions for message [$id] are [$reactionsList]")
reactions.postValue(reactionsList)
}
@WorkerThread
private fun computeTextContent(content: Content) {
val textContent = content.utf8Text.orEmpty().trim()
Log.i("$TAG Found text content [$textContent] for message [${chatMessage.messageId}]")
val spannableBuilder = SpannableStringBuilder(textContent)
// Check for mentions
@ -345,7 +409,7 @@ class ChatMessageModel @WorkerThread constructor(
val start = matcher.start()
val end = matcher.end()
val source = textContent.subSequence(start + 1, end) // +1 to remove @
Log.i("$TAG Found mention [$source]")
Log.d("$TAG Found mention [$source]")
// Find address matching username
val address = if (chatRoom.localAddress.username == source) {
@ -362,7 +426,7 @@ class ChatMessageModel @WorkerThread constructor(
// Find display name for address
if (address != null) {
val displayName = coreContext.contactsManager.findDisplayName(address)
Log.i(
Log.d(
"$TAG Using display name [$displayName] instead of username [$source]"
)
spannableBuilder.replace(start, end, "@$displayName")

View file

@ -11,7 +11,7 @@ class FileModel @AnyThread constructor(
val file: String,
fileSize: Long,
val isWaitingToBeDownloaded: Boolean = false,
private val onClicked: ((file: String) -> Unit)? = null
private val onClicked: ((model: FileModel) -> Unit)? = null
) {
companion object {
private const val TAG = "[File Model]"
@ -23,6 +23,8 @@ class FileModel @AnyThread constructor(
val path = MutableLiveData<String>()
val downloadProgress = MutableLiveData<Int>()
val mimeType: FileUtils.MimeType
val isImage: Boolean
@ -33,6 +35,7 @@ class FileModel @AnyThread constructor(
init {
path.postValue(file)
downloadProgress.postValue(-1)
formattedFileSize.postValue(FileUtils.bytesToDisplayableSize(fileSize))
if (!isWaitingToBeDownloaded) {
@ -53,7 +56,7 @@ class FileModel @AnyThread constructor(
@UiThread
fun onClick() {
onClicked?.invoke(file)
onClicked?.invoke(this)
}
@AnyThread

View file

@ -300,8 +300,8 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() {
fun addAttachment(file: String) {
val list = arrayListOf<FileModel>()
list.addAll(attachments.value.orEmpty())
val model = FileModel(file, 0) { file ->
removeAttachment(file)
val model = FileModel(file, 0) { model ->
removeAttachment(model.file)
}
list.add(model)
attachments.value = list

View file

@ -58,6 +58,13 @@ class FileUtils {
return Formatter.formatShortFileSize(coreContext.context, bytes)
}
@AnyThread
fun isExtensionImage(path: String): Boolean {
val extension = getExtensionFromFileName(path)
val type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
return getMimeType(type) == MimeType.Image
}
@AnyThread
fun isExtensionVideo(path: String): Boolean {
val extension = getExtensionFromFileName(path)

View file

@ -199,10 +199,10 @@ class LinphoneUtils {
ChatMessage.State.Delivered -> {
R.drawable.envelope_simple
}
ChatMessage.State.InProgress, ChatMessage.State.FileTransferInProgress -> {
ChatMessage.State.InProgress -> {
R.drawable.in_progress
}
ChatMessage.State.NotDelivered, ChatMessage.State.FileTransferError -> {
ChatMessage.State.NotDelivered -> {
R.drawable.warning_circle
}
else -> {

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="android.view.View" />
@ -58,6 +59,22 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/download_progress"
android:layout_width="0dp"
android:layout_height="0dp"
android:indeterminate="false"
android:progress="@{model.downloadProgress}"
android:max="100"
android:visibility="@{!model.isWaitingToBeDownloaded || model.downloadProgress == -1 || model.downloadProgress >= 100 ? View.GONE : View.VISIBLE}"
app:trackColor="@color/orange_main_100"
app:indicatorColor="@color/orange_main_500"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="@id/file_icon"
tools:progress="40" />
<View
android:id="@+id/file_background"
android:layout_width="0dp"