diff --git a/app/build.gradle b/app/build.gradle index bac4966..f9e0dc9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -107,7 +107,7 @@ android { dependencies { // Twilio - implementation "com.twilio:conversations-android-with-symbols:6.0.3" + implementation "com.twilio:conversations-android-with-symbols:6.2.0" // or without symbols: // implementation "com.twilio:conversations-android:6.0.3" @@ -132,6 +132,7 @@ dependencies { // Room components implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-ktx:$room_version" + androidTestImplementation project(':app') kapt "androidx.room:room-compiler:$room_version" // Material diff --git a/app/src/androidTest/java/com/twilio/conversations/app/ui/MessageListActivityTest.kt b/app/src/androidTest/java/com/twilio/conversations/app/ui/MessageListActivityTest.kt index bc7276c..a931013 100644 --- a/app/src/androidTest/java/com/twilio/conversations/app/ui/MessageListActivityTest.kt +++ b/app/src/androidTest/java/com/twilio/conversations/app/ui/MessageListActivityTest.kt @@ -510,8 +510,8 @@ class MessageListActivityTest { // Validate media messages if (message.type == MessageType.MEDIA) { val mediaMatcher = when { - message.mediaDownloadState == DOWNLOADING -> hasDescendant(withId(R.id.attachment_progress)) - message.mediaDownloadState == COMPLETED -> hasDescendant(withText(R.string.attachment_tap_to_open)) + message.attachmentsList.first().downloadState == DOWNLOADING -> hasDescendant(withId(R.id.attachment_progress)) + message.attachmentsList.first().downloadState == COMPLETED -> hasDescendant(withText(R.string.attachment_tap_to_open)) else -> hasDescendant( withText( Formatter.formatShortFileSize( @@ -532,7 +532,7 @@ class MessageListActivityTest { hasSibling( allOf( withId(R.id.attachment_file_name), - withText(message.mediaFileName) + withText(message.attachmentsList.first().fileName) ) ) ) @@ -541,7 +541,7 @@ class MessageListActivityTest { allOf( withId(R.id.attachment_progress), withEffectiveVisibility( - if (message.mediaDownloadState == DOWNLOADING) + if (message.attachmentsList.first().downloadState == DOWNLOADING) Visibility.VISIBLE else Visibility.GONE ) ) diff --git a/app/src/main/java/com/twilio/conversations/app/adapters/MessageListAdapter.kt b/app/src/main/java/com/twilio/conversations/app/adapters/MessageListAdapter.kt index 77eb800..ebe981c 100644 --- a/app/src/main/java/com/twilio/conversations/app/adapters/MessageListAdapter.kt +++ b/app/src/main/java/com/twilio/conversations/app/adapters/MessageListAdapter.kt @@ -5,7 +5,10 @@ import android.text.format.Formatter import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ImageView import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView import androidx.core.content.ContextCompat import androidx.databinding.ViewDataBinding import androidx.paging.PagedListAdapter @@ -16,10 +19,9 @@ import com.twilio.conversations.app.common.enums.Direction import com.twilio.conversations.app.common.enums.DownloadState.COMPLETED import com.twilio.conversations.app.common.enums.DownloadState.DOWNLOADING import com.twilio.conversations.app.common.enums.DownloadState.ERROR -import com.twilio.conversations.app.common.enums.DownloadState.NOT_STARTED -import com.twilio.conversations.app.common.enums.Reaction import com.twilio.conversations.app.common.enums.SendStatus import com.twilio.conversations.app.data.models.MessageListViewItem +import com.twilio.conversations.app.data.models.MessageAttachmentViewItem import com.twilio.conversations.app.databinding.RowMessageItemIncomingBinding import com.twilio.conversations.app.databinding.RowMessageItemOutgoingBinding import com.twilio.conversations.app.databinding.ViewReactionItemBinding @@ -27,7 +29,7 @@ import timber.log.Timber class MessageListAdapter( private val onDisplaySendError: (message: MessageListViewItem) -> Unit, - private val onDownloadMedia: (message: MessageListViewItem) -> Unit, + private val onDownloadMedia: (message: MessageListViewItem, media: MessageAttachmentViewItem) -> Unit, private val onOpenMedia: (location: Uri, mimeType: String) -> Unit, private val onItemLongClick: (messageIndex: Long) -> Unit, private val onReactionClicked: (messageIndex: Long) -> Unit @@ -63,52 +65,6 @@ class MessageListAdapter( val binding = holder.binding val context = binding.root.context - val mediaSize = Formatter.formatShortFileSize(context, message.mediaSize ?: 0) - val mediaUploadedBytes = Formatter.formatShortFileSize(context, message.mediaUploadedBytes ?: 0) - val mediaDownloadedBytes = Formatter.formatShortFileSize(context, message.mediaDownloadedBytes ?: 0) - - val attachmentInfoText = when { - message.sendStatus == SendStatus.ERROR -> context.getString(R.string.err_failed_to_upload_media) - - message.mediaUploading -> context.getString(R.string.attachment_uploading, mediaUploadedBytes) - - message.mediaUploadUri != null || - message.mediaDownloadState == COMPLETED -> context.getString(R.string.attachment_tap_to_open) - - message.mediaDownloadState == NOT_STARTED -> mediaSize - - message.mediaDownloadState == DOWNLOADING -> context.getString( - R.string.attachment_downloading, - mediaDownloadedBytes - ) - - message.mediaDownloadState == ERROR -> context.getString(R.string.err_failed_to_download_media) - - else -> error("Never happens") - } - - val attachmentInfoColor = when { - message.sendStatus == SendStatus.ERROR || - message.mediaDownloadState == ERROR -> ContextCompat.getColor(context, R.color.colorAccent) - - message.mediaUploading -> ContextCompat.getColor(context, R.color.text_subtitle) - - message.mediaUploadUri != null || - message.mediaDownloadState == COMPLETED -> ContextCompat.getColor(context, R.color.colorPrimary) - - else -> ContextCompat.getColor(context, R.color.text_subtitle) - } - - val attachmentOnClickListener = View.OnClickListener { - if (message.mediaDownloadState == COMPLETED && message.mediaUri != null) { - onOpenMedia(message.mediaUri, message.mediaType!!) - } else if (message.mediaUploadUri != null) { - onOpenMedia(message.mediaUploadUri, message.mediaType!!) - } else if (message.mediaDownloadState != DOWNLOADING) { - onDownloadMedia(message) - } - } - val longClickListener = View.OnLongClickListener { onItemLongClick(message.index) return@OnLongClickListener true @@ -126,22 +82,102 @@ class MessageListAdapter( is RowMessageItemIncomingBinding -> { binding.message = message addReactions(binding.messageReactionHolder, message) - binding.attachmentInfo.text = attachmentInfoText - binding.attachmentInfo.setTextColor(attachmentInfoColor) - binding.attachmentBackground.setOnClickListener(attachmentOnClickListener) - binding.attachmentBackground.setOnLongClickListener(longClickListener) + setupAttachments(binding.attachmentsContainer, message, longClickListener) } is RowMessageItemOutgoingBinding -> { binding.message = message addReactions(binding.messageReactionHolder, message) - binding.attachmentInfo.text = attachmentInfoText - binding.attachmentInfo.setTextColor(attachmentInfoColor) - binding.attachmentBackground.setOnClickListener(attachmentOnClickListener) - binding.attachmentBackground.setOnLongClickListener(longClickListener) + setupAttachments(binding.attachmentsContainer, message, longClickListener) } else -> error("Unknown binding type: $binding") } + } + private fun setupAttachments( + attachmentsContainer: LinearLayout, + message: MessageListViewItem, + longClickListener: View.OnLongClickListener + ) { + attachmentsContainer.removeAllViews() + + message.attachmentsList.forEach { attachment -> + val attachmentView = LayoutInflater.from(attachmentsContainer.context) + .inflate(R.layout.item_attachment, attachmentsContainer, false) + + val icon = attachmentView.findViewById(R.id.attachment_icon) + val fileName = attachmentView.findViewById(R.id.attachment_file_name) + val info = attachmentView.findViewById(R.id.attachment_info) + val progress = attachmentView.findViewById(R.id.attachment_progress) + val failed = attachmentView.findViewById(R.id.attachment_failed) + + fileName.text = attachment.fileName ?: "Unknown file" + val size = Formatter.formatShortFileSize(attachmentsContainer.context, attachment.size ?: 0) + + when { + message.sendStatus == SendStatus.ERROR -> { + info.text = attachmentsContainer.context.getString(R.string.err_failed_to_upload_media) + info.setTextColor(ContextCompat.getColor(attachmentsContainer.context, R.color.colorAccent)) + failed.visibility = View.VISIBLE + progress.visibility = View.GONE + icon.setImageResource(R.drawable.ic_attachment_to_download) + } + attachment.downloadState == ERROR -> { + info.text = attachmentsContainer.context.getString(R.string.err_failed_to_download_media) + info.setTextColor(ContextCompat.getColor(attachmentsContainer.context, R.color.colorAccent)) + failed.visibility = View.VISIBLE + progress.visibility = View.GONE + icon.setImageResource(R.drawable.ic_attachment_to_download) + } + attachment.downloadState == DOWNLOADING -> { + val downloadedBytes = Formatter.formatShortFileSize(attachmentsContainer.context, attachment.downloadedBytes ?: 0) + info.text = attachmentsContainer.context.getString(R.string.attachment_downloading, downloadedBytes) + info.setTextColor(ContextCompat.getColor(attachmentsContainer.context, R.color.text_subtitle)) + progress.visibility = View.VISIBLE + failed.visibility = View.GONE + icon.setImageResource(R.drawable.ic_attachment_to_download) + } + attachment.uploading -> { + val uploadedBytes = Formatter.formatShortFileSize(attachmentsContainer.context, attachment.uploadedBytes ?: 0) + info.text = attachmentsContainer.context.getString(R.string.attachment_uploading, uploadedBytes) + info.setTextColor(ContextCompat.getColor(attachmentsContainer.context, R.color.text_subtitle)) + progress.visibility = View.VISIBLE + failed.visibility = View.GONE + icon.setImageResource(R.drawable.ic_attachment_to_download) + } + attachment.downloadState == COMPLETED || attachment.uploadUri != null -> { + info.text = attachmentsContainer.context.getString(R.string.attachment_tap_to_open) + info.setTextColor(ContextCompat.getColor(attachmentsContainer.context, R.color.colorPrimary)) + progress.visibility = View.GONE + failed.visibility = View.GONE + icon.setImageResource(R.drawable.ic_attachment_downloaded) + } + else -> { + info.text = size + info.setTextColor(ContextCompat.getColor(attachmentsContainer.context, R.color.text_subtitle)) + progress.visibility = View.GONE + failed.visibility = View.GONE + icon.setImageResource(R.drawable.ic_attachment_to_download) + } + } + + attachmentView.setOnClickListener { + when { + attachment.downloadState == COMPLETED && attachment.uri != null -> { + onOpenMedia(attachment.uri, attachment.type ?: "") + } + attachment.uploadUri != null -> { + onOpenMedia(attachment.uploadUri, attachment.type ?: "") + } + attachment.downloadState != DOWNLOADING -> { + onDownloadMedia(message, attachment) + } + } + } + + attachmentView.setOnLongClickListener(longClickListener) + + attachmentsContainer.addView(attachmentView) + } } private fun addReactions(rootView: LinearLayout, message: MessageListViewItem) { diff --git a/app/src/main/java/com/twilio/conversations/app/common/DataConverter.kt b/app/src/main/java/com/twilio/conversations/app/common/DataConverter.kt index 2316bff..92ccf5f 100644 --- a/app/src/main/java/com/twilio/conversations/app/common/DataConverter.kt +++ b/app/src/main/java/com/twilio/conversations/app/common/DataConverter.kt @@ -10,7 +10,6 @@ import com.twilio.conversations.Message import com.twilio.conversations.Participant import com.twilio.conversations.User import com.twilio.conversations.app.common.enums.Direction -import com.twilio.conversations.app.common.enums.DownloadState import com.twilio.conversations.app.common.enums.MessageType import com.twilio.conversations.app.common.enums.Reaction import com.twilio.conversations.app.common.enums.Reactions @@ -24,6 +23,7 @@ import com.twilio.conversations.app.common.extensions.asMessageDateString import com.twilio.conversations.app.common.extensions.firstMedia import com.twilio.conversations.app.data.localCache.entity.ConversationDataItem import com.twilio.conversations.app.data.localCache.entity.MessageDataItem +import com.twilio.conversations.app.data.localCache.entity.MessageAttachmentDataItem import com.twilio.conversations.app.data.localCache.entity.ParticipantDataItem import com.twilio.conversations.app.data.models.* import com.twilio.conversations.app.manager.friendlyName @@ -63,10 +63,15 @@ fun Message.toMessageDataItem(currentUserIdentity: String = participant.identity if (this.author == currentUserIdentity) Direction.OUTGOING.value else Direction.INCOMING.value, if (this.author == currentUserIdentity) SendStatus.SENT.value else SendStatus.UNDEFINED.value, uuid, - media?.sid, - media?.filename, - media?.contentType, - media?.size + attachmentsList = this.attachedMedia.map { mediaItem -> + MessageAttachmentDataItem( + sid = mediaItem.sid, + fileName = mediaItem.filename, + type = mediaItem.contentType, + size = mediaItem.size + ) + }, + mediaSize = this.attachedMedia.sumOf { it.size } ) } @@ -84,17 +89,22 @@ fun MessageDataItem.toMessageListViewItem(authorChanged: Boolean): MessageListVi sendStatusIcon = SendStatus.fromInt(this.sendStatus).asLastMesageStatusIcon(), getReactions(attributes).asReactionList(), MessageType.fromInt(this.type), - this.mediaSid, - this.mediaFileName, - this.mediaType, + attachmentsList = this.attachmentsList.map { attachment -> + MessageAttachmentViewItem( + attachment.sid, + attachment.fileName, + attachment.type, + attachment.size, + attachment.uri?.toUri(), + attachment.downloadId, + attachment.downloadedBytes, + attachment.downloadState, + attachment.uploading, + attachment.uploadedBytes, + attachment.uploadUri?.toUri() + ) + }, this.mediaSize, - this.mediaUri?.toUri(), - this.mediaDownloadId, - this.mediaDownloadedBytes, - DownloadState.fromInt(this.mediaDownloadState), - this.mediaUploading, - this.mediaUploadedBytes, - this.mediaUploadUri?.toUri(), this.errorCode ) } diff --git a/app/src/main/java/com/twilio/conversations/app/data/localCache/LocalCacheProvider.kt b/app/src/main/java/com/twilio/conversations/app/data/localCache/LocalCacheProvider.kt index 6178858..4857ec1 100644 --- a/app/src/main/java/com/twilio/conversations/app/data/localCache/LocalCacheProvider.kt +++ b/app/src/main/java/com/twilio/conversations/app/data/localCache/LocalCacheProvider.kt @@ -4,6 +4,8 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.twilio.conversations.app.data.localCache.converters.AttachmentsListConverter import com.twilio.conversations.app.data.localCache.dao.ConversationsDao import com.twilio.conversations.app.data.localCache.dao.MessagesDao import com.twilio.conversations.app.data.localCache.dao.ParticipantsDao @@ -12,6 +14,7 @@ import com.twilio.conversations.app.data.localCache.entity.MessageDataItem import com.twilio.conversations.app.data.localCache.entity.ParticipantDataItem @Database(entities = [ConversationDataItem::class, MessageDataItem::class, ParticipantDataItem::class], version = 1, exportSchema = false) +@TypeConverters(AttachmentsListConverter::class) abstract class LocalCacheProvider : RoomDatabase() { abstract fun conversationsDao(): ConversationsDao diff --git a/app/src/main/java/com/twilio/conversations/app/data/localCache/converters/AttachmentsListConverter.kt b/app/src/main/java/com/twilio/conversations/app/data/localCache/converters/AttachmentsListConverter.kt new file mode 100644 index 0000000..5065420 --- /dev/null +++ b/app/src/main/java/com/twilio/conversations/app/data/localCache/converters/AttachmentsListConverter.kt @@ -0,0 +1,21 @@ +package com.twilio.conversations.app.data.localCache.converters + +import androidx.room.TypeConverter +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.twilio.conversations.app.data.localCache.entity.MessageAttachmentDataItem + +class AttachmentsListConverter { + private val gson = Gson() + + @TypeConverter + fun fromAttachmentsList(attachmentsList: List): String { + return gson.toJson(attachmentsList) + } + + @TypeConverter + fun toAttachmentsList(attachmentsListString: String): List { + val listType = object : TypeToken>() {}.type + return gson.fromJson(attachmentsListString, listType) ?: emptyList() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/twilio/conversations/app/data/localCache/dao/MediaUpdateHelper.kt b/app/src/main/java/com/twilio/conversations/app/data/localCache/dao/MediaUpdateHelper.kt new file mode 100644 index 0000000..54de2c5 --- /dev/null +++ b/app/src/main/java/com/twilio/conversations/app/data/localCache/dao/MediaUpdateHelper.kt @@ -0,0 +1,114 @@ +package com.twilio.conversations.app.data.localCache.dao + +import com.twilio.conversations.app.common.enums.DownloadState +import com.twilio.conversations.app.data.localCache.converters.AttachmentsListConverter + +class MediaUpdateHelper { + private val mediaConverter = AttachmentsListConverter() + + fun updateAttachmentDownloadState( + dao: MessagesDao, + messageSid: String, + attachmentSid: String, + downloadState: DownloadState + ) { + val message = dao.getMessageForAttachmentsUpdate(messageSid) ?: return + + val updatedMediaList = message.attachmentsList.map { mediaItem -> + if (mediaItem.sid == attachmentSid) { + mediaItem.copy(downloadState = downloadState) + } else { + mediaItem + } + } + + val updatedMediaJson = mediaConverter.fromAttachmentsList(updatedMediaList) + dao.updateMessageAttachments(messageSid, updatedMediaJson) + } + + fun updateAttachmentDownloadProgress( + dao: MessagesDao, + messageSid: String, + attachmentSid: String, + downloadedBytes: Long, + ) { + val message = dao.getMessageForAttachmentsUpdate(messageSid) ?: return + + val updatedMediaList = message.attachmentsList.map { mediaItem -> + if (mediaItem.sid == attachmentSid) { + mediaItem.copy( + downloadedBytes = downloadedBytes, + ) + } else { + mediaItem + } + } + + val updatedMediaJson = mediaConverter.fromAttachmentsList(updatedMediaList) + dao.updateMessageAttachments(messageSid, updatedMediaJson) + } + + fun updateAttachmentDownloadLocation( + dao: MessagesDao, + messageSid: String, + attachmentSid: String, + uri: String, + ) { + val message = dao.getMessageForAttachmentsUpdate(messageSid) ?: return + + val updatedMediaList = message.attachmentsList.map { mediaItem -> + if (mediaItem.sid == attachmentSid) { + mediaItem.copy(uri = uri) + } else { + mediaItem + } + } + + val updatedMediaJson = mediaConverter.fromAttachmentsList(updatedMediaList) + dao.updateMessageAttachments(messageSid, updatedMediaJson) + } + + fun updateAttachmentDownloadId( + dao: MessagesDao, + messageSid: String, + attachmentSid: String, + downloadId: Long + ) { + val message = dao.getMessageForAttachmentsUpdate(messageSid) ?: return + + val updatedMediaList = message.attachmentsList.map { mediaItem -> + if (mediaItem.sid == attachmentSid) { + mediaItem.copy(downloadId = downloadId) + } else { + mediaItem + } + } + + val updatedMediaJson = mediaConverter.fromAttachmentsList(updatedMediaList) + dao.updateMessageAttachments(messageSid, updatedMediaJson) + } + + fun updateAttachmentUploadProgressByUuid( + dao: MessagesDao, + messageUuid: String, + attachmentUuid: String, + uploadedBytes: Long, + uploading: Boolean + ) { + val message = dao.getMessageByUuid(messageUuid) ?: return + + val updatedMediaList = message.attachmentsList.map { mediaItem -> + if (mediaItem.uuid == attachmentUuid) { + mediaItem.copy( + uploadedBytes = uploadedBytes, + uploading = uploading + ) + } else { + mediaItem + } + } + + val updatedMediaJson = mediaConverter.fromAttachmentsList(updatedMediaList) + dao.updateMessageAttachmentsByUuid(messageUuid, updatedMediaJson) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/twilio/conversations/app/data/localCache/dao/MessagesDao.kt b/app/src/main/java/com/twilio/conversations/app/data/localCache/dao/MessagesDao.kt index b3c4db1..ba0bc06 100644 --- a/app/src/main/java/com/twilio/conversations/app/data/localCache/dao/MessagesDao.kt +++ b/app/src/main/java/com/twilio/conversations/app/data/localCache/dao/MessagesDao.kt @@ -7,6 +7,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction +import com.twilio.conversations.app.common.enums.DownloadState import com.twilio.conversations.app.data.localCache.entity.MessageDataItem @Dao @@ -56,22 +57,42 @@ interface MessagesDao { @Delete fun delete(message: MessageDataItem) - @Query("UPDATE message_table SET mediaDownloadState = :downloadState WHERE sid = :messageSid") - fun updateMediaDownloadState(messageSid: String, downloadState: Int) + @Query("SELECT * FROM message_table WHERE sid = :messageSid") + fun getMessageForAttachmentsUpdate(messageSid: String): MessageDataItem? + + @Query("UPDATE message_table SET attachmentsList = :updatedMediaJson WHERE sid = :messageSid") + fun updateMessageAttachments(messageSid: String, updatedMediaJson: String) - @Query("UPDATE message_table SET mediaDownloadedBytes = :downloadedBytes WHERE sid = :messageSid") - fun updateMediaDownloadedBytes(messageSid: String, downloadedBytes: Long) + @Query("UPDATE message_table SET attachmentsList = :updatedMediaJson WHERE uuid = :messageUuid") + fun updateMessageAttachmentsByUuid(messageUuid: String, updatedMediaJson: String) - @Query("UPDATE message_table SET mediaUri = :location WHERE sid = :messageSid") - fun updateMediaDownloadLocation(messageSid: String, location: String) - - @Query("UPDATE message_table SET mediaDownloadId = :downloadId WHERE sid = :messageSid") - fun updateMediaDownloadId(messageSid: String, downloadId: Long) + @Transaction + fun updateAttachmentDownloadState(messageSid: String, attachmentSid: String, downloadState: DownloadState) { + val helper = MediaUpdateHelper() + helper.updateAttachmentDownloadState(this, messageSid, attachmentSid, downloadState) + } - @Query("UPDATE message_table SET mediaUploading = :downloading WHERE uuid = :uuid") - fun updateMediaUploadStatus(uuid: String, downloading: Boolean) + @Transaction + fun updateAttachmentDownloadProgress(messageSid: String, attachmentSid: String, downloadedBytes: Long) { + val helper = MediaUpdateHelper() + helper.updateAttachmentDownloadProgress(this, messageSid, attachmentSid, downloadedBytes) + } - @Query("UPDATE message_table SET mediaUploadedBytes = :downloadedBytes WHERE uuid = :uuid") - fun updateMediaUploadedBytes(uuid: String, downloadedBytes: Long) + @Transaction + fun updateAttachmentUri(messageSid: String, attachmentSid: String, uri: String) { + val helper = MediaUpdateHelper() + helper.updateAttachmentDownloadLocation(this, messageSid, attachmentSid, uri) + } + + @Transaction + fun updateAttachmentDownloadId(messageSid: String, attachmentSid: String, downloadId: Long) { + val helper = MediaUpdateHelper() + helper.updateAttachmentDownloadId(this, messageSid, attachmentSid, downloadId) + } + @Transaction + fun updateMediaUploadStatus(messageUuid: String, attachmentUuid: String, uploadedBytes: Long, uploading: Boolean) { + val helper = MediaUpdateHelper() + helper.updateAttachmentUploadProgressByUuid(this, messageUuid, attachmentUuid, uploadedBytes, uploading) + } } diff --git a/app/src/main/java/com/twilio/conversations/app/data/localCache/entity/MessageDataItem.kt b/app/src/main/java/com/twilio/conversations/app/data/localCache/entity/MessageDataItem.kt index f070aae..69ba030 100644 --- a/app/src/main/java/com/twilio/conversations/app/data/localCache/entity/MessageDataItem.kt +++ b/app/src/main/java/com/twilio/conversations/app/data/localCache/entity/MessageDataItem.kt @@ -1,7 +1,22 @@ package com.twilio.conversations.app.data.localCache.entity import androidx.room.Entity -import androidx.room.PrimaryKey +import com.twilio.conversations.app.common.enums.DownloadState + +data class MessageAttachmentDataItem( + val uuid: String = "", + val sid: String = "", + val fileName: String? = null, + val type: String? = null, + val size: Long? = null, + val uri: String? = null, + val downloadId: Long? = null, + val downloadedBytes: Long? = null, + val downloadState: DownloadState = DownloadState.NOT_STARTED, + val uploading: Boolean = false, + val uploadedBytes: Long? = null, + val uploadUri: String? = null, +) @Entity(tableName = "message_table", primaryKeys = ["sid", "uuid"]) data class MessageDataItem( @@ -17,16 +32,7 @@ data class MessageDataItem( val direction: Int, val sendStatus: Int, val uuid: String, - val mediaSid: String? = null, - val mediaFileName: String? = null, - val mediaType: String? = null, + val attachmentsList: List = emptyList(), val mediaSize: Long? = null, - val mediaUri: String? = null, - val mediaDownloadId: Long? = null, - val mediaDownloadedBytes: Long? = null, - val mediaDownloadState: Int = 0, - val mediaUploading: Boolean = false, - val mediaUploadedBytes: Long? = null, - val mediaUploadUri: String? = null, val errorCode: Int = 0 ) diff --git a/app/src/main/java/com/twilio/conversations/app/data/models/MessageListViewItem.kt b/app/src/main/java/com/twilio/conversations/app/data/models/MessageListViewItem.kt index 0849e66..4c737ad 100644 --- a/app/src/main/java/com/twilio/conversations/app/data/models/MessageListViewItem.kt +++ b/app/src/main/java/com/twilio/conversations/app/data/models/MessageListViewItem.kt @@ -4,10 +4,23 @@ import android.net.Uri import com.twilio.conversations.app.common.enums.Direction import com.twilio.conversations.app.common.enums.DownloadState import com.twilio.conversations.app.common.enums.MessageType -import com.twilio.conversations.app.common.enums.Reaction import com.twilio.conversations.app.common.enums.Reactions import com.twilio.conversations.app.common.enums.SendStatus +data class MessageAttachmentViewItem( + val sid: String, + val fileName: String?, + val type: String?, + val size: Long?, + val uri: Uri?, + val downloadId: Long?, + val downloadedBytes: Long?, + val downloadState: DownloadState, + val uploading: Boolean, + val uploadedBytes: Long?, + val uploadUri: Uri? +) + data class MessageListViewItem( val sid: String, val uuid: String, @@ -21,16 +34,7 @@ data class MessageListViewItem( val sendStatusIcon: Int, val reactions: Reactions, val type: MessageType, - val mediaSid: String?, - val mediaFileName: String?, - val mediaType: String?, + val attachmentsList: List = emptyList(), val mediaSize: Long?, - val mediaUri: Uri?, - val mediaDownloadId: Long?, - val mediaDownloadedBytes: Long?, - val mediaDownloadState: DownloadState, - val mediaUploading: Boolean, - val mediaUploadedBytes: Long?, - val mediaUploadUri: Uri?, val errorCode: Int ) diff --git a/app/src/main/java/com/twilio/conversations/app/manager/MessageListManager.kt b/app/src/main/java/com/twilio/conversations/app/manager/MessageListManager.kt index 799537d..7cd407a 100644 --- a/app/src/main/java/com/twilio/conversations/app/manager/MessageListManager.kt +++ b/app/src/main/java/com/twilio/conversations/app/manager/MessageListManager.kt @@ -10,11 +10,11 @@ import com.twilio.conversations.app.common.enums.DownloadState import com.twilio.conversations.app.common.enums.MessageType import com.twilio.conversations.app.common.enums.Reactions import com.twilio.conversations.app.common.enums.SendStatus -import com.twilio.conversations.app.common.extensions.firstMedia import com.twilio.conversations.app.common.extensions.removeMessage import com.twilio.conversations.app.common.toMessageDataItem import com.twilio.conversations.app.data.ConversationsClientWrapper import com.twilio.conversations.app.data.localCache.entity.MessageDataItem +import com.twilio.conversations.app.data.localCache.entity.MessageAttachmentDataItem import com.twilio.conversations.app.data.models.ReactionAttributes import com.twilio.conversations.app.repository.ConversationsRepository import com.twilio.conversations.extensions.advanceLastReadMessageIndex @@ -30,20 +30,26 @@ import timber.log.Timber import java.io.InputStream import java.util.* +data class MediaInput ( + val uuid: String, + val uri: String, + val inputStream: InputStream, + val fileName: String?, + val mimeType: String? +) + interface MessageListManager { suspend fun sendTextMessage(text: String, uuid: String) suspend fun retrySendTextMessage(messageUuid: String) - suspend fun sendMediaMessage( - uri: String, - inputStream: InputStream, - fileName: String?, - mimeType: String?, + suspend fun sendMultipleMediaMessage( + items: List, messageUuid: String ) - suspend fun retrySendMediaMessage(inputStream: InputStream, messageUuid: String) + suspend fun retrySendMediaMessage(items: List, messageUuid: String) suspend fun updateMessageStatus(messageUuid: String, sendStatus: SendStatus, errorCode: Int = 0) suspend fun updateMessageMediaDownloadState( index: Long, + attachmentSid: String, downloadState: DownloadState, downloadedBytes: Long, downloadedLocation: String? @@ -51,8 +57,8 @@ interface MessageListManager { suspend fun setReactions(index: Long, reactions: Reactions) suspend fun notifyMessageRead(index: Long) suspend fun typing() - suspend fun getMediaContentTemporaryUrl(index: Long): String - suspend fun setMessageMediaDownloadId(messageIndex: Long, id: Long) + suspend fun getMediaContentTemporaryUrl(index: Long, attachmentSid: String): String + suspend fun setMessageMediaDownloadId(messageIndex: Long, attachmentSid: String, id: Long) suspend fun removeMessage(messageIndex: Long) } @@ -110,73 +116,70 @@ class MessageListManagerImpl( conversationsRepository.updateMessageByUuid(sentMessage) } - override suspend fun sendMediaMessage( - uri: String, - inputStream: InputStream, - fileName: String?, - mimeType: String?, - messageUuid: String - ) { + override suspend fun sendMultipleMediaMessage(items: List, messageUuid: String) { val identity = conversationsClient.getConversationsClient().myIdentity val conversation = conversationsClient.getConversationsClient().getConversation(conversationSid) val participantSid = conversation.getParticipantByIdentity(identity).sid val attributes = Attributes(messageUuid) - val message = MessageDataItem( - "", - conversationSid, - participantSid, - MessageType.MEDIA.value, - identity, - Date().time, - null, - -1, - attributes.toString(), - Direction.OUTGOING.value, - SendStatus.SENDING.value, - messageUuid, - mediaFileName = fileName, - mediaUploadUri = uri, - mediaType = mimeType - ) - conversationsRepository.insertMessage(message) - val sentMessage = conversation.sendMessage { - this.attributes = attributes - addMedia( - inputStream, - mimeType ?: "", - fileName, - createMediaUploadListener(uri, messageUuid) + val newMessage = + MessageDataItem( + "", + conversationSid, + participantSid, + MessageType.MEDIA.value, + identity, + Date().time, + null, + -1, + attributes.toString(), + Direction.OUTGOING.value, + SendStatus.SENDING.value, + messageUuid, + attachmentsList = items.filterNotNull().map { it -> + MessageAttachmentDataItem(uuid = it.uuid, uri = it.uri, fileName = it.fileName, type = it.mimeType, downloadState = DownloadState.COMPLETED) + } ) - }.toMessageDataItem(identity, messageUuid) - conversationsRepository.updateMessageByUuid(sentMessage) + conversationsRepository.insertMessage(newMessage) + + val message = conversation.sendMessage { + items.filterNotNull().forEach { + this.attributes = attributes + it.inputStream?.let { it1 -> + addMedia( + it1, + it.mimeType ?: "", + it.fileName, + createMediaUploadListener(it.uri, messageUuid, it.uuid) + ) + } + } + + } + + conversationsRepository.updateMessageByUuid(message.toMessageDataItem(identity, messageUuid)) } - override suspend fun retrySendMediaMessage( - inputStream: InputStream, - messageUuid: String - ) { + override suspend fun retrySendMediaMessage(items: List, messageUuid: String) { val message = withContext(dispatchers.io()) { conversationsRepository.getMessageByUuid(messageUuid) } ?: return if (message.sendStatus == SendStatus.SENDING.value) return - if (message.mediaUploadUri == null) { - Timber.w("Missing mediaUploadUri in retrySendMediaMessage: $message") - return - } + conversationsRepository.updateMessageByUuid(message.copy(sendStatus = SendStatus.SENDING.value)) val identity = conversationsClient.getConversationsClient().myIdentity val conversation = conversationsClient.getConversationsClient().getConversation(conversationSid) - val sentMessage = conversation.sendMessage { this.attributes = Attributes(messageUuid) - addMedia( - inputStream, - message.mediaType ?: "", - message.mediaFileName, - createMediaUploadListener(message.mediaUploadUri, messageUuid) - ) - }.toMessageDataItem(identity, message.uuid) + items.filterNotNull().forEach { mediaInput -> + addMedia( + mediaInput.inputStream, + mediaInput.mimeType ?: "", + mediaInput.fileName, + createMediaUploadListener(mediaInput.uri, messageUuid, mediaInput.uuid) + ) + } + }.toMessageDataItem(identity, messageUuid) conversationsRepository.updateMessageByUuid(sentMessage) } @@ -184,28 +187,32 @@ class MessageListManagerImpl( private fun createMediaUploadListener( uri: String, messageUuid: String, + attachmentUuid: String ): MediaUploadListener { return object: MediaUploadListener { override fun onStarted() { Timber.d("Upload started for $uri") - conversationsRepository.updateMessageMediaUploadStatus( - messageUuid - ) + conversationsRepository.updateMessageMediaUploadStatus( + messageUuid, attachmentUuid + ) } override fun onProgress(bytesSent: Long) { Timber.d("Upload progress for $uri: $bytesSent bytes") - conversationsRepository.updateMessageMediaUploadStatus( - messageUuid, - uploadedBytes = bytesSent - ) + conversationsRepository.updateMessageMediaUploadStatus( + messageUuid, + attachmentUuid, + uploadedBytes = bytesSent, + uploading = true + ) } override fun onCompleted(mediaSid: kotlin.String) { Timber.d("Upload for $uri complete: $mediaSid") conversationsRepository.updateMessageMediaUploadStatus( messageUuid, + attachmentUuid, uploading = false ) } @@ -223,17 +230,19 @@ class MessageListManagerImpl( override suspend fun updateMessageMediaDownloadState( index: Long, + attachmentSid: String, downloadState: DownloadState, downloadedBytes: Long, downloadedLocation: String? ) { - val message = conversationsClient.getConversationsClient().getConversation(conversationSid).getMessageByIndex(index) - conversationsRepository.updateMessageMediaDownloadStatus( - messageSid = message.sid, - downloadedBytes = downloadedBytes, - downloadLocation = downloadedLocation, - downloadState = downloadState.value - ) + val message = conversationsClient.getConversationsClient().getConversation(conversationSid).getMessageByIndex(index) + conversationsRepository.updateMessageMediaDownloadStatus( + messageSid = message.sid, + attachmentSid = attachmentSid, + downloadedBytes = downloadedBytes, + downloadLocation = downloadedLocation, + downloadState = downloadState + ) } override suspend fun setReactions(index: Long, reactions: Reactions) { @@ -260,14 +269,14 @@ class MessageListManagerImpl( conversationsClient.getConversationsClient().getConversation(conversationSid).typing() } - override suspend fun getMediaContentTemporaryUrl(index: Long): String { + override suspend fun getMediaContentTemporaryUrl(index: Long, attachmentSid: String): String { val message = conversationsClient.getConversationsClient().getConversation(conversationSid).getMessageByIndex(index) - return message.firstMedia?.getTemporaryContentUrl()!! + return message.attachedMedia.find { it.sid == attachmentSid }?.getTemporaryContentUrl()!! } - override suspend fun setMessageMediaDownloadId(messageIndex: Long, id: Long) { + override suspend fun setMessageMediaDownloadId(messageIndex: Long, attachmentSid: String, id: Long) { val message = conversationsClient.getConversationsClient().getConversation(conversationSid).getMessageByIndex(messageIndex) - conversationsRepository.updateMessageMediaDownloadStatus(messageSid = message.sid, downloadId = id) + conversationsRepository.updateMessageMediaDownloadStatus(messageSid = message.sid, attachmentSid = attachmentSid, downloadId = id) } override suspend fun removeMessage(messageIndex: Long) { diff --git a/app/src/main/java/com/twilio/conversations/app/repository/ConversationsRepository.kt b/app/src/main/java/com/twilio/conversations/app/repository/ConversationsRepository.kt index 6d9d501..077b8fa 100644 --- a/app/src/main/java/com/twilio/conversations/app/repository/ConversationsRepository.kt +++ b/app/src/main/java/com/twilio/conversations/app/repository/ConversationsRepository.kt @@ -10,6 +10,7 @@ import com.twilio.conversations.app.common.asMessageDataItems import com.twilio.conversations.app.common.asMessageListViewItems import com.twilio.conversations.app.common.asParticipantDataItem import com.twilio.conversations.app.common.enums.CrashIn +import com.twilio.conversations.app.common.enums.DownloadState import com.twilio.conversations.app.common.extensions.getAndSubscribeUser import com.twilio.conversations.app.common.extensions.getMessageCount import com.twilio.conversations.app.common.extensions.getParticipantCount @@ -23,6 +24,7 @@ import com.twilio.conversations.app.data.ConversationsClientWrapper import com.twilio.conversations.app.data.localCache.LocalCacheProvider import com.twilio.conversations.app.data.localCache.entity.ConversationDataItem import com.twilio.conversations.app.data.localCache.entity.MessageDataItem +import com.twilio.conversations.app.data.localCache.entity.MessageAttachmentDataItem import com.twilio.conversations.app.data.localCache.entity.ParticipantDataItem import com.twilio.conversations.app.data.models.MessageListViewItem import com.twilio.conversations.app.data.models.RepositoryRequestStatus @@ -74,16 +76,19 @@ interface ConversationsRepository { fun getConversationParticipants(conversationSid: String): Flow>> fun updateMessageMediaDownloadStatus( messageSid: String, + attachmentSid: String, downloadId: Long? = null, downloadLocation: String? = null, - downloadState: Int? = null, + downloadState: DownloadState? = null, downloadedBytes: Long? = null ) fun updateMessageMediaUploadStatus( messageUuid: String, + attachmentUuid: String, uploading: Boolean? = null, uploadedBytes: Long? = null ) + fun updateMessageAttachments(messageUuid: String, attachments: List) fun simulateCrash(where: CrashIn) fun clear() fun subscribeToConversationsClientEvents() @@ -271,42 +276,49 @@ class ConversationsRepositoryImpl( override fun updateMessageMediaDownloadStatus( messageSid: String, + attachmentSid: String, downloadId: Long?, downloadLocation: String?, - downloadState: Int?, + downloadState: DownloadState?, downloadedBytes: Long? ) { launch { if (downloadId != null) { - localCache.messagesDao().updateMediaDownloadId(messageSid, downloadId) + localCache.messagesDao().updateAttachmentDownloadId(messageSid, attachmentSid, downloadId) } if (downloadLocation != null) { - localCache.messagesDao().updateMediaDownloadLocation(messageSid, downloadLocation) + localCache.messagesDao().updateAttachmentUri(messageSid, attachmentSid, downloadLocation) } if (downloadState != null) { - localCache.messagesDao().updateMediaDownloadState(messageSid, downloadState) + localCache.messagesDao().updateAttachmentDownloadState(messageSid, attachmentSid, downloadState) } if (downloadedBytes != null) { - localCache.messagesDao().updateMediaDownloadedBytes(messageSid, downloadedBytes) + localCache.messagesDao().updateAttachmentDownloadProgress(messageSid, attachmentSid, downloadedBytes) } } } override fun updateMessageMediaUploadStatus( messageUuid: String, + attachmentUuid: String, uploading: Boolean?, uploadedBytes: Long? ) { launch { if (uploading != null) { - localCache.messagesDao().updateMediaUploadStatus(messageUuid, uploading) - } - if (uploadedBytes != null) { - localCache.messagesDao().updateMediaUploadedBytes(messageUuid, uploadedBytes) + localCache.messagesDao().updateMediaUploadStatus(messageUuid, attachmentUuid, uploadedBytes = 0, uploading) } } } + override fun updateMessageAttachments(messageUuid: String, attachments: List) { + launch { + val message = localCache.messagesDao().getMessageByUuid(messageUuid) ?: return@launch + val updatedMessage = message.copy(attachmentsList = attachments) + localCache.messagesDao().insertOrReplace(updatedMessage) + } + } + override fun simulateCrash(where: CrashIn) { launch { conversationsClientWrapper.getConversationsClient().simulateCrash(where) diff --git a/app/src/main/java/com/twilio/conversations/app/ui/MessageListActivity.kt b/app/src/main/java/com/twilio/conversations/app/ui/MessageListActivity.kt index cab6555..c6ba6d8 100644 --- a/app/src/main/java/com/twilio/conversations/app/ui/MessageListActivity.kt +++ b/app/src/main/java/com/twilio/conversations/app/ui/MessageListActivity.kt @@ -20,6 +20,7 @@ import com.twilio.conversations.app.common.extensions.* import com.twilio.conversations.app.common.injector import com.twilio.conversations.app.data.models.MessageListViewItem import com.twilio.conversations.app.databinding.ActivityMessageListBinding +import com.twilio.conversations.app.manager.MediaInput import com.twilio.conversations.app.ui.dialogs.AttachFileDialog import com.twilio.conversations.app.ui.dialogs.MessageActionsDialog import com.twilio.conversations.app.ui.dialogs.ReactionDetailsDialog @@ -67,9 +68,12 @@ class MessageListActivity : BaseActivity() { Timber.d("Display send error clicked: ${message.uuid}") showSendErrorDialog(message) }, - onDownloadMedia = { message -> + onDownloadMedia = { message, media -> Timber.d("Download clicked: $message") - messageListViewModel.startMessageMediaDownload(message.index, message.mediaFileName) + media.sid?.let { + messageListViewModel.startMessageMediaDownload(message.index, + it, media.fileName) + } }, onOpenMedia = { uri, mimeType -> Timber.d("Open clicked") @@ -207,11 +211,29 @@ class MessageListActivity : BaseActivity() { if (message.type == MessageType.TEXT) { messageListViewModel.resendTextMessage(message.uuid) } else if (message.type == MessageType.MEDIA) { - val fileInputStream = message.mediaUploadUri?.let { contentResolver.openInputStream(it) } - if (fileInputStream != null) { - messageListViewModel.resendMediaMessage(fileInputStream, message.uuid) - } else { - Timber.w("Could not get input stream for file reading: ${message.mediaUploadUri}") + try { + val mediaInputs = message.attachmentsList.mapNotNull { attachment -> + attachment.uploadUri?.let { uploadUri -> + val inputStream = contentResolver.openInputStream(uploadUri) + if (inputStream != null) { + MediaInput( + uuid = UUID.randomUUID().toString(), + uri = uploadUri.toString(), + inputStream = inputStream, + fileName = attachment.fileName, + mimeType = attachment.type + ) + } else { + null + } + } + } + if (mediaInputs.isNotEmpty()) { + messageListViewModel.resendMediaMessage(mediaInputs, message.uuid) + } else { + showToast(R.string.err_failed_to_resend_media) + } + } catch (e: Exception) { showToast(R.string.err_failed_to_resend_media) } } diff --git a/app/src/main/java/com/twilio/conversations/app/ui/dialogs/AttachFileDialog.kt b/app/src/main/java/com/twilio/conversations/app/ui/dialogs/AttachFileDialog.kt index 381315b..b35a120 100644 --- a/app/src/main/java/com/twilio/conversations/app/ui/dialogs/AttachFileDialog.kt +++ b/app/src/main/java/com/twilio/conversations/app/ui/dialogs/AttachFileDialog.kt @@ -8,7 +8,7 @@ import android.provider.OpenableColumns import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.activity.result.contract.ActivityResultContracts.OpenDocument +import androidx.activity.result.contract.ActivityResultContracts.OpenMultipleDocuments import androidx.activity.result.contract.ActivityResultContracts.TakePicture import androidx.core.content.FileProvider import com.twilio.conversations.app.common.enums.ConversationsError @@ -18,6 +18,7 @@ import com.twilio.conversations.app.common.extensions.lazyActivityViewModel import com.twilio.conversations.app.common.extensions.parcelable import com.twilio.conversations.app.common.injector import com.twilio.conversations.app.databinding.DialogAttachFileBinding +import com.twilio.conversations.app.manager.MediaInput import timber.log.Timber import java.io.File import java.text.SimpleDateFormat @@ -39,8 +40,10 @@ class AttachFileDialog : BaseBottomSheetDialogFragment() { dismiss() } - private val openDocument = registerForActivityResult(OpenDocument()) { uri: Uri? -> - uri?.let { sendMediaMessage(it) } + private val openMultipleDocuments = registerForActivityResult(OpenMultipleDocuments()) { uriList: List? -> + if (uriList != null) { + sendMultipleMediaMessage(uriList) + } dismiss() } @@ -66,7 +69,7 @@ class AttachFileDialog : BaseBottomSheetDialogFragment() { } binding.fileManager.setOnClickListener { - openDocument.launch(arrayOf("*/*")) + openMultipleDocuments.launch(arrayOf("*/*")) } } @@ -92,13 +95,36 @@ class AttachFileDialog : BaseBottomSheetDialogFragment() { val type = contentResolver.getType(uri) val name = contentResolver.getString(uri, OpenableColumns.DISPLAY_NAME) if (inputStream != null) { - messageListViewModel.sendMediaMessage(uri.toString(), inputStream, name, type) + messageListViewModel.sendMultipleMediaMessage(listOf(MediaInput("", uri.toString(), inputStream, name, type))) } else { messageListViewModel.onMessageError.value = ConversationsError.MESSAGE_SEND_FAILED Timber.w("Could not get input stream for file reading: $uri") } } + fun sendMultipleMediaMessage(uriList: List) { + var failed = false + val contentResolver = requireContext().contentResolver + val mediaInput = uriList.map { uri -> + val inputStream = contentResolver.openInputStream(uri) + val type = contentResolver.getType(uri) + val name = contentResolver.getString(uri, OpenableColumns.DISPLAY_NAME) + val uuid = UUID.randomUUID().toString() + if (inputStream == null) { + failed = true; + Timber.w("Could not get input stream for file reading: $uri") + null + } else { + MediaInput(uuid, uri.toString(), inputStream, name, type) + } + } + if (failed) { + messageListViewModel.onMessageError.value = ConversationsError.MESSAGE_SEND_FAILED + + } + + messageListViewModel.sendMultipleMediaMessage(mediaInput) + } companion object { diff --git a/app/src/main/java/com/twilio/conversations/app/ui/dialogs/MessageActionsDialog.kt b/app/src/main/java/com/twilio/conversations/app/ui/dialogs/MessageActionsDialog.kt index 27abbf3..2238660 100644 --- a/app/src/main/java/com/twilio/conversations/app/ui/dialogs/MessageActionsDialog.kt +++ b/app/src/main/java/com/twilio/conversations/app/ui/dialogs/MessageActionsDialog.kt @@ -65,16 +65,40 @@ class MessageActionsDialog : BaseBottomSheetDialogFragment() { } private fun shareMessage(message: MessageListViewItem) { - val intent = Intent(Intent.ACTION_SEND) - if (message.type == MEDIA) { - intent.type = message.mediaType - val uri = message.mediaUploadUri ?: message.mediaUri ?: return - intent.putExtra(Intent.EXTRA_STREAM, uri) - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + shareMediaMessage(message) + } else { + shareTextMessage(message) + } + } + + private fun shareTextMessage(message: MessageListViewItem) { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, message.body) + } + startActivity(Intent.createChooser(intent, null)) + } + + private fun shareMediaMessage(message: MessageListViewItem) { + val availableUris = message.attachmentsList.mapNotNull { attachment -> + attachment.uploadUri ?: attachment.uri + } + + if (availableUris.isEmpty()) return + + val intent = if (availableUris.size == 1) { + Intent(Intent.ACTION_SEND).apply { + type = message.attachmentsList.first().type ?: "*/*" + putExtra(Intent.EXTRA_STREAM, availableUris.first()) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } } else { - intent.type = "text/plain" - intent.putExtra(Intent.EXTRA_TEXT, message.body) + Intent(Intent.ACTION_SEND_MULTIPLE).apply { + type = "*/*" + putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(availableUris)) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } } startActivity(Intent.createChooser(intent, null)) diff --git a/app/src/main/java/com/twilio/conversations/app/viewModel/MessageListViewModel.kt b/app/src/main/java/com/twilio/conversations/app/viewModel/MessageListViewModel.kt index 0d3fd9d..a46e44a 100644 --- a/app/src/main/java/com/twilio/conversations/app/viewModel/MessageListViewModel.kt +++ b/app/src/main/java/com/twilio/conversations/app/viewModel/MessageListViewModel.kt @@ -34,6 +34,7 @@ import com.twilio.conversations.app.common.extensions.queryById import com.twilio.conversations.app.data.localCache.entity.ParticipantDataItem import com.twilio.conversations.app.data.models.MessageListViewItem import com.twilio.conversations.app.data.models.RepositoryRequestStatus +import com.twilio.conversations.app.manager.MediaInput import com.twilio.conversations.app.manager.MessageListManager import com.twilio.conversations.app.repository.ConversationsRepository import com.twilio.util.TwilioException @@ -89,9 +90,11 @@ class MessageListViewModel( private val messagesObserver: Observer> = Observer { list -> list.forEach { message -> - if (message?.mediaDownloadState == DownloadState.DOWNLOADING && message.mediaDownloadId != null) { - if (updateMessageMediaDownloadState(message.index, message.mediaDownloadId)) { - observeMessageMediaDownload(message.index, message.mediaDownloadId) + message.attachmentsList.forEach { attachment -> + if (attachment?.downloadState == DownloadState.DOWNLOADING && attachment.downloadId != null) { + if (updateMessageMediaDownloadState(message.index, attachment.sid, attachment.downloadId)) { + observeMessageMediaDownload(message.index, attachment.sid, attachment.downloadId) + } } } } @@ -144,11 +147,11 @@ class MessageListViewModel( } } - fun sendMediaMessage(uri: String, inputStream: InputStream, fileName: String?, mimeType: String?) = + fun sendMultipleMediaMessage(items: List) = viewModelScope.launch { val messageUuid = UUID.randomUUID().toString() try { - messageListManager.sendMediaMessage(uri, inputStream, fileName, mimeType, messageUuid) + messageListManager.sendMultipleMediaMessage(items, messageUuid) onMessageSent.call() Timber.d("Media message sent: $messageUuid") } catch (e: TwilioException) { @@ -158,9 +161,9 @@ class MessageListViewModel( } } - fun resendMediaMessage(inputStream: InputStream, messageUuid: String) = viewModelScope.launch { + fun resendMediaMessage(items: List, messageUuid: String) = viewModelScope.launch { try { - messageListManager.retrySendMediaMessage(inputStream, messageUuid) + messageListManager.retrySendMediaMessage(items, messageUuid) onMessageSent.call() Timber.d("Media re-sent: $messageUuid") } catch (e: TwilioException) { @@ -215,26 +218,28 @@ class MessageListViewModel( fun updateMessageMediaDownloadStatus( messageIndex: Long, + attachmentSid: String, downloadState: DownloadState, downloadedBytes: Long = 0, downloadedLocation: String? = null ) = viewModelScope.launch { messageListManager.updateMessageMediaDownloadState( messageIndex, + attachmentSid, downloadState, downloadedBytes, downloadedLocation ) } - fun startMessageMediaDownload(messageIndex: Long, fileName: String?) = viewModelScope.launch { + fun startMessageMediaDownload(messageIndex: Long, attachmentSid: String, fileName: String?) = viewModelScope.launch { Timber.d("Start file download for message index $messageIndex") - updateMessageMediaDownloadStatus(messageIndex, DownloadState.DOWNLOADING) + updateMessageMediaDownloadStatus(messageIndex, attachmentSid, DownloadState.DOWNLOADING) - val sourceUriResult = runCatching { Uri.parse(messageListManager.getMediaContentTemporaryUrl(messageIndex)) } + val sourceUriResult = runCatching { Uri.parse(messageListManager.getMediaContentTemporaryUrl(messageIndex, attachmentSid)) } val sourceUri = sourceUriResult.getOrElse { e -> Timber.w(e, "Message media download failed: cannot get sourceUri") - updateMessageMediaDownloadStatus(messageIndex, DownloadState.ERROR) + updateMessageMediaDownloadStatus(messageIndex, attachmentSid, DownloadState.ERROR) return@launch } @@ -251,16 +256,16 @@ class MessageListViewModel( val downloadId = downloadManager.enqueue(downloadRequest) Timber.d("Download enqueued with ID: $downloadId") - messageListManager.setMessageMediaDownloadId(messageIndex, downloadId) - observeMessageMediaDownload(messageIndex, downloadId) + messageListManager.setMessageMediaDownloadId(messageIndex, attachmentSid, downloadId) + observeMessageMediaDownload(messageIndex, attachmentSid, downloadId) } - private fun observeMessageMediaDownload(messageIndex: Long, downloadId: Long) { + private fun observeMessageMediaDownload(messageIndex: Long, attachmentSid:String, downloadId: Long) { val downloadManager = appContext.getSystemService(AppCompatActivity.DOWNLOAD_SERVICE) as DownloadManager val downloadCursor = downloadManager.queryById(downloadId) val downloadObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { override fun onChange(selfChange: Boolean) { - if (!updateMessageMediaDownloadState(messageIndex, downloadId)) { + if (!updateMessageMediaDownloadState(messageIndex, attachmentSid, downloadId)) { Timber.d("Download $downloadId completed") downloadCursor.unregisterContentObserver(this) downloadCursor.close() @@ -274,7 +279,7 @@ class MessageListViewModel( * Notifies the view model of the current download state * @return true if the download is still in progress */ - private fun updateMessageMediaDownloadState(messageIndex: Long, downloadId: Long): Boolean { + private fun updateMessageMediaDownloadState(messageIndex: Long, attachmentSid: String, downloadId: Long): Boolean { val downloadManager = appContext.getSystemService(AppCompatActivity.DOWNLOAD_SERVICE) as DownloadManager val cursor = downloadManager.queryById(downloadId) @@ -288,7 +293,7 @@ class MessageListViewModel( val downloadedBytes = cursor.getLong(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) Timber.d("Download status changed. Status: $status, downloaded bytes: $downloadedBytes") - updateMessageMediaDownloadStatus(messageIndex, DownloadState.DOWNLOADING, downloadedBytes) + updateMessageMediaDownloadStatus(messageIndex, attachmentSid, DownloadState.DOWNLOADING, downloadedBytes) when (status) { DownloadManager.STATUS_SUCCESSFUL -> { @@ -298,6 +303,7 @@ class MessageListViewModel( .toString() updateMessageMediaDownloadStatus( messageIndex, + attachmentSid, DownloadState.COMPLETED, downloadedBytes, downloadedLocation @@ -305,7 +311,7 @@ class MessageListViewModel( } DownloadManager.STATUS_FAILED -> { onMessageError.value = ConversationsError.MESSAGE_MEDIA_DOWNLOAD_FAILED - updateMessageMediaDownloadStatus(messageIndex, DownloadState.ERROR, downloadedBytes) + updateMessageMediaDownloadStatus(messageIndex, attachmentSid, DownloadState.ERROR, downloadedBytes) Timber.w( "Message media download failed. Failure reason: %s", cursor.getString(DownloadManager.COLUMN_REASON) diff --git a/app/src/main/res/layout/item_attachment.xml b/app/src/main/res/layout/item_attachment.xml new file mode 100644 index 0000000..0049590 --- /dev/null +++ b/app/src/main/res/layout/item_attachment.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/row_message_item_incoming.xml b/app/src/main/res/layout/row_message_item_incoming.xml index f17c764..e865460 100644 --- a/app/src/main/res/layout/row_message_item_incoming.xml +++ b/app/src/main/res/layout/row_message_item_incoming.xml @@ -84,90 +84,18 @@ tools:text="@tools:sample/lorem/random" tools:visibility="visible" /> - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/row_message_item_outgoing.xml b/app/src/main/res/layout/row_message_item_outgoing.xml index 21c8942..30407d3 100644 --- a/app/src/main/res/layout/row_message_item_outgoing.xml +++ b/app/src/main/res/layout/row_message_item_outgoing.xml @@ -66,7 +66,7 @@ app:constraint_referenced_ids=" message_body, message_date, - attachment_background" /> + attachments_container" /> - - - - - - - - - - - - - - - diff --git a/app/src/sharedTest/com/twilio/conversations/app/Mocks.kt b/app/src/sharedTest/com/twilio/conversations/app/Mocks.kt index 46b3cd6..2162614 100644 --- a/app/src/sharedTest/com/twilio/conversations/app/Mocks.kt +++ b/app/src/sharedTest/com/twilio/conversations/app/Mocks.kt @@ -9,6 +9,7 @@ import com.twilio.conversations.app.common.enums.MessageType import com.twilio.conversations.app.common.enums.SendStatus import com.twilio.conversations.app.data.localCache.entity.ConversationDataItem import com.twilio.conversations.app.data.localCache.entity.MessageDataItem +import com.twilio.conversations.app.data.localCache.entity.MessageAttachmentDataItem import com.twilio.conversations.app.data.localCache.entity.ParticipantDataItem import com.twilio.conversations.app.data.models.ConversationDetailsViewItem import com.twilio.conversations.app.data.models.ParticipantListViewItem @@ -46,21 +47,11 @@ fun createTestMessageDataItem(sid: String = UUID.randomUUID().toString(), direction: Int = 0, sendStatus: Int = 0, uuid: String = UUID.randomUUID().toString(), - mediaSid: String? = null, - mediaFileName: String? = null, - mediaType: String? = null, + attachmentsList: List = emptyList(), mediaSize: Long? = null, - mediaUri: String? = null, - mediaDownloadId: Long? = null, - mediaDownloadedBytes: Long? = null, - mediaDownloadState: Int = NOT_STARTED.value, - mediaUploading: Boolean = false, - mediaUploadedBytes: Long? = null, - mediaUploadUri: String? = null + errorCode: Int = 0 ) = MessageDataItem(sid, conversationSid, participantSid, type, author, dateCreated, body, - index, attributes, direction, sendStatus, uuid, mediaSid, mediaFileName, mediaType, - mediaSize, mediaUri, mediaDownloadId, mediaDownloadedBytes, mediaDownloadState, mediaUploading, - mediaUploadedBytes, mediaUploadUri) + index, attributes, direction, sendStatus, uuid, attachmentsList, mediaSize, errorCode) fun createTestParticipantDataItem( sid: String = "", @@ -115,11 +106,31 @@ fun getMockedMessages(count: Int, body: String, conversationSid: String, directi mediaFileName: String = "", mediaSize: Long = 0, mediaDownloadState: DownloadState = NOT_STARTED, mediaUri: String? = null, mediaDownloadedBytes: Long? = null, sendStatus: SendStatus = SendStatus.UNDEFINED): List { + val attachmentsList = if (mediaFileName.isNotEmpty()) { + listOf( + MessageAttachmentDataItem( + uuid = UUID.randomUUID().toString(), + sid = "", + fileName = mediaFileName, + type = "image/jpeg", + size = mediaSize, + uri = mediaUri, + downloadId = null, + downloadedBytes = mediaDownloadedBytes, + downloadState = mediaDownloadState, + uploading = false, + uploadedBytes = null, + uploadUri = null + ) + ) + } else { + emptyList() + } + val messages = Array(count) { index -> createTestMessageDataItem(conversationSid = conversationSid, index = index.toLong(), body = "${body}_$index", direction = direction, author = author, attributes = attributes, - type = type.value, mediaFileName = mediaFileName, mediaSize = mediaSize, mediaDownloadState = mediaDownloadState.value, - mediaUri = mediaUri, mediaDownloadedBytes = mediaDownloadedBytes, sendStatus = sendStatus.value) + type = type.value, attachmentsList = attachmentsList, sendStatus = sendStatus.value) } return messages.toList() } diff --git a/app/src/test/java/com/twilio/conversations/app/manager/LoginManagerTest.kt b/app/src/test/java/com/twilio/conversations/app/manager/LoginManagerTest.kt index 4a212f7..c81f3d3 100644 --- a/app/src/test/java/com/twilio/conversations/app/manager/LoginManagerTest.kt +++ b/app/src/test/java/com/twilio/conversations/app/manager/LoginManagerTest.kt @@ -23,7 +23,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ObsoleteCoroutinesApi import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before @@ -79,33 +79,33 @@ class LoginManagerTest { } @Test - fun `signIn() should attempt sign in`() = runBlockingTest { + fun `signIn() should attempt sign in`() = runTest { loginManager.signIn(VALID_CREDENTIAL, VALID_CREDENTIAL) verify(conversationsClientWrapper, times(1)).create(VALID_CREDENTIAL, VALID_CREDENTIAL) } @Test - fun `signInUsingStoredCredentials() should attempt sign in`() = runBlockingTest { + fun `signInUsingStoredCredentials() should attempt sign in`() = runTest { credentialStorageNotEmpty(credentialStorage, VALID_CREDENTIAL) loginManager.signInUsingStoredCredentials() verify(conversationsClientWrapper, times(1)).create(VALID_CREDENTIAL, VALID_CREDENTIAL) } @Test - fun `signInUsingStoredCredentials() should not attempt sign in when credential storage is empty`() = runBlockingTest { + fun `signInUsingStoredCredentials() should not attempt sign in when credential storage is empty`() = runTest { credentialStorageEmpty(credentialStorage) runCatching { loginManager.signInUsingStoredCredentials() } verify(conversationsClientWrapper, times(0)).create(INVALID_CREDENTIAL, INVALID_CREDENTIAL) } @Test - fun `signIn() should attempt to store credentials when client is created`() = runBlockingTest { + fun `signIn() should attempt to store credentials when client is created`() = runTest { loginManager.signIn(VALID_CREDENTIAL, VALID_CREDENTIAL) verify(credentialStorage, times(1)).storeCredentials(VALID_CREDENTIAL, VALID_CREDENTIAL) } @Test - fun `signIn() should not attempt to clear credentials when fatal error occurred`() = runBlockingTest { + fun `signIn() should not attempt to clear credentials when fatal error occurred`() = runTest { val error = ConversationsError.TOKEN_ACCESS_DENIED whenCall(conversationsClientWrapper.create(INVALID_CREDENTIAL, INVALID_CREDENTIAL)).then { throw createTwilioException(error) } runCatching { loginManager.signIn(INVALID_CREDENTIAL, INVALID_CREDENTIAL) } @@ -113,7 +113,7 @@ class LoginManagerTest { } @Test - fun `signIn() should not attempt to store credentials when error occurred`() = runBlockingTest { + fun `signIn() should not attempt to store credentials when error occurred`() = runTest { val error = ConversationsError.GENERIC_ERROR whenCall(conversationsClientWrapper.create(INVALID_CREDENTIAL, INVALID_CREDENTIAL)).then { throw createTwilioException(error) } runCatching { loginManager.signIn(INVALID_CREDENTIAL, INVALID_CREDENTIAL) } @@ -121,13 +121,13 @@ class LoginManagerTest { } @Test - fun `signInUsingStoredCredentials() should not attempt to store credentials`() = runBlockingTest { + fun `signInUsingStoredCredentials() should not attempt to store credentials`() = runTest { loginManager.signInUsingStoredCredentials() verify(credentialStorage, times(0)).storeCredentials(VALID_CREDENTIAL, VALID_CREDENTIAL) } @Test - fun `signInUsingStoredCredentials() should attempt to clear credentials when fatal error occurred`() = runBlockingTest { + fun `signInUsingStoredCredentials() should attempt to clear credentials when fatal error occurred`() = runTest { credentialStorageNotEmpty(credentialStorage, OUTDATED_CREDENTIAL) val error = ConversationsError.TOKEN_ACCESS_DENIED whenCall(conversationsClientWrapper.create(OUTDATED_CREDENTIAL, OUTDATED_CREDENTIAL)).then { throw createTwilioException(error) } @@ -136,7 +136,7 @@ class LoginManagerTest { } @Test - fun `signOut should clear credentials`() = runBlockingTest { + fun `signOut should clear credentials`() = runTest { credentialStorageNotEmpty(credentialStorage, OUTDATED_CREDENTIAL) loginManager.signOut() diff --git a/app/src/test/java/com/twilio/conversations/app/manager/MessageListManagerTest.kt b/app/src/test/java/com/twilio/conversations/app/manager/MessageListManagerTest.kt index 961e9ed..1a01d41 100644 --- a/app/src/test/java/com/twilio/conversations/app/manager/MessageListManagerTest.kt +++ b/app/src/test/java/com/twilio/conversations/app/manager/MessageListManagerTest.kt @@ -22,6 +22,7 @@ import com.twilio.conversations.app.common.extensions.firstMedia import com.twilio.conversations.app.common.extensions.toConversationsError import com.twilio.conversations.app.createTestMessageDataItem import com.twilio.conversations.app.data.ConversationsClientWrapper +import com.twilio.conversations.app.manager.MediaInput import com.twilio.conversations.app.repository.ConversationsRepository import com.twilio.conversations.app.testUtil.CoroutineTestRule import com.twilio.conversations.app.testUtil.toMessageMock @@ -39,9 +40,9 @@ import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.verify import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals @@ -52,6 +53,7 @@ import org.junit.runner.RunWith import org.mockito.Mock import org.powermock.core.classloader.annotations.PrepareForTest import org.powermock.modules.junit4.PowerMockRunner +import java.util.UUID @RunWith(PowerMockRunner::class) @PrepareForTest( @@ -62,7 +64,7 @@ import org.powermock.modules.junit4.PowerMockRunner ) class MessageListManagerTest { - private val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher() + private val testDispatcher = StandardTestDispatcher() @Rule var coroutineTestRule = CoroutineTestRule(testDispatcher) @@ -113,7 +115,7 @@ class MessageListManagerTest { } @Test - fun `sendTextMessage() should update local cache with send status SENT on success`() = runBlockingTest { + fun `sendTextMessage() should update local cache with send status SENT on success`() = runTest { val messageUuid = "uuid" val message = createTestMessageDataItem(body = "test message", uuid = messageUuid) coEvery { participant.sid } returns message.participantSid @@ -131,7 +133,7 @@ class MessageListManagerTest { } @Test - fun `sendTextMessage() should update local cache with send status SENDING on failure`() = runBlockingTest { + fun `sendTextMessage() should update local cache with send status SENDING on failure`() = runTest { val message = createTestMessageDataItem(body = "test message", uuid = "uuid") coEvery { participant.sid } returns message.participantSid coEvery { @@ -156,7 +158,7 @@ class MessageListManagerTest { } @Test - fun `sendTextMessage() should NOT update local cache with on participant failure`() = runBlockingTest { + fun `sendTextMessage() should NOT update local cache with on participant failure`() = runTest { val message = createTestMessageDataItem(body = "test message", uuid = "uuid") coEvery { participant.sid } returns message.participantSid coEvery { conversation.getParticipantByIdentity(any()) } throws createTwilioException(ConversationsError.MESSAGE_SEND_FAILED) @@ -176,7 +178,7 @@ class MessageListManagerTest { } @Test - fun `retrySendMessage() should update local cache with send status SENT on success`() = runBlockingTest(testDispatcher) { + fun `retrySendMessage() should update local cache with send status SENT on success`() = runTest { val message = createTestMessageDataItem(body = "test message", uuid = "uuid", author = participantIdentity, sendStatus = SendStatus.ERROR.value) coEvery { participant.sid } returns message.participantSid @@ -198,7 +200,7 @@ class MessageListManagerTest { } @Test - fun `retrySendMessage() should NOT update local cache if already sending`() = runBlockingTest(testDispatcher) { + fun `retrySendMessage() should NOT update local cache if already sending`() = runTest { val message = createTestMessageDataItem(body = "test message", uuid = "uuid", author = participantIdentity, sendStatus = SendStatus.SENDING.value) coEvery { participant.sid } returns message.participantSid @@ -217,7 +219,7 @@ class MessageListManagerTest { } @Test - fun `retrySendMessage() should update local cache with send status SENDING on failure`() = runBlockingTest(testDispatcher) { + fun `retrySendMessage() should update local cache with send status SENDING on failure`() = runTest { val message = createTestMessageDataItem(body = "test message", uuid = "uuid", author = participantIdentity, sendStatus = SendStatus.ERROR.value) coEvery { participant.sid } returns message.participantSid @@ -241,7 +243,7 @@ class MessageListManagerTest { } @Test - fun `sendMediaMessage() should update local cache with send status SENT on success`() = runBlockingTest { + fun `sendMultipleMediaMessage() should update local cache with send status SENT on success`() = runTest { val messageUuid = "uuid" val mediaUri = "uri" val fileName = "fileName" @@ -254,21 +256,23 @@ class MessageListManagerTest { this.addMedia(any(), any(), any(), any()) } } returns message.toMessageMock(participant) - messageListManager.sendMediaMessage(mediaUri, mockk(), fileName, mimeType, messageUuid) + val mediaInput = MediaInput(UUID.randomUUID().toString(), mediaUri, mockk(), fileName, mimeType) + messageListManager.sendMultipleMediaMessage(listOf(mediaInput), messageUuid) verify(conversationsRepository).insertMessage(argThat { type == MessageType.MEDIA.value && body == null && uuid == messageUuid && sendStatus == SendStatus.SENDING.value - && mediaFileName == fileName - && mediaUploadUri == mediaUri - && mediaType == mimeType + && attachmentsList.isNotEmpty() + && attachmentsList.first().fileName == fileName + && attachmentsList.first().uri == mediaUri + && attachmentsList.first().type == mimeType }) } @Test - fun `sendMediaMessage() should update local cache with send status SENDING on failure`() = runBlockingTest { + fun `sendMultipleMediaMessage() should update local cache with send status SENDING on failure`() = runTest { val messageUuid = "uuid" val mediaUri = "uri" val fileName = "fileName" @@ -282,7 +286,8 @@ class MessageListManagerTest { } } throws createTwilioException(ConversationsError.MESSAGE_SEND_FAILED) try { - messageListManager.sendMediaMessage(mediaUri, mockk(), fileName, mimeType, messageUuid) + val mediaInput = MediaInput(UUID.randomUUID().toString(), mediaUri, mockk(), fileName, mimeType) + messageListManager.sendMultipleMediaMessage(listOf(mediaInput), messageUuid) } catch (e: TwilioException) { assert(ConversationsError.MESSAGE_SEND_FAILED == e.toConversationsError()) } @@ -292,9 +297,10 @@ class MessageListManagerTest { && body == null && uuid == messageUuid && sendStatus == SendStatus.SENDING.value - && mediaFileName == fileName - && mediaUploadUri == mediaUri - && mediaType == mimeType + && attachmentsList.isNotEmpty() + && attachmentsList.first().fileName == fileName + && attachmentsList.first().uri == mediaUri + && attachmentsList.first().type == mimeType }) verify(conversationsRepository, never()).updateMessageByUuid(argThat { @@ -302,14 +308,15 @@ class MessageListManagerTest { && body == null && uuid == messageUuid && sendStatus == SendStatus.SENT.value - && mediaFileName == fileName - && mediaUploadUri == mediaUri - && mediaType == mimeType + && attachmentsList.isNotEmpty() + && attachmentsList.first().fileName == fileName + && attachmentsList.first().uri == mediaUri + && attachmentsList.first().type == mimeType }) } @Test - fun `sendMediaMessage() should NOT update local cache with on participant failure`() = runBlockingTest { + fun `sendMultipleMediaMessage() should NOT update local cache with on participant failure`() = runTest { val messageUuid = "uuid" val mediaUri = "uri" val fileName = "fileName" @@ -318,7 +325,8 @@ class MessageListManagerTest { every { participant.sid } returns message.participantSid coEvery { conversation.getParticipantByIdentity(any()) } throws createTwilioException(ConversationsError.MESSAGE_SEND_FAILED) try { - messageListManager.sendMediaMessage(mediaUri, mockk(), fileName, mimeType, messageUuid) + val mediaInput = MediaInput(UUID.randomUUID().toString(), mediaUri, mockk(), fileName, mimeType) + messageListManager.sendMultipleMediaMessage(listOf(mediaInput), messageUuid) } catch (e: TwilioException) { assert(ConversationsError.MESSAGE_SEND_FAILED == e.toConversationsError()) } @@ -328,9 +336,10 @@ class MessageListManagerTest { && body == null && uuid == messageUuid && sendStatus == SendStatus.SENDING.value - && mediaFileName == fileName - && mediaUploadUri == mediaUri - && mediaType == mimeType + && attachmentsList.isNotEmpty() + && attachmentsList.first().fileName == fileName + && attachmentsList.first().uri == mediaUri + && attachmentsList.first().type == mimeType }) verify(conversationsRepository, never()).updateMessageByUuid(argThat { @@ -338,21 +347,21 @@ class MessageListManagerTest { && body == null && uuid == messageUuid && sendStatus == SendStatus.SENT.value - && mediaFileName == fileName - && mediaUploadUri == mediaUri - && mediaType == mimeType + && attachmentsList.isNotEmpty() + && attachmentsList.first().fileName == fileName + && attachmentsList.first().uri == mediaUri + && attachmentsList.first().type == mimeType }) } @Test - fun `retrySendMediaMessage() should update local cache with send status SENT on success`() = runBlockingTest(testDispatcher) { + fun `retrySendMediaMessage() should update local cache with send status SENT on success`() = runTest { val messageUuid = "uuid" val mediaUri = "uri" val fileName = "fileName" val mimeType = "mimeType" val message = createTestMessageDataItem(uuid = messageUuid, author = participantIdentity, - sendStatus = SendStatus.ERROR.value, mediaUploadUri = mediaUri, mediaFileName = fileName, - mediaType = mimeType, type = MessageType.MEDIA.value, mediaSid = "sid") + sendStatus = SendStatus.ERROR.value, type = MessageType.MEDIA.value) every { participant.sid } returns message.participantSid coEvery { conversation.sendMessage { @@ -361,35 +370,37 @@ class MessageListManagerTest { } } returns message.toMessageMock(participant) whenCall(conversationsRepository.getMessageByUuid(message.uuid)).thenReturn(message) - messageListManager.retrySendMediaMessage(mockk(), message.uuid) + val mediaInput = MediaInput(UUID.randomUUID().toString(), mediaUri, mockk(), fileName, mimeType) + messageListManager.retrySendMediaMessage(listOf(mediaInput), message.uuid) inOrder(conversationsRepository).verify(conversationsRepository).updateMessageByUuid(argThat { type == MessageType.MEDIA.value && body == "" && uuid == messageUuid && sendStatus == SendStatus.SENDING.value - && mediaFileName == fileName - && mediaType == mimeType + && attachmentsList.isNotEmpty() + && attachmentsList.first().fileName == fileName + && attachmentsList.first().type == mimeType }) inOrder(conversationsRepository).verify(conversationsRepository).updateMessageByUuid(argThat { type == MessageType.MEDIA.value && body == "" && uuid == messageUuid && sendStatus == SendStatus.SENT.value - && mediaFileName == fileName - && mediaType == mimeType + && attachmentsList.isNotEmpty() + && attachmentsList.first().fileName == fileName + && attachmentsList.first().type == mimeType }) } @Test - fun `retrySendMediaMessage() should NOT update local cache if already sending`() = runBlockingTest(testDispatcher) { + fun `retrySendMediaMessage() should NOT update local cache if already sending`() = runTest { val messageUuid = "uuid" val mediaUri = "uri" val fileName = "fileName" val mimeType = "mimeType" val message = createTestMessageDataItem(uuid = messageUuid, author = participantIdentity, - sendStatus = SendStatus.SENDING.value, mediaUploadUri = mediaUri, mediaFileName = fileName, - mediaType = mimeType, type = MessageType.MEDIA.value, mediaSid = "sid") + sendStatus = SendStatus.SENDING.value, type = MessageType.MEDIA.value) coEvery { participant.sid } returns message.participantSid coEvery { conversation.sendMessage { @@ -398,27 +409,28 @@ class MessageListManagerTest { } } returns message.toMessageMock(participant) whenCall(conversationsRepository.getMessageByUuid(message.uuid)).thenReturn(message) - messageListManager.retrySendMediaMessage(mockk(), message.uuid) + val mediaInput = MediaInput(UUID.randomUUID().toString(), mediaUri, mockk(), fileName, mimeType) + messageListManager.retrySendMediaMessage(listOf(mediaInput), message.uuid) verify(conversationsRepository, times(0)).updateMessageByUuid(argThat { type == MessageType.MEDIA.value && body == "" && uuid == messageUuid && sendStatus == SendStatus.SENT.value - && mediaFileName == fileName - && mediaType == mimeType + && attachmentsList.isNotEmpty() + && attachmentsList.first().fileName == fileName + && attachmentsList.first().type == mimeType }) } @Test - fun `retrySendMediaMessage() should update local cache with send status SENDING on failure`() = runBlockingTest(testDispatcher) { + fun `retrySendMediaMessage() should update local cache with send status SENDING on failure`() = runTest { val messageUuid = "uuid" val mediaUri = "uri" val fileName = "fileName" val mimeType = "mimeType" val message = createTestMessageDataItem(uuid = messageUuid, author = participantIdentity, - sendStatus = SendStatus.ERROR.value, mediaUploadUri = mediaUri, mediaFileName = fileName, - mediaType = mimeType, type = MessageType.MEDIA.value, mediaSid = "sid") + sendStatus = SendStatus.ERROR.value, type = MessageType.MEDIA.value) coEvery { participant.sid } returns message.participantSid coEvery { conversation.sendMessage { @@ -429,7 +441,8 @@ class MessageListManagerTest { coEvery { conversationsClient.getConversation(any(), any()) } throws createTwilioException(ConversationsError.MESSAGE_SEND_FAILED) whenCall(conversationsRepository.getMessageByUuid(message.uuid)).thenReturn(message) try { - messageListManager.retrySendMediaMessage(mockk(), message.uuid) + val mediaInput = MediaInput(UUID.randomUUID().toString(), mediaUri, mockk(), fileName, mimeType) + messageListManager.retrySendMediaMessage(listOf(mediaInput), message.uuid) } catch (e: TwilioException) { assert(ConversationsError.MESSAGE_SEND_FAILED == e.toConversationsError()) } @@ -439,28 +452,31 @@ class MessageListManagerTest { && body == "" && uuid == messageUuid && sendStatus == SendStatus.SENDING.value - && mediaFileName == fileName - && mediaType == mimeType + && attachmentsList.isNotEmpty() + && attachmentsList.first().fileName == fileName + && attachmentsList.first().type == mimeType }) } @Test - fun `setMessageMediaDownloadId should update repository`() = runBlockingTest { + fun `setMessageMediaDownloadId should update repository`() = runTest { val messageIndex = 1L val downloadId = 2L + val attachmentSid = "attachment_sid" val messageSid = "sid" val message = mockk() every { message.sid } returns messageSid coEvery { conversation.getMessageByIndex(messageIndex) } returns message - messageListManager.setMessageMediaDownloadId(messageIndex, downloadId) + messageListManager.setMessageMediaDownloadId(messageIndex, attachmentSid, downloadId) - verify { conversationsRepository.updateMessageMediaDownloadStatus(messageSid = message.sid, downloadId = downloadId)} + verify { conversationsRepository.updateMessageMediaDownloadStatus(messageSid = message.sid, attachmentSid = attachmentSid, downloadId = downloadId)} } @Test - fun `getMediaContentTemporaryUrl returns Media getTemporaryContentUrl`() = runBlockingTest { + fun `getMediaContentTemporaryUrl returns Media getTemporaryContentUrl`() = runTest { val messageIndex = 1L + val attachmentSid = "attachment_sid" val mediaTempUrl = "url" val message = mockk() val media = mockk() @@ -468,6 +484,6 @@ class MessageListManagerTest { every { message.firstMedia } returns media coEvery { media.getTemporaryContentUrl() } returns mediaTempUrl - assertEquals(mediaTempUrl, messageListManager.getMediaContentTemporaryUrl(messageIndex)) + assertEquals(mediaTempUrl, messageListManager.getMediaContentTemporaryUrl(messageIndex, attachmentSid)) } } diff --git a/app/src/test/java/com/twilio/conversations/app/repository/ConversationsRepositoryTest.kt b/app/src/test/java/com/twilio/conversations/app/repository/ConversationsRepositoryTest.kt index ab75525..71c7f34 100644 --- a/app/src/test/java/com/twilio/conversations/app/repository/ConversationsRepositoryTest.kt +++ b/app/src/test/java/com/twilio/conversations/app/repository/ConversationsRepositoryTest.kt @@ -55,9 +55,8 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule @@ -77,7 +76,7 @@ import org.powermock.modules.junit4.PowerMockRunner ) class ConversationsRepositoryTest { - private val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher() + private val testDispatcher = StandardTestDispatcher() @Rule var coroutineTestRule = CoroutineTestRule(testDispatcher) @@ -124,7 +123,7 @@ class ConversationsRepositoryTest { } @Test - fun `getUserConversations() should return statuses in correct order`() = runBlocking { + fun `getUserConversations() should return statuses in correct order`() = runTest { every { localCacheProvider.conversationsDao().getUserConversations() } returns flowOf(emptyList()) val actual = conversationsRepository.getUserConversations().toList().map { it.requestStatus } @@ -134,7 +133,7 @@ class ConversationsRepositoryTest { } @Test - fun `getUserConversations() first should return user conversations stored in local cache`() = runBlocking { + fun `getUserConversations() first should return user conversations stored in local cache`() = runTest { val expectedConversations = getMockedConversations(USER_CONVERSATION_COUNT, "User Conversations").toList() every { localCacheProvider.conversationsDao().getUserConversations() } returns flowOf(expectedConversations) @@ -145,7 +144,7 @@ class ConversationsRepositoryTest { } @Test - fun `getUserConversations() should fetch conversations and store them in local cache`() = runBlocking { + fun `getUserConversations() should fetch conversations and store them in local cache`() = runTest { val expectedConversation = createTestConversationDataItem() every { localCacheProvider.conversationsDao().getUserConversations() } returns flowOf(emptyList()) @@ -160,7 +159,7 @@ class ConversationsRepositoryTest { } @Test - fun `getUserConversations() should delete outdated conversations from local cache`() = runBlocking { + fun `getUserConversations() should delete outdated conversations from local cache`() = runTest { val expectedConversations = getMockedConversations(USER_CONVERSATION_COUNT, "User Conversations").toList() every { localCacheProvider.conversationsDao().getUserConversations() } returns flowOf(expectedConversations) @@ -173,7 +172,7 @@ class ConversationsRepositoryTest { } @Test - fun `getUserConversations() should return error if cannot fetch conversation`() = runBlocking { + fun `getUserConversations() should return error if cannot fetch conversation`() = runTest { val expectedConversation = createTestConversationDataItem() every { localCacheProvider.conversationsDao().getUserConversations() } returns flowOf(emptyList()) @@ -185,7 +184,7 @@ class ConversationsRepositoryTest { } @Test - fun `onConversationDeleted should remove received Conversation from local cache when called`() = runBlocking { + fun `onConversationDeleted should remove received Conversation from local cache when called`() = runTest { val conversation = createTestConversationDataItem().toConversationMock() clientListener.onConversationDeleted(conversation) @@ -195,7 +194,7 @@ class ConversationsRepositoryTest { } @Test - fun `onConversationAdded should add received Conversation to local cache when called`() = runBlocking { + fun `onConversationAdded should add received Conversation to local cache when called`() = runTest { val conversation = createTestConversationDataItem() coEvery { conversationsClient.getConversation(any()) } returns conversation.toConversationMock() @@ -216,7 +215,7 @@ class ConversationsRepositoryTest { } @Test - fun `onConversationUpdated should update received Conversation in local cache when called`() = runBlocking { + fun `onConversationUpdated should update received Conversation in local cache when called`() = runTest { val conversation = createTestConversationDataItem() coEvery { conversationsClient.getConversation(any()) } returns conversation.toConversationMock() @@ -237,7 +236,7 @@ class ConversationsRepositoryTest { } @Test - fun `getMessages() should return statuses in correct order`() = runBlocking { + fun `getMessages() should return statuses in correct order`() = runTest { every { localCacheProvider.messagesDao().getMessagesSorted(any()) } returns ItemDataSource.factory(emptyList()) coEvery { conversation.getLastMessages(any()).asMessageDataItems(any()) } returns emptyList() @@ -245,7 +244,7 @@ class ConversationsRepositoryTest { } @Test - fun `getMessages() first should return messages stored in local cache`() = runBlocking { + fun `getMessages() first should return messages stored in local cache`() = runTest { val conversationSid = "conversation_1" val expectedMessages = getMockedMessages(MESSAGE_COUNT, "Message body", conversationSid) @@ -257,7 +256,7 @@ class ConversationsRepositoryTest { } @Test - fun `getMessages() should fetch messages and store them in local cache`() = runBlocking { + fun `getMessages() should fetch messages and store them in local cache`() = runTest { val conversationSid = "conversation_1" val expectedMessage = createTestMessageDataItem(conversationSid = conversationSid) @@ -270,7 +269,7 @@ class ConversationsRepositoryTest { } @Test - fun `getMessages() should return error if cannot fetch conversation descriptors`() = runBlocking { + fun `getMessages() should return error if cannot fetch conversation descriptors`() = runTest { every { localCacheProvider.messagesDao().getMessagesSorted(any()) } returns ItemDataSource.factory(emptyList()) coEvery { conversation.getLastMessages(any()).asMessageDataItems(any()) } throws createTwilioException(UNKNOWN) @@ -281,7 +280,7 @@ class ConversationsRepositoryTest { } @Test - fun `getMessages() should return error if cannot fetch conversation`() = runBlocking { + fun `getMessages() should return error if cannot fetch conversation`() = runTest { val conversationSid = "conversation_1" val expectedMessage = createTestMessageDataItem(conversationSid = conversationSid) @@ -295,7 +294,7 @@ class ConversationsRepositoryTest { } @Test - fun `getTypingMemebers should return data from LocalCache`() = runBlocking { + fun `getTypingMemebers should return data from LocalCache`() = runTest { val conversationSid = "123" val typingParticipants = listOf(ParticipantDataItem(conversationSid = conversationSid, identity = "asd", sid = "321", lastReadMessageIndex = null, lastReadTimestamp = null, friendlyName = "user", isOnline = true)) @@ -305,7 +304,7 @@ class ConversationsRepositoryTest { } @Test - fun `participant typing status updated via messageListManagerListener`() = runBlocking { + fun `participant typing status updated via messageListManagerListener`() = runTest { // Set up a ConversationsRepository and capture the messageListManagerListener that's added to joined conversations val conversationsClient = mockk() val listenerSlot = slot() @@ -348,7 +347,7 @@ class ConversationsRepositoryTest { } @Test - fun `message deleted via ConversationListener`() = testDispatcher.runBlockingTest { + fun `message deleted via ConversationListener`() = runTest { val conversationListenerCaptor = ArgumentCaptor.forClass(ConversationListener::class.java) val conversation = createTestConversationDataItem().toConversationMock(conversationListenerCaptor = conversationListenerCaptor) val participant = createTestParticipantDataItem().toParticipantMock(conversation) @@ -362,7 +361,7 @@ class ConversationsRepositoryTest { } @Test - fun `message updated via ConversationListener`() = testDispatcher.runBlockingTest { + fun `message updated via ConversationListener`() = runTest { val conversationListenerCaptor = ArgumentCaptor.forClass(ConversationListener::class.java) val conversation = createTestConversationDataItem().toConversationMock(conversationListenerCaptor = conversationListenerCaptor) val participant = createTestParticipantDataItem().toParticipantMock(conversation) @@ -376,7 +375,7 @@ class ConversationsRepositoryTest { } @Test - fun `message added via ConversationListener`() = testDispatcher.runBlockingTest { + fun `message added via ConversationListener`() = runTest { val conversationListenerCaptor = ArgumentCaptor.forClass(ConversationListener::class.java) val conversation = createTestConversationDataItem().toConversationMock(conversationListenerCaptor = conversationListenerCaptor) val participant = createTestParticipantDataItem().toParticipantMock(conversation) diff --git a/app/src/test/java/com/twilio/conversations/app/testUtil/CoroutineTestRule.kt b/app/src/test/java/com/twilio/conversations/app/testUtil/CoroutineTestRule.kt index 07cf96f..2da4ae5 100644 --- a/app/src/test/java/com/twilio/conversations/app/testUtil/CoroutineTestRule.kt +++ b/app/src/test/java/com/twilio/conversations/app/testUtil/CoroutineTestRule.kt @@ -3,13 +3,14 @@ package com.twilio.conversations.app.testUtil import com.twilio.conversations.app.common.DispatcherProvider import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.junit.rules.TestWatcher import org.junit.runner.Description -class CoroutineTestRule(private val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()) : TestWatcher() { +class CoroutineTestRule(private val testDispatcher: TestDispatcher = StandardTestDispatcher()) : TestWatcher() { val testDispatcherProvider = object : DispatcherProvider { override fun default(): CoroutineDispatcher = testDispatcher @@ -26,6 +27,5 @@ class CoroutineTestRule(private val testDispatcher: TestCoroutineDispatcher = Te override fun finished(description: Description?) { super.finished(description) Dispatchers.resetMain() - testDispatcher.cleanupTestCoroutines() } } diff --git a/app/src/test/java/com/twilio/conversations/app/testUtil/MockMessage.kt b/app/src/test/java/com/twilio/conversations/app/testUtil/MockMessage.kt index 41bbb81..01cefd8 100644 --- a/app/src/test/java/com/twilio/conversations/app/testUtil/MockMessage.kt +++ b/app/src/test/java/com/twilio/conversations/app/testUtil/MockMessage.kt @@ -10,21 +10,24 @@ import com.twilio.conversations.app.common.enums.MessageType import com.twilio.conversations.app.common.extensions.asDateString import com.twilio.conversations.app.common.extensions.firstMedia import com.twilio.conversations.app.data.localCache.entity.MessageDataItem -import io.mockk.every -import io.mockk.mockk import org.powermock.api.mockito.PowerMockito import java.util.* fun MessageDataItem.toMessageMock(participant: Participant): Message { val message = PowerMockito.mock(Message::class.java) - every { message.firstMedia } returns if (type == MessageType.TEXT.value) null else mockk { - every { sid } returns (mediaSid ?: "") - every { contentType } returns (mediaType ?: "") - every { category } returns MediaCategory.MEDIA - every { filename } returns mediaFileName - every { size } returns (mediaSize ?: 0) - } + whenCall(message.firstMedia).thenReturn( + if (type == MessageType.TEXT.value || attachmentsList.isEmpty()) null else { + val firstAttachment = attachmentsList.first() + PowerMockito.mock(com.twilio.conversations.Media::class.java).apply { + whenCall(sid).thenReturn(firstAttachment.sid) + whenCall(contentType).thenReturn(firstAttachment.type ?: "") + whenCall(category).thenReturn(MediaCategory.MEDIA) + whenCall(filename).thenReturn(firstAttachment.fileName) + whenCall(size).thenReturn(firstAttachment.size ?: 0) + } + } + ) whenCall(message.sid).thenReturn(sid) whenCall(message.author).thenReturn(author) whenCall(message.conversationSid).thenReturn(conversationSid) diff --git a/app/src/test/java/com/twilio/conversations/app/viewModel/LoginViewModelTest.kt b/app/src/test/java/com/twilio/conversations/app/viewModel/LoginViewModelTest.kt index 27f87f1..625f7bb 100644 --- a/app/src/test/java/com/twilio/conversations/app/viewModel/LoginViewModelTest.kt +++ b/app/src/test/java/com/twilio/conversations/app/viewModel/LoginViewModelTest.kt @@ -20,9 +20,8 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.ObsoleteCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before @@ -68,28 +67,28 @@ class LoginViewModelTest { } @Test - fun `Should attempt sign in when not loading`() = runBlockingTest { + fun `Should attempt sign in when not loading`() = runTest { loginViewModel.isLoading.value = false loginViewModel.signIn(VALID_CREDENTIAL, VALID_CREDENTIAL) verify(loginManager, times(1)).signIn(VALID_CREDENTIAL, VALID_CREDENTIAL) } @Test - fun `Should not attempt sign in when loading`() = runBlockingTest { + fun `Should not attempt sign in when loading`() = runTest { loginViewModel.isLoading.value = true loginViewModel.signIn(VALID_CREDENTIAL, VALID_CREDENTIAL) verify(loginManager, times(0)).signIn(VALID_CREDENTIAL, VALID_CREDENTIAL) } @Test - fun `Should not attempt sign in with invalid credentials`() = runBlockingTest { + fun `Should not attempt sign in with invalid credentials`() = runTest { loginViewModel.signIn(INVALID_CREDENTIAL, INVALID_CREDENTIAL) verify(loginManager, times(0)).signIn(VALID_CREDENTIAL, VALID_CREDENTIAL) } @DelicateCoroutinesApi @Test - fun `Should set isLoading to true while attempting sign in and unchanged when done`() = runBlocking { + fun `Should set isLoading to true while attempting sign in and unchanged when done`() = runTest { assertEquals(false, loginViewModel.isLoading.waitValue()) GlobalScope.launch { @@ -102,19 +101,19 @@ class LoginViewModelTest { } @Test - fun `Should call onSignInSuccess when sign in successful`() = runBlockingTest { + fun `Should call onSignInSuccess when sign in successful`() = runTest { loginViewModel.signIn(VALID_CREDENTIAL, VALID_CREDENTIAL) assertTrue(loginViewModel.onSignInSuccess.waitCalled()) } @Test - fun `Should not call onSignInError when sign in successful`() = runBlockingTest { + fun `Should not call onSignInError when sign in successful`() = runTest { loginViewModel.signIn(VALID_CREDENTIAL, VALID_CREDENTIAL) assertFalse(loginViewModel.onSignInError.waitCalled()) } @Test - fun `Should call onSignInError when sign in fails`() = runBlockingTest { + fun `Should call onSignInError when sign in fails`() = runTest { val error = ConversationsError.TOKEN_ACCESS_DENIED whenCall(loginManager.signIn(INVALID_CREDENTIAL, INVALID_CREDENTIAL)).then { throw createTwilioException(error) } loginViewModel.signIn(INVALID_CREDENTIAL, INVALID_CREDENTIAL) @@ -122,7 +121,7 @@ class LoginViewModelTest { } @Test - fun `Should not call onSignInSuccess when sign in fails`() = runBlockingTest { + fun `Should not call onSignInSuccess when sign in fails`() = runTest { val error = ConversationsError.TOKEN_ERROR whenCall(loginManager.signIn(VALID_CREDENTIAL, VALID_CREDENTIAL)).then { throw createTwilioException(error) } loginViewModel.signIn(VALID_CREDENTIAL, VALID_CREDENTIAL) diff --git a/app/src/test/java/com/twilio/conversations/app/viewModel/MessageListViewModelTest.kt b/app/src/test/java/com/twilio/conversations/app/viewModel/MessageListViewModelTest.kt index 475b8dc..0a1ed24 100644 --- a/app/src/test/java/com/twilio/conversations/app/viewModel/MessageListViewModelTest.kt +++ b/app/src/test/java/com/twilio/conversations/app/viewModel/MessageListViewModelTest.kt @@ -15,6 +15,7 @@ import com.twilio.conversations.app.data.models.MessageListViewItem import com.twilio.conversations.app.data.models.RepositoryRequestStatus import com.twilio.conversations.app.data.models.RepositoryResult import com.twilio.conversations.app.getMockedMessages +import com.twilio.conversations.app.manager.MediaInput import com.twilio.conversations.app.manager.MessageListManager import com.twilio.conversations.app.repository.ConversationsRepository import com.twilio.conversations.app.testUtil.CoroutineTestRule @@ -173,9 +174,10 @@ class MessageListViewModelTest { @Test fun `sendMediaMessage should call onMessageSent on success`() = runBlocking { - coEvery { messageListManager.sendMediaMessage(any(), any(), any(), any(), any()) } returns Unit + coEvery { messageListManager.sendMultipleMediaMessage(any(), any()) } returns Unit messageListViewModel = MessageListViewModel(context, conversationSid, conversationsRepository, messageListManager) - messageListViewModel.sendMediaMessage("", mock(InputStream::class.java), null, null) + val mediaInput = MediaInput(UUID.randomUUID().toString(), "", mock(InputStream::class.java), null, null) + messageListViewModel.sendMultipleMediaMessage(listOf(mediaInput)) assertTrue(messageListViewModel.onMessageSent.waitCalled()) assertTrue(messageListViewModel.onMessageError.waitNotCalled()) @@ -183,9 +185,10 @@ class MessageListViewModelTest { @Test fun `sendMediaMessage should call onMessageError on failure`() = runBlocking { - coEvery { messageListManager.sendMediaMessage(any(), any(), any(), any(), any()) } throws createTwilioException(ConversationsError.MESSAGE_SEND_FAILED) + coEvery { messageListManager.sendMultipleMediaMessage(any(), any()) } throws createTwilioException(ConversationsError.MESSAGE_SEND_FAILED) messageListViewModel = MessageListViewModel(context, conversationSid, conversationsRepository, messageListManager) - messageListViewModel.sendMediaMessage("", mock(InputStream::class.java), null, null) + val mediaInput = MediaInput(UUID.randomUUID().toString(), "", mock(InputStream::class.java), null, null) + messageListViewModel.sendMultipleMediaMessage(listOf(mediaInput)) assertTrue(messageListViewModel.onMessageSent.waitNotCalled()) assertTrue(messageListViewModel.onMessageError.waitValue(ConversationsError.MESSAGE_SEND_FAILED)) @@ -195,7 +198,8 @@ class MessageListViewModelTest { fun `resendMediaMessage should call onMessageSent on success`() = runBlocking { coEvery { messageListManager.retrySendMediaMessage(any(), any()) } returns Unit messageListViewModel = MessageListViewModel(context, conversationSid, conversationsRepository, messageListManager) - messageListViewModel.resendMediaMessage( mock(InputStream::class.java), "") + val mediaInput = MediaInput(UUID.randomUUID().toString(), "", mock(InputStream::class.java), null, null) + messageListViewModel.resendMediaMessage(listOf(mediaInput), "") assertTrue(messageListViewModel.onMessageSent.waitCalled()) assertTrue(messageListViewModel.onMessageError.waitNotCalled()) @@ -205,7 +209,8 @@ class MessageListViewModelTest { fun `resendMediaMessage should call onMessageError on failure`() = runBlocking { coEvery { messageListManager.retrySendMediaMessage(any(), any()) } throws createTwilioException(ConversationsError.MESSAGE_SEND_FAILED) messageListViewModel = MessageListViewModel(context, conversationSid, conversationsRepository, messageListManager) - messageListViewModel.resendMediaMessage( mock(InputStream::class.java), "") + val mediaInput = MediaInput(UUID.randomUUID().toString(), "", mock(InputStream::class.java), null, null) + messageListViewModel.resendMediaMessage(listOf(mediaInput), "") assertTrue(messageListViewModel.onMessageSent.waitNotCalled()) assertTrue(messageListViewModel.onMessageError.waitValue(ConversationsError.MESSAGE_SEND_FAILED)) @@ -217,10 +222,10 @@ class MessageListViewModelTest { val downloadState = DownloadState.NOT_STARTED val downloadedBytes = 2L val downloadLocation = "asd" - coEvery { messageListManager.updateMessageMediaDownloadState(any(), any(), any(), any()) } returns Unit + coEvery { messageListManager.updateMessageMediaDownloadState(any(), any(), any(), any(), any()) } returns Unit messageListViewModel = MessageListViewModel(context, conversationSid, conversationsRepository, messageListManager) - messageListViewModel.updateMessageMediaDownloadStatus(messageIndex, downloadState, downloadedBytes, downloadLocation) - coVerify { messageListManager.updateMessageMediaDownloadState(messageIndex, downloadState, downloadedBytes, downloadLocation) } + messageListViewModel.updateMessageMediaDownloadStatus(messageIndex, "attachmentSid", downloadState, downloadedBytes, downloadLocation) + coVerify { messageListManager.updateMessageMediaDownloadState(messageIndex, "attachmentSid", downloadState, downloadedBytes, downloadLocation) } } } diff --git a/app/src/test/java/com/twilio/conversations/app/viewModel/SplashViewModelTest.kt b/app/src/test/java/com/twilio/conversations/app/viewModel/SplashViewModelTest.kt index 6f2252c..24f7f5a 100644 --- a/app/src/test/java/com/twilio/conversations/app/viewModel/SplashViewModelTest.kt +++ b/app/src/test/java/com/twilio/conversations/app/viewModel/SplashViewModelTest.kt @@ -12,9 +12,8 @@ import junit.framework.TestCase.assertTrue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ObsoleteCoroutinesApi -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before @@ -62,7 +61,7 @@ class SplashViewModelTest { } @Test - fun `Should attempt sign in when client is not already created`() = runBlockingTest { + fun `Should attempt sign in when client is not already created`() = runTest { whenCall(loginManager.isLoggedIn()).thenReturn(false) splashViewModel.signInOrLaunchSignInActivity() @@ -75,7 +74,7 @@ class SplashViewModelTest { } @Test - fun `Should attempt sign in by calling initialize() when client is not already created`() = runBlockingTest { + fun `Should attempt sign in by calling initialize() when client is not already created`() = runTest { whenCall(loginManager.isLoggedIn()).thenReturn(false) splashViewModel.initialize() @@ -87,7 +86,7 @@ class SplashViewModelTest { } @Test - fun `Should not attempt sign in when client is already created`() = runBlockingTest { + fun `Should not attempt sign in when client is already created`() = runTest { whenCall(loginManager.isLoggedIn()).thenReturn(true) splashViewModel.signInOrLaunchSignInActivity() verify(loginManager).isLoggedIn() @@ -97,7 +96,7 @@ class SplashViewModelTest { } @Test - fun `Should attempt sign in when client creation not in progress`() = runBlockingTest { + fun `Should attempt sign in when client creation not in progress`() = runTest { whenCall(loginManager.isLoggedIn()).thenReturn(false) splashViewModel.signInOrLaunchSignInActivity() @@ -106,7 +105,7 @@ class SplashViewModelTest { } @Test - fun `Should not attempt sign in when client creation in progress`() = runBlockingTest { + fun `Should not attempt sign in when client creation in progress`() = runTest { whenCall(loginManager.isLoggedIn()).thenReturn(true) splashViewModel.signInOrLaunchSignInActivity() @@ -115,7 +114,7 @@ class SplashViewModelTest { } @Test - fun `Should call onShowLoginScreen when error occurred`() = runBlocking { + fun `Should call onShowLoginScreen when error occurred`() = runTest { val error = ConversationsError.TOKEN_ACCESS_DENIED whenCall(loginManager.isLoggedIn()).thenReturn(false) whenCall(loginManager.signInUsingStoredCredentials()).then { throw createTwilioException(error) } @@ -126,7 +125,7 @@ class SplashViewModelTest { } @Test - fun `Should call onShowLoginScreen when response is empty credentials error`() = runBlocking { + fun `Should call onShowLoginScreen when response is empty credentials error`() = runTest { val error = ConversationsError.NO_STORED_CREDENTIALS whenCall(loginManager.isLoggedIn()).thenReturn(false) whenCall(loginManager.signInUsingStoredCredentials()).then { throw createTwilioException(error) } @@ -137,7 +136,7 @@ class SplashViewModelTest { } @Test - fun `Should call onCloseSplashScreen when sign in successful`() = runBlockingTest { + fun `Should call onCloseSplashScreen when sign in successful`() = runTest { whenCall(loginManager.isLoggedIn()).thenReturn(false) splashViewModel.signInOrLaunchSignInActivity() diff --git a/build.gradle b/build.gradle index 028849c..71dec7c 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ wrapper { } buildscript { - ext.kotlin_version = '1.9.20' + ext.kotlin_version = '2.1.0' ext.kotlinx_coroutines_version = '1.7.3' ext.mockk_version = '1.12.2' ext.paging_version = '2.1.2'