diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index f9f47ee34b2..2f9ff680ed1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -116,6 +116,7 @@ import com.wire.android.ui.home.conversations.banner.ConversationBanner import com.wire.android.ui.home.conversations.banner.ConversationBannerViewModel import com.wire.android.ui.home.conversations.call.ConversationCallViewModel import com.wire.android.ui.home.conversations.call.ConversationCallViewState +import com.wire.android.ui.home.conversations.composer.MessageComposerViewModel import com.wire.android.ui.home.conversations.delete.DeleteMessageDialog import com.wire.android.ui.home.conversations.details.GroupConversationDetailsNavBackArgs import com.wire.android.ui.home.conversations.edit.EditMessageMenuItems @@ -130,6 +131,7 @@ import com.wire.android.ui.home.conversations.model.ExpirationStatus import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.ui.home.conversations.selfdeletion.SelfDeletionMapper.toSelfDeletionDuration import com.wire.android.ui.home.conversations.selfdeletion.SelfDeletionMenuItems +import com.wire.android.ui.home.conversations.sendmessage.SendMessageViewModel import com.wire.android.ui.home.gallery.MediaGalleryActionType import com.wire.android.ui.home.gallery.MediaGalleryNavBackArgs import com.wire.android.ui.home.messagecomposer.MessageComposer @@ -188,6 +190,7 @@ fun ConversationScreen( conversationCallViewModel: ConversationCallViewModel = hiltViewModel(), conversationMessagesViewModel: ConversationMessagesViewModel = hiltViewModel(), messageComposerViewModel: MessageComposerViewModel = hiltViewModel(), + sendMessageViewModel: SendMessageViewModel = hiltViewModel(), conversationMigrationViewModel: ConversationMigrationViewModel = hiltViewModel(), messageDraftViewModel: MessageDraftViewModel = hiltViewModel(), groupDetailsScreenResultRecipient: ResultRecipient, @@ -335,7 +338,7 @@ fun ConversationScreen( NavigationCommand(MessageDetailsScreenDestination(conversationInfoViewModel.conversationId, messageId, isSelfMessage)) ) }, - onSendMessage = messageComposerViewModel::trySendMessage, + onSendMessage = sendMessageViewModel::trySendMessage, onDeleteMessage = conversationMessagesViewModel::showDeleteMessageDialog, onAssetItemClicked = conversationMessagesViewModel::downloadOrFetchAssetAndShowDialog, onImageFullScreenMode = { message, isSelfMessage -> @@ -389,14 +392,14 @@ fun ConversationScreen( } }, onBackButtonClick = { conversationScreenOnBackButtonClick(messageComposerViewModel, focusManager, navigator) }, - composerMessages = messageComposerViewModel.infoMessage, + composerMessages = sendMessageViewModel.infoMessage, conversationMessages = conversationMessagesViewModel.infoMessage, conversationMessagesViewModel = conversationMessagesViewModel, onSelfDeletingMessageRead = messageComposerViewModel::startSelfDeletion, onNewSelfDeletingMessagesStatus = messageComposerViewModel::updateSelfDeletingMessages, tempWritableImageUri = messageComposerViewModel.tempWritableImageUri, tempWritableVideoUri = messageComposerViewModel.tempWritableVideoUri, - onFailedMessageRetryClicked = messageComposerViewModel::retrySendingMessage, + onFailedMessageRetryClicked = sendMessageViewModel::retrySendingMessage, requestMentions = messageComposerViewModel::searchMembersToMention, onClearMentionSearchResult = messageComposerViewModel::clearMentionSearchResult, onPermissionPermanentlyDenied = { @@ -470,8 +473,8 @@ fun ConversationScreen( } ) AssetTooLargeDialog( - dialogState = messageComposerViewModel.assetTooLargeDialogState, - hideDialog = messageComposerViewModel::hideAssetTooLargeError + dialogState = sendMessageViewModel.assetTooLargeDialogState, + hideDialog = sendMessageViewModel::hideAssetTooLargeError ) VisitLinkDialog( dialogState = messageComposerViewModel.visitLinkDialogState, @@ -489,15 +492,15 @@ fun ConversationScreen( ) SureAboutMessagingInDegradedConversationDialog( - dialogState = messageComposerViewModel.sureAboutMessagingDialogState, - sendAnyway = messageComposerViewModel::acceptSureAboutSendingMessage, - hideDialog = messageComposerViewModel::dismissSureAboutSendingMessage + dialogState = sendMessageViewModel.sureAboutMessagingDialogState, + sendAnyway = sendMessageViewModel::acceptSureAboutSendingMessage, + hideDialog = sendMessageViewModel::dismissSureAboutSendingMessage ) - (messageComposerViewModel.sureAboutMessagingDialogState as? SureAboutMessagingDialogState.Visible.ConversationUnderLegalHold)?.let { + (sendMessageViewModel.sureAboutMessagingDialogState as? SureAboutMessagingDialogState.Visible.ConversationUnderLegalHold)?.let { LegalHoldSubjectMessageDialog( - dialogDismissed = messageComposerViewModel::dismissSureAboutSendingMessage, - sendAnywayClicked = messageComposerViewModel::acceptSureAboutSendingMessage, + dialogDismissed = sendMessageViewModel::dismissSureAboutSendingMessage, + sendAnywayClicked = sendMessageViewModel::acceptSureAboutSendingMessage, ) } @@ -852,7 +855,7 @@ private fun ConversationScreenContent( } @Composable -fun SnackBarMessage( +private fun SnackBarMessage( composerMessages: SharedFlow, conversationMessages: SharedFlow ) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModel.kt new file mode 100644 index 00000000000..5c36838b871 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModel.kt @@ -0,0 +1,188 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.conversations.composer + +import android.net.Uri +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.wire.android.mapper.ContactMapper +import com.wire.android.navigation.SavedStateViewModel +import com.wire.android.ui.home.conversations.ConversationNavArgs +import com.wire.android.ui.home.conversations.InvalidLinkDialogState +import com.wire.android.ui.home.conversations.MessageComposerViewState +import com.wire.android.ui.home.conversations.VisitLinkDialogState +import com.wire.android.ui.home.conversations.model.UIMessage +import com.wire.android.ui.navArgs +import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.kalium.logic.configuration.FileSharingStatus +import com.wire.kalium.logic.data.conversation.Conversation.TypingIndicatorMode +import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.data.message.SelfDeletionTimer +import com.wire.kalium.logic.data.message.draft.MessageDraft +import com.wire.kalium.logic.data.user.OtherUser +import com.wire.kalium.logic.feature.conversation.InteractionAvailability +import com.wire.kalium.logic.feature.conversation.IsInteractionAvailableResult +import com.wire.kalium.logic.feature.conversation.MembersToMentionUseCase +import com.wire.kalium.logic.feature.conversation.ObserveConversationInteractionAvailabilityUseCase +import com.wire.kalium.logic.feature.conversation.SendTypingEventUseCase +import com.wire.kalium.logic.feature.conversation.UpdateConversationReadDateUseCase +import com.wire.kalium.logic.feature.message.draft.SaveMessageDraftUseCase +import com.wire.kalium.logic.feature.message.ephemeral.EnqueueMessageSelfDeletionUseCase +import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveSelfDeletionTimerSettingsForConversationUseCase +import com.wire.kalium.logic.feature.selfDeletingMessages.PersistNewSelfDeletionTimerUseCase +import com.wire.kalium.logic.feature.user.IsFileSharingEnabledUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import kotlinx.datetime.Instant +import javax.inject.Inject + +@Suppress("LongParameterList", "TooManyFunctions") +@HiltViewModel +class MessageComposerViewModel @Inject constructor( + override val savedStateHandle: SavedStateHandle, + private val dispatchers: DispatcherProvider, + private val isFileSharingEnabled: IsFileSharingEnabledUseCase, + private val observeConversationInteractionAvailability: ObserveConversationInteractionAvailabilityUseCase, + private val updateConversationReadDate: UpdateConversationReadDateUseCase, + private val contactMapper: ContactMapper, + private val membersToMention: MembersToMentionUseCase, + private val enqueueMessageSelfDeletion: EnqueueMessageSelfDeletionUseCase, + private val observeSelfDeletingMessages: ObserveSelfDeletionTimerSettingsForConversationUseCase, + private val persistNewSelfDeletingStatus: PersistNewSelfDeletionTimerUseCase, + private val sendTypingEvent: SendTypingEventUseCase, + private val saveMessageDraft: SaveMessageDraftUseCase +) : SavedStateViewModel(savedStateHandle) { + + var messageComposerViewState = mutableStateOf(MessageComposerViewState()) + private set + + var tempWritableVideoUri: Uri? = null + private set + + var tempWritableImageUri: Uri? = null + private set + + private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() + val conversationId: QualifiedID = conversationNavArgs.conversationId + + var visitLinkDialogState: VisitLinkDialogState by mutableStateOf( + VisitLinkDialogState.Hidden + ) + + var invalidLinkDialogState: InvalidLinkDialogState by mutableStateOf( + InvalidLinkDialogState.Hidden + ) + + init { + observeIsTypingAvailable() + observeSelfDeletingMessagesStatus() + setFileSharingStatus() + } + + private fun observeIsTypingAvailable() = viewModelScope.launch { + observeConversationInteractionAvailability(conversationId).collect { result -> + messageComposerViewState.value = messageComposerViewState.value.copy( + interactionAvailability = when (result) { + is IsInteractionAvailableResult.Failure -> InteractionAvailability.DISABLED + is IsInteractionAvailableResult.Success -> result.interactionAvailability + } + ) + } + } + + private fun observeSelfDeletingMessagesStatus() = viewModelScope.launch { + observeSelfDeletingMessages( + conversationId, + considerSelfUserSettings = true + ).collect { selfDeletingStatus -> + messageComposerViewState.value = + messageComposerViewState.value.copy(selfDeletionTimer = selfDeletingStatus) + } + } + + fun searchMembersToMention(searchQuery: String) { + viewModelScope.launch(dispatchers.io()) { + val members = membersToMention(conversationId, searchQuery).map { + contactMapper.fromOtherUser(it.user as OtherUser) + } + + messageComposerViewState.value = + messageComposerViewState.value.copy(mentionSearchResult = members) + } + } + + fun clearMentionSearchResult() { + messageComposerViewState.value = + messageComposerViewState.value.copy(mentionSearchResult = emptyList()) + } + + private fun setFileSharingStatus() { + // TODO: handle restriction when sending assets + viewModelScope.launch { + messageComposerViewState.value = when (isFileSharingEnabled().state) { + FileSharingStatus.Value.Disabled, + is FileSharingStatus.Value.EnabledSome -> + messageComposerViewState.value.copy(isFileSharingEnabled = false) + + FileSharingStatus.Value.EnabledAll -> + messageComposerViewState.value.copy(isFileSharingEnabled = true) + } + } + } + + fun updateConversationReadDate(utcISO: String) { + viewModelScope.launch(dispatchers.io()) { + updateConversationReadDate(conversationId, Instant.parse(utcISO)) + } + } + + fun startSelfDeletion(uiMessage: UIMessage) { + enqueueMessageSelfDeletion(conversationId, uiMessage.header.messageId) + } + + fun updateSelfDeletingMessages(newSelfDeletionTimer: SelfDeletionTimer) = + viewModelScope.launch { + messageComposerViewState.value = + messageComposerViewState.value.copy(selfDeletionTimer = newSelfDeletionTimer) + persistNewSelfDeletingStatus(conversationId, newSelfDeletionTimer) + } + + fun hideVisitLinkDialog() { + visitLinkDialogState = VisitLinkDialogState.Hidden + } + + fun hideInvalidLinkError() { + invalidLinkDialogState = InvalidLinkDialogState.Hidden + } + + fun sendTypingEvent(typingIndicatorMode: TypingIndicatorMode) { + viewModelScope.launch { + sendTypingEvent(conversationId, typingIndicatorMode) + } + } + + fun saveDraft(messageDraft: MessageDraft) { + viewModelScope.launch { + saveMessageDraft(conversationId, messageDraft) + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt index e9fdfdfac3e..ed91d434103 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt @@ -39,12 +39,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootNavGraph import com.wire.android.R import com.wire.android.media.audiomessage.AudioState +import com.wire.android.model.SnackBarMessage import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.style.PopUpNavigationAnimation @@ -54,15 +56,14 @@ import com.wire.android.ui.common.calculateCurrentTab import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dialogs.PermissionPermanentlyDeniedDialog import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.topBarElevation import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.visbility.rememberVisibilityState import com.wire.android.ui.destinations.MediaGalleryScreenDestination import com.wire.android.ui.home.conversations.DownloadedAssetDialog -import com.wire.android.ui.home.conversations.MessageComposerViewModel import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState -import com.wire.android.ui.home.conversations.SnackBarMessage import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions @@ -70,6 +71,7 @@ import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.id.ConversationId import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch @RootNavGraph @@ -81,8 +83,7 @@ import kotlinx.coroutines.launch fun ConversationMediaScreen( navigator: Navigator, conversationAssetMessagesViewModel: ConversationAssetMessagesViewModel = hiltViewModel(), - conversationMessagesViewModel: ConversationMessagesViewModel = hiltViewModel(), - messageComposerViewModel: MessageComposerViewModel = hiltViewModel() + conversationMessagesViewModel: ConversationMessagesViewModel = hiltViewModel() ) { val permissionPermanentlyDeniedDialogState = rememberVisibilityState() @@ -129,10 +130,7 @@ fun ConversationMediaScreen( hideDialog = permissionPermanentlyDeniedDialogState::dismiss ) - SnackBarMessage( - messageComposerViewModel.infoMessage, - conversationMessagesViewModel.infoMessage - ) + SnackBarMessage(conversationMessagesViewModel.infoMessage) } @OptIn(ExperimentalFoundationApi::class) @@ -207,6 +205,20 @@ private fun Content( } } +@Composable +private fun SnackBarMessage(infoMessages: SharedFlow) { + val context = LocalContext.current + val snackbarHostState = LocalSnackbarHostState.current + + LaunchedEffect(Unit) { + infoMessages.collect { + snackbarHostState.showSnackbar( + message = it.uiText.asString(context.resources) + ) + } + } +} + enum class ConversationMediaScreenTabItem(@StringRes override val titleResId: Int) : TabItem { PICTURES(R.string.label_conversation_pictures), FILES(R.string.label_conversation_files); diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt index 4ff4a071f2c..589d3e48243 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt @@ -271,6 +271,7 @@ class ConversationMessagesViewModel @Inject constructor( val assetContent = messageContent.value assetDataPath(conversationId, messageId)?.let { (path, _) -> messageId to AssetBundle( + key = assetContent.remoteData.assetId, dataPath = path, fileName = assetContent.name ?: DEFAULT_ASSET_NAME, dataSize = assetContent.sizeInBytes, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/AssetBundle.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/AssetBundle.kt index f9aee9e8aae..ab9e502496b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/AssetBundle.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/AssetBundle.kt @@ -26,6 +26,7 @@ import okio.Path * Represents a set of metadata information of an asset message */ data class AssetBundle( + val key: String, val mimeType: String, val dataPath: Path, val dataSize: Long, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt similarity index 62% rename from app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModel.kt rename to app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt index f74aa873d41..a9981874fe0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModel.kt @@ -16,9 +16,8 @@ * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.ui.home.conversations +package com.wire.android.ui.home.conversations.sendmessage -import android.net.Uri import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -26,55 +25,42 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.wire.android.R import com.wire.android.appLogger -import com.wire.android.mapper.ContactMapper import com.wire.android.media.PingRinger import com.wire.android.model.SnackBarMessage import com.wire.android.navigation.SavedStateViewModel +import com.wire.android.ui.home.conversations.AssetTooLargeDialogState +import com.wire.android.ui.home.conversations.ConversationNavArgs +import com.wire.android.ui.home.conversations.ConversationSnackbarMessages +import com.wire.android.ui.home.conversations.SureAboutMessagingDialogState import com.wire.android.ui.home.conversations.model.AssetBundle -import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.ui.home.conversations.model.UriAsset +import com.wire.android.ui.home.conversations.usecase.HandleUriAssetUseCase import com.wire.android.ui.home.messagecomposer.state.ComposableMessageBundle import com.wire.android.ui.home.messagecomposer.state.MessageBundle import com.wire.android.ui.home.messagecomposer.state.Ping import com.wire.android.ui.navArgs -import com.wire.android.util.FileManager import com.wire.android.util.ImageUtil import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.getAudioLengthInMs import com.wire.kalium.logic.CoreFailure -import com.wire.kalium.logic.configuration.FileSharingStatus import com.wire.kalium.logic.data.asset.AttachmentType import com.wire.kalium.logic.data.asset.KaliumFileSystem import com.wire.kalium.logic.data.conversation.Conversation.TypingIndicatorMode import com.wire.kalium.logic.data.id.QualifiedID -import com.wire.kalium.logic.data.message.SelfDeletionTimer -import com.wire.kalium.logic.data.message.draft.MessageDraft -import com.wire.kalium.logic.data.user.OtherUser import com.wire.kalium.logic.failure.LegalHoldEnabledForConversationFailure -import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCase import com.wire.kalium.logic.feature.asset.ScheduleNewAssetMessageResult import com.wire.kalium.logic.feature.asset.ScheduleNewAssetMessageUseCase -import com.wire.kalium.logic.feature.conversation.InteractionAvailability -import com.wire.kalium.logic.feature.conversation.IsInteractionAvailableResult -import com.wire.kalium.logic.feature.conversation.MembersToMentionUseCase -import com.wire.kalium.logic.feature.conversation.ObserveConversationInteractionAvailabilityUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationUnderLegalHoldNotifiedUseCase import com.wire.kalium.logic.feature.conversation.ObserveDegradedConversationNotifiedUseCase import com.wire.kalium.logic.feature.conversation.SendTypingEventUseCase import com.wire.kalium.logic.feature.conversation.SetNotifiedAboutConversationUnderLegalHoldUseCase import com.wire.kalium.logic.feature.conversation.SetUserInformedAboutVerificationUseCase -import com.wire.kalium.logic.feature.conversation.UpdateConversationReadDateUseCase import com.wire.kalium.logic.feature.message.RetryFailedMessageUseCase import com.wire.kalium.logic.feature.message.SendEditTextMessageUseCase import com.wire.kalium.logic.feature.message.SendKnockUseCase import com.wire.kalium.logic.feature.message.SendLocationUseCase import com.wire.kalium.logic.feature.message.SendTextMessageUseCase import com.wire.kalium.logic.feature.message.draft.RemoveMessageDraftUseCase -import com.wire.kalium.logic.feature.message.draft.SaveMessageDraftUseCase -import com.wire.kalium.logic.feature.message.ephemeral.EnqueueMessageSelfDeletionUseCase -import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveSelfDeletionTimerSettingsForConversationUseCase -import com.wire.kalium.logic.feature.selfDeletingMessages.PersistNewSelfDeletionTimerUseCase -import com.wire.kalium.logic.feature.user.IsFileSharingEnabledUseCase import com.wire.kalium.logic.functional.Either import com.wire.kalium.logic.functional.onFailure import dagger.hilt.android.lifecycle.HiltViewModel @@ -83,53 +69,33 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.datetime.Instant import okio.Path import okio.Path.Companion.toPath import javax.inject.Inject @Suppress("LongParameterList", "TooManyFunctions") @HiltViewModel -class MessageComposerViewModel @Inject constructor( +class SendMessageViewModel @Inject constructor( override val savedStateHandle: SavedStateHandle, private val sendAssetMessage: ScheduleNewAssetMessageUseCase, private val sendTextMessage: SendTextMessageUseCase, private val sendEditTextMessage: SendEditTextMessageUseCase, private val retryFailedMessage: RetryFailedMessageUseCase, private val dispatchers: DispatcherProvider, - private val isFileSharingEnabled: IsFileSharingEnabledUseCase, - private val observeConversationInteractionAvailability: ObserveConversationInteractionAvailabilityUseCase, private val kaliumFileSystem: KaliumFileSystem, - private val updateConversationReadDate: UpdateConversationReadDateUseCase, - private val contactMapper: ContactMapper, - private val membersToMention: MembersToMentionUseCase, - private val getAssetSizeLimit: GetAssetSizeLimitUseCase, + private val handleUriAsset: HandleUriAssetUseCase, private val sendKnockUseCase: SendKnockUseCase, - private val enqueueMessageSelfDeletion: EnqueueMessageSelfDeletionUseCase, - private val observeSelfDeletingMessages: ObserveSelfDeletionTimerSettingsForConversationUseCase, - private val persistNewSelfDeletingStatus: PersistNewSelfDeletionTimerUseCase, private val sendTypingEvent: SendTypingEventUseCase, private val pingRinger: PingRinger, private val imageUtil: ImageUtil, - private val fileManager: FileManager, private val setUserInformedAboutVerification: SetUserInformedAboutVerificationUseCase, private val observeDegradedConversationNotified: ObserveDegradedConversationNotifiedUseCase, private val setNotifiedAboutConversationUnderLegalHold: SetNotifiedAboutConversationUnderLegalHoldUseCase, private val observeConversationUnderLegalHoldNotified: ObserveConversationUnderLegalHoldNotifiedUseCase, private val sendLocation: SendLocationUseCase, - private val saveMessageDraft: SaveMessageDraftUseCase, - private val removeMessageDraft: RemoveMessageDraftUseCase + private val removeMessageDraft: RemoveMessageDraftUseCase, ) : SavedStateViewModel(savedStateHandle) { - var messageComposerViewState = mutableStateOf(MessageComposerViewState()) - private set - - var tempWritableVideoUri: Uri? = null - private set - - var tempWritableImageUri: Uri? = null - private set - private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() val conversationId: QualifiedID = conversationNavArgs.conversationId @@ -140,51 +106,14 @@ class MessageComposerViewModel @Inject constructor( AssetTooLargeDialogState.Hidden ) - var visitLinkDialogState: VisitLinkDialogState by mutableStateOf( - VisitLinkDialogState.Hidden - ) - - var invalidLinkDialogState: InvalidLinkDialogState by mutableStateOf( - InvalidLinkDialogState.Hidden - ) - var sureAboutMessagingDialogState: SureAboutMessagingDialogState by mutableStateOf( SureAboutMessagingDialogState.Hidden ) - init { - initTempWritableVideoUri() - initTempWritableImageUri() - observeIsTypingAvailable() - observeSelfDeletingMessagesStatus() - setFileSharingStatus() - } - private fun onSnackbarMessage(type: SnackBarMessage) = viewModelScope.launch { _infoMessage.emit(type) } - private fun observeIsTypingAvailable() = viewModelScope.launch { - observeConversationInteractionAvailability(conversationId).collect { result -> - messageComposerViewState.value = messageComposerViewState.value.copy( - interactionAvailability = when (result) { - is IsInteractionAvailableResult.Failure -> InteractionAvailability.DISABLED - is IsInteractionAvailableResult.Success -> result.interactionAvailability - } - ) - } - } - - private fun observeSelfDeletingMessagesStatus() = viewModelScope.launch { - observeSelfDeletingMessages( - conversationId, - considerSelfUserSettings = true - ).collect { selfDeletingStatus -> - messageComposerViewState.value = - messageComposerViewState.value.copy(selfDeletionTimer = selfDeletingStatus) - } - } - private suspend fun shouldInformAboutDegradedBeforeSendingMessage(): Boolean = observeDegradedConversationNotified(conversationId).first().let { !it } @@ -265,47 +194,26 @@ class MessageComposerViewModel @Inject constructor( attachmentUri: UriAsset, audioPath: Path? = null ) { - val tempCachePath = kaliumFileSystem.rootCachePath - val assetBundle = fileManager.getAssetBundleFromUri( - attachmentUri = attachmentUri.uri, - tempCachePath = tempCachePath, + when (val result = handleUriAsset.invoke( + uri = attachmentUri.uri, + saveToDeviceIfInvalid = attachmentUri.saveToDeviceIfInvalid, audioPath = audioPath - ) - if (assetBundle != null) { - // The max limit for sending assets changes between user and asset types. - // Check [GetAssetSizeLimitUseCase] class for more detailed information about the real limits. - val maxSizeLimitInBytes = - getAssetSizeLimit(isImage = assetBundle.assetType == AttachmentType.IMAGE) - handleBundle(assetBundle, maxSizeLimitInBytes, attachmentUri) - } else { - onSnackbarMessage(ConversationSnackbarMessages.ErrorPickingAttachment) - } - } + )) { + is HandleUriAssetUseCase.Result.Failure.AssetTooLarge -> { + assetTooLargeDialogState = AssetTooLargeDialogState.Visible( + assetType = result.assetBundle.assetType, + maxLimitInMB = result.maxLimitInMB, + savedToDevice = attachmentUri.saveToDeviceIfInvalid + ) + } - private suspend fun handleBundle( - assetBundle: AssetBundle, - maxSizeLimitInBytes: Long, - attachmentUri: UriAsset - ) { - if (assetBundle.dataSize <= maxSizeLimitInBytes) { - sendAttachment(assetBundle) - } else { - if (attachmentUri.saveToDeviceIfInvalid) { - with(assetBundle) { - fileManager.saveToExternalMediaStorage( - fileName, - dataPath, - dataSize, - mimeType, - dispatchers - ) - } + HandleUriAssetUseCase.Result.Failure.Unknown -> { + onSnackbarMessage(ConversationSnackbarMessages.ErrorPickingAttachment) + } + + is HandleUriAssetUseCase.Result.Success -> { + sendAttachment(result.assetBundle) } - assetTooLargeDialogState = AssetTooLargeDialogState.Visible( - assetType = assetBundle.assetType, - maxLimitInMB = maxSizeLimitInBytes.div(sizeOf1MB).toInt(), - savedToDevice = attachmentUri.saveToDeviceIfInvalid - ) } } @@ -380,91 +288,10 @@ class MessageComposerViewModel @Inject constructor( } } - private fun initTempWritableVideoUri() { - viewModelScope.launch { - tempWritableVideoUri = - fileManager.getTempWritableVideoUri(kaliumFileSystem.rootCachePath) - } - } - - private fun initTempWritableImageUri() { - viewModelScope.launch { - tempWritableImageUri = - fileManager.getTempWritableImageUri(kaliumFileSystem.rootCachePath) - } - } - - fun searchMembersToMention(searchQuery: String) { - viewModelScope.launch(dispatchers.io()) { - val members = membersToMention(conversationId, searchQuery).map { - contactMapper.fromOtherUser(it.user as OtherUser) - } - - messageComposerViewState.value = - messageComposerViewState.value.copy(mentionSearchResult = members) - } - } - - fun clearMentionSearchResult() { - messageComposerViewState.value = - messageComposerViewState.value.copy(mentionSearchResult = emptyList()) - } - - private fun setFileSharingStatus() { - // TODO: handle restriction when sending assets - viewModelScope.launch { - messageComposerViewState.value = when (isFileSharingEnabled().state) { - FileSharingStatus.Value.Disabled, - is FileSharingStatus.Value.EnabledSome -> - messageComposerViewState.value.copy(isFileSharingEnabled = false) - - FileSharingStatus.Value.EnabledAll -> - messageComposerViewState.value.copy(isFileSharingEnabled = true) - } - } - } - - fun updateConversationReadDate(utcISO: String) { - viewModelScope.launch(dispatchers.io()) { - updateConversationReadDate(conversationId, Instant.parse(utcISO)) - } - } - - fun startSelfDeletion(uiMessage: UIMessage) { - enqueueMessageSelfDeletion(conversationId, uiMessage.header.messageId) - } - - fun updateSelfDeletingMessages(newSelfDeletionTimer: SelfDeletionTimer) = - viewModelScope.launch { - messageComposerViewState.value = - messageComposerViewState.value.copy(selfDeletionTimer = newSelfDeletionTimer) - persistNewSelfDeletingStatus(conversationId, newSelfDeletionTimer) - } - fun hideAssetTooLargeError() { assetTooLargeDialogState = AssetTooLargeDialogState.Hidden } - fun hideVisitLinkDialog() { - visitLinkDialogState = VisitLinkDialogState.Hidden - } - - fun hideInvalidLinkError() { - invalidLinkDialogState = InvalidLinkDialogState.Hidden - } - - fun sendTypingEvent(typingIndicatorMode: TypingIndicatorMode) { - viewModelScope.launch { - sendTypingEvent(conversationId, typingIndicatorMode) - } - } - - fun saveDraft(messageDraft: MessageDraft) { - viewModelScope.launch { - saveMessageDraft(conversationId, messageDraft) - } - } - fun acceptSureAboutSendingMessage() { (sureAboutMessagingDialogState as? SureAboutMessagingDialogState.Visible)?.let { viewModelScope.launch { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/HandleUriAssetUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/HandleUriAssetUseCase.kt new file mode 100644 index 00000000000..483ca16711c --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/HandleUriAssetUseCase.kt @@ -0,0 +1,87 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.usecase + +import android.net.Uri +import com.wire.android.ui.home.conversations.model.AssetBundle +import com.wire.android.util.FileManager +import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.kalium.logic.data.asset.AttachmentType +import com.wire.kalium.logic.data.asset.KaliumFileSystem +import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCase +import kotlinx.coroutines.withContext +import okio.Path +import javax.inject.Inject + +class HandleUriAssetUseCase @Inject constructor( + private val getAssetSizeLimit: GetAssetSizeLimitUseCase, + private val fileManager: FileManager, + private val kaliumFileSystem: KaliumFileSystem, + private val dispatchers: DispatcherProvider +) { + + suspend fun invoke( + uri: Uri, + saveToDeviceIfInvalid: Boolean = false, + audioPath: Path? = null, + ): Result = withContext(dispatchers.io()) { + + val tempCachePath = kaliumFileSystem.rootCachePath + val assetBundle = fileManager.getAssetBundleFromUri( + attachmentUri = uri, + tempCachePath = tempCachePath, + audioPath = audioPath + ) + if (assetBundle != null) { + // The max limit for sending assets changes between user and asset types. + // Check [GetAssetSizeLimitUseCase] class for more detailed information about the real limits. + val maxSizeLimitInBytes = getAssetSizeLimit(isImage = assetBundle.assetType == AttachmentType.IMAGE) + + if (assetBundle.dataSize <= maxSizeLimitInBytes) { + return@withContext Result.Success(assetBundle) + } else { + if (saveToDeviceIfInvalid) { + with(assetBundle) { + fileManager.saveToExternalMediaStorage( + fileName, + dataPath, + dataSize, + mimeType, + dispatchers + ) + } + } + return@withContext Result.Failure.AssetTooLarge(assetBundle, maxSizeLimitInBytes.div(sizeOf1MB).toInt()) + } + } else { + return@withContext Result.Failure.Unknown + } + } + + companion object { + private const val sizeOf1MB = 1024 * 1024 + } + + sealed class Result { + data class Success(val assetBundle: AssetBundle) : Result() + sealed class Failure : Result() { + data class AssetTooLarge(val assetBundle: AssetBundle, val maxLimitInMB: Int) : Failure() + data object Unknown : Failure() + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt index 0df4866b6be..b8318707aa7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.sharing -import android.content.Context import android.content.Intent import android.net.Uri import android.os.Parcelable @@ -37,7 +36,9 @@ import com.wire.android.mapper.toUIPreview import com.wire.android.model.ImageAsset import com.wire.android.model.SnackBarMessage import com.wire.android.model.UserAvatarData +import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.search.DEFAULT_SEARCH_QUERY_DEBOUNCE +import com.wire.android.ui.home.conversations.usecase.HandleUriAssetUseCase import com.wire.android.ui.home.conversationslist.model.BlockState import com.wire.android.ui.home.conversationslist.model.ConversationInfo import com.wire.android.ui.home.conversationslist.model.ConversationItem @@ -45,22 +46,17 @@ import com.wire.android.ui.home.conversationslist.parseConversationEventType import com.wire.android.ui.home.conversationslist.parsePrivateConversationEventType import com.wire.android.ui.home.conversationslist.showLegalHoldIndicator import com.wire.android.ui.home.messagecomposer.SelfDeletionDuration -import com.wire.android.util.FileManager import com.wire.android.util.ImageUtil import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.getAudioLengthInMs -import com.wire.android.util.getMetadataFromUri -import com.wire.android.util.getMimeType -import com.wire.android.util.isImageFile import com.wire.android.util.parcelableArrayList -import com.wire.android.util.resampleImageAndCopyToTempPath import com.wire.android.util.ui.WireSessionImageLoader +import com.wire.kalium.logic.data.asset.AttachmentType import com.wire.kalium.logic.data.asset.KaliumFileSystem import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.message.SelfDeletionTimer import com.wire.kalium.logic.data.message.SelfDeletionTimer.Companion.SELF_DELETION_LOG_TAG -import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCase import com.wire.kalium.logic.feature.asset.ScheduleNewAssetMessageResult import com.wire.kalium.logic.feature.asset.ScheduleNewAssetMessageUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationListDetailsUseCase @@ -83,7 +79,6 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.util.UUID import javax.inject.Inject @HiltViewModel @@ -93,11 +88,10 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( private val getSelf: GetSelfUserUseCase, private val userTypeMapper: UserTypeMapper, private val observeConversationListDetails: ObserveConversationListDetailsUseCase, - private val fileManager: FileManager, private val sendAssetMessage: ScheduleNewAssetMessageUseCase, private val sendTextMessage: SendTextMessageUseCase, private val kaliumFileSystem: KaliumFileSystem, - private val getAssetSizeLimit: GetAssetSizeLimitUseCase, + private val handleUriAsset: HandleUriAssetUseCase, private val persistNewSelfDeletionTimerUseCase: PersistNewSelfDeletionTimerUseCase, private val observeSelfDeletionSettingsForConversation: ObserveSelfDeletionTimerSettingsForConversationUseCase, private val wireSessionImageLoader: WireSessionImageLoader, @@ -281,14 +275,14 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( suspend fun handleReceivedDataFromSharingIntent(activity: AppCompatActivity) { val incomingIntent = ShareCompat.IntentReader(activity) - appLogger.e("Received data from sharing intent ${incomingIntent.streamCount}") + appLogger.i("Received data from sharing intent ${incomingIntent.streamCount}") importMediaState = importMediaState.copy(isImporting = true) if (incomingIntent.streamCount == 0) { handleSharedText(incomingIntent.text.toString()) } else { if (incomingIntent.isSingleShare) { // ACTION_SEND - handleSingleIntent(incomingIntent, activity) + handleSingleIntent(incomingIntent) } else { // ACTION_SEND_MULTIPLE handleMultipleActionIntent(activity) @@ -301,33 +295,30 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( importMediaState = importMediaState.copy(importedText = text) } - private suspend fun handleSingleIntent( - incomingIntent: ShareCompat.IntentReader, - activity: AppCompatActivity - ) { + private suspend fun handleSingleIntent(incomingIntent: ShareCompat.IntentReader) { incomingIntent.stream?.let { uri -> - uri.getMimeType(activity)?.let { mimeType -> - handleImportedAsset(activity, mimeType, uri)?.let { importedAsset -> - importMediaState = - importMediaState.copy(importedAssets = mutableListOf(importedAsset)) + handleImportedAsset(uri)?.let { importedAsset -> + if (importedAsset.assetSizeExceeded != null) { + onSnackbarMessage( + ImportMediaSnackbarMessages.MaxAssetSizeExceeded(importedAsset.assetSizeExceeded!!) + ) } + importMediaState = importMediaState.copy(importedAssets = mutableListOf(importedAsset)) } } } private suspend fun handleMultipleActionIntent(activity: AppCompatActivity) { - val importedMediaAssets = mutableListOf() - activity.intent.parcelableArrayList(Intent.EXTRA_STREAM)?.forEach { + val importedMediaAssets = activity.intent.parcelableArrayList(Intent.EXTRA_STREAM)?.mapNotNull { val fileUri = it.toString().toUri() - handleImportedAsset( - activity, - fileUri.getMimeType(activity).toString(), - fileUri - )?.let { importedAsset -> - importedMediaAssets.add(importedAsset) - } - } + handleImportedAsset(fileUri) + } ?: listOf() + importMediaState = importMediaState.copy(importedAssets = importedMediaAssets) + + importedMediaAssets.firstOrNull { it.assetSizeExceeded != null }?.let { + onSnackbarMessage(ImportMediaSnackbarMessages.MaxAssetSizeExceeded(it.assetSizeExceeded!!)) + } } fun checkRestrictionsAndSendImportedMedia(onSent: (ConversationId) -> Unit) = @@ -352,24 +343,28 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( val job = viewModelScope.launch { sendAssetMessage( conversationId = conversation.conversationId, - assetDataPath = importedAsset.dataPath, - assetName = importedAsset.name, - assetDataSize = importedAsset.size, - assetMimeType = importedAsset.mimeType, + assetDataPath = importedAsset.assetBundle.dataPath, + assetName = importedAsset.assetBundle.fileName, + assetDataSize = importedAsset.assetBundle.dataSize, + assetMimeType = importedAsset.assetBundle.mimeType, assetWidth = if (isImage) (importedAsset as ImportedMediaAsset.Image).width else 0, assetHeight = if (isImage) (importedAsset as ImportedMediaAsset.Image).height else 0, audioLengthInMs = getAudioLengthInMs( - dataPath = importedAsset.dataPath, - mimeType = importedAsset.mimeType + dataPath = importedAsset.assetBundle.dataPath, + mimeType = importedAsset.assetBundle.mimeType, ) ).also { val logConversationId = conversation.conversationId.toLogString() if (it is ScheduleNewAssetMessageResult.Failure) { - appLogger.e("Failed to import asset message to " + - "conversationId=$logConversationId") + appLogger.e( + "Failed to import asset message to " + + "conversationId=$logConversationId" + ) } else { - appLogger.d("Success importing asset message to " + - "conversationId=$logConversationId") + appLogger.d( + "Success importing asset message to " + + "conversationId=$logConversationId" + ) } } } @@ -415,77 +410,48 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( ) } - private suspend fun handleImportedAsset( - context: Context, - importedAssetMimeType: String, - uri: Uri - ): ImportedMediaAsset? = withContext(dispatchers.io()) { - val assetKey = UUID.randomUUID().toString() - val fileMetadata = uri.getMetadataFromUri(context) - val tempAssetPath = kaliumFileSystem.tempFilePath(assetKey) - val mimeType = fileMetadata.mimeType.ifEmpty { importedAssetMimeType } - return@withContext when { - isAboveLimit(isImageFile(mimeType), fileMetadata.sizeInBytes) -> null - isImageFile(mimeType) -> { - // Only resample the image if it is too large - val resampleSize = uri.resampleImageAndCopyToTempPath( - context, - tempAssetPath, - ImageUtil.ImageSizeClass.Medium - ) - if (resampleSize <= 0) return@withContext null + private suspend fun handleImportedAsset(uri: Uri): ImportedMediaAsset? = withContext(dispatchers.io()) { + when (val result = handleUriAsset.invoke(uri, saveToDeviceIfInvalid = false, audioPath = null)) { + is HandleUriAssetUseCase.Result.Failure.AssetTooLarge -> mapToImportedAsset(result.assetBundle, result.maxLimitInMB) + + HandleUriAssetUseCase.Result.Failure.Unknown -> null + is HandleUriAssetUseCase.Result.Success -> mapToImportedAsset(result.assetBundle, null) + } + } + private fun mapToImportedAsset(assetBundle: AssetBundle, assetSizeExceeded: Int?): ImportedMediaAsset { + return when (assetBundle.assetType) { + AttachmentType.IMAGE -> { val (imgWidth, imgHeight) = ImageUtil.extractImageWidthAndHeight( kaliumFileSystem, - tempAssetPath + assetBundle.dataPath ) - ImportedMediaAsset.Image( - name = fileMetadata.name, - size = fileMetadata.sizeInBytes, - mimeType = mimeType, - dataPath = tempAssetPath, - key = assetKey, + assetBundle = assetBundle, width = imgWidth, height = imgHeight, + assetSizeExceeded = assetSizeExceeded, wireSessionImageLoader = wireSessionImageLoader ) } - else -> { - fileManager.copyToPath(uri, tempAssetPath) + AttachmentType.GENERIC_FILE, + AttachmentType.AUDIO, + AttachmentType.VIDEO -> { ImportedMediaAsset.GenericAsset( - name = fileMetadata.name, - size = fileMetadata.sizeInBytes, - mimeType = mimeType, - dataPath = tempAssetPath, - key = assetKey + assetBundle = assetBundle, + assetSizeExceeded = assetSizeExceeded ) } } } - private suspend fun isAboveLimit(isImage: Boolean, sizeInBytes: Long): Boolean { - val assetLimitInBytesForCurrentUser = getAssetSizeLimit(isImage).toInt() - val sizeOf1MB = SIZE_OF_1_MB - val isAboveLimit = sizeInBytes > assetLimitInBytesForCurrentUser - if (isAboveLimit) { - onSnackbarMessage( - ImportMediaSnackbarMessages.MaxAssetSizeExceeded( - assetLimitInBytesForCurrentUser.div(sizeOf1MB) - ) - ) - } - return isAboveLimit - } - fun onSnackbarMessage(type: SnackBarMessage) = viewModelScope.launch { _infoMessage.emit(type) } private companion object { const val MAX_LIMIT_MEDIA_IMPORT = 20 - const val SIZE_OF_1_MB = 1024 * 1024 } } diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt index 82ed7a227c5..bebac6f8a47 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt @@ -68,6 +68,7 @@ import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.topappbar.search.SearchBarState import com.wire.android.ui.common.topappbar.search.SearchTopBar @@ -103,7 +104,7 @@ fun ImportMediaScreen( FeatureFlagState.SharingRestrictedState.NO_USER -> { ImportMediaLoggedOutContent( fileSharingRestrictedState = fileSharingRestrictedState, - navigateBack = navigator::navigateBack + navigateBack = navigator.finish ) } @@ -112,7 +113,7 @@ fun ImportMediaScreen( ImportMediaRestrictedContent( fileSharingRestrictedState = fileSharingRestrictedState, importMediaAuthenticatedState = importMediaViewModel.importMediaState, - navigateBack = navigator::navigateBack + navigateBack = navigator.finish ) } @@ -127,14 +128,14 @@ fun ImportMediaScreen( navigator.navigate( NavigationCommand( ConversationScreenDestination(it), - BackStackMode.CLEAR_TILL_START + BackStackMode.REMOVE_CURRENT ) ) } }, onNewSelfDeletionTimerPicked = importMediaViewModel::onNewSelfDeletionTimerPicked, infoMessage = importMediaViewModel.infoMessage, - navigateBack = navigator::navigateBack, + navigateBack = navigator.finish, ) val context = LocalContext.current LaunchedEffect(importMediaViewModel.importMediaState.importedAssets) { @@ -150,7 +151,7 @@ fun ImportMediaScreen( } } - BackHandler { navigator.navigateBack() } + BackHandler { navigator.finish() } } @Composable @@ -165,6 +166,7 @@ fun ImportMediaRestrictedContent( WireCenterAlignedTopAppBar( elevation = 0.dp, onNavigationPressed = navigateBack, + navigationIconType = NavigationIconType.Close, title = stringResource(id = R.string.import_media_content_title), actions = { UserProfileAvatar( @@ -205,6 +207,7 @@ fun ImportMediaRegularContent( WireCenterAlignedTopAppBar( elevation = 0.dp, onNavigationPressed = navigateBack, + navigationIconType = NavigationIconType.Close, title = stringResource(id = R.string.import_media_content_title), actions = { UserProfileAvatar( @@ -255,6 +258,7 @@ fun ImportMediaLoggedOutContent( WireCenterAlignedTopAppBar( elevation = 0.dp, onNavigationPressed = navigateBack, + navigationIconType = NavigationIconType.Close, title = stringResource(id = R.string.import_media_content_title), ) }, diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportedMediaAsset.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportedMediaAsset.kt new file mode 100644 index 00000000000..00454e52f64 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportedMediaAsset.kt @@ -0,0 +1,42 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.sharing + +import com.wire.android.model.ImageAsset +import com.wire.android.ui.home.conversations.model.AssetBundle +import com.wire.android.util.ui.WireSessionImageLoader + +sealed class ImportedMediaAsset( + open val assetBundle: AssetBundle, + open val assetSizeExceeded: Int? +) { + class GenericAsset( + override val assetBundle: AssetBundle, + override val assetSizeExceeded: Int?, + ) : ImportedMediaAsset(assetBundle, assetSizeExceeded) + + class Image( + val width: Int, + val height: Int, + override val assetBundle: AssetBundle, + override val assetSizeExceeded: Int?, + val wireSessionImageLoader: WireSessionImageLoader + ) : ImportedMediaAsset(assetBundle, assetSizeExceeded) { + val localImageAsset = ImageAsset.LocalImageAsset(wireSessionImageLoader, assetBundle.dataPath, assetBundle.key) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportedMediaTypes.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportedMediaTypes.kt index 85b5a5d26e3..b85eca1860f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportedMediaTypes.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportedMediaTypes.kt @@ -19,15 +19,12 @@ package com.wire.android.ui.sharing import androidx.compose.runtime.Composable import com.wire.android.model.Clickable -import com.wire.android.model.ImageAsset import com.wire.android.ui.home.conversations.model.MessageGenericAsset import com.wire.android.ui.home.conversations.model.MessageImage import com.wire.android.ui.home.conversations.model.messagetypes.image.ImageMessageParams -import com.wire.android.util.ui.WireSessionImageLoader import com.wire.kalium.logic.data.asset.AssetTransferStatus import com.wire.kalium.logic.util.fileExtension import com.wire.kalium.logic.util.splitFileExtension -import okio.Path @Composable fun ImportedMediaItemView(item: ImportedMediaAsset, isMultipleImport: Boolean) { @@ -52,41 +49,12 @@ fun ImportedImageView(item: ImportedMediaAsset.Image, isMultipleImport: Boolean) @Composable fun ImportedGenericAssetView(item: ImportedMediaAsset.GenericAsset, isMultipleImport: Boolean) { MessageGenericAsset( - assetName = item.name.splitFileExtension().first, - assetExtension = item.name.fileExtension() ?: "", - assetSizeInBytes = item.size, + assetName = item.assetBundle.fileName.splitFileExtension().first, + assetExtension = item.assetBundle.fileName.fileExtension() ?: "", + assetSizeInBytes = item.assetBundle.dataSize, onAssetClick = Clickable(enabled = false), assetTransferStatus = AssetTransferStatus.NOT_DOWNLOADED, shouldFillMaxWidth = !isMultipleImport, isImportedMediaAsset = true ) } - -sealed class ImportedMediaAsset( - open val name: String, - open val size: Long, - open val mimeType: String, - open val dataPath: Path, - open val key: String -) { - class GenericAsset( - override val name: String, - override val size: Long, - override val mimeType: String, - override val dataPath: Path, - override val key: String - ) : ImportedMediaAsset(name, size, mimeType, dataPath, key) - - class Image( - val width: Int, - val height: Int, - override val name: String, - override val size: Long, - override val mimeType: String, - override val dataPath: Path, - override val key: String, - val wireSessionImageLoader: WireSessionImageLoader - ) : ImportedMediaAsset(name, size, mimeType, dataPath, key) { - val localImageAsset = ImageAsset.LocalImageAsset(wireSessionImageLoader, dataPath, key) - } -} diff --git a/app/src/main/kotlin/com/wire/android/util/FileManager.kt b/app/src/main/kotlin/com/wire/android/util/FileManager.kt index 668222ba412..62841cace17 100644 --- a/app/src/main/kotlin/com/wire/android/util/FileManager.kt +++ b/app/src/main/kotlin/com/wire/android/util/FileManager.kt @@ -104,6 +104,8 @@ class FileManager @Inject constructor(@ApplicationContext private val context: C return@withContext getTempWritableAttachmentUri(context, tempImagePath) } + // TODO we should handle those errors more user friendly + @Suppress("TooGenericExceptionCaught") suspend fun getAssetBundleFromUri( attachmentUri: Uri, tempCachePath: Path, @@ -111,6 +113,7 @@ class FileManager @Inject constructor(@ApplicationContext private val context: C dispatcher: DispatcherProvider = DefaultDispatcherProvider(), ): AssetBundle? = withContext(dispatcher.io()) { try { + val assetKey = UUID.randomUUID().toString() val assetFileName = context.getFileName(attachmentUri) ?: throw IOException("The selected asset has an invalid name") val fullTempAssetPath = "$tempCachePath/${UUID.randomUUID()}".toPath() @@ -126,10 +129,13 @@ class FileManager @Inject constructor(@ApplicationContext private val context: C // of video assets hitting the max limit. copyToPath(attachmentUri, fullTempAssetPath) } - AssetBundle(mimeType, assetPath, assetSize, assetFileName, attachmentType) + AssetBundle(assetKey, mimeType, assetPath, assetSize, assetFileName, attachmentType) } catch (e: IOException) { appLogger.e("There was an error while obtaining the file from disk", e) null + } catch (e: Exception) { + appLogger.e("There was an error while handling file from disk", e) + null } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt similarity index 58% rename from app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelArrangement.kt rename to app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt index b5ba928ce86..f4dd6c331dd 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt @@ -16,18 +16,16 @@ * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.ui.home.conversations +package com.wire.android.ui.home.conversations.composer -import android.net.Uri import androidx.lifecycle.SavedStateHandle import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri import com.wire.android.framework.FakeKaliumFileSystem import com.wire.android.framework.TestConversation import com.wire.android.mapper.ContactMapper -import com.wire.android.media.PingRinger import com.wire.android.model.UserAvatarData -import com.wire.android.ui.home.conversations.model.AssetBundle +import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.model.ExpirationStatus import com.wire.android.ui.home.conversations.model.MessageFlowStatus import com.wire.android.ui.home.conversations.model.MessageHeader @@ -36,10 +34,7 @@ import com.wire.android.ui.home.conversations.model.MessageStatus import com.wire.android.ui.home.conversations.model.MessageTime import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.ui.navArgs -import com.wire.android.util.FileManager -import com.wire.android.util.ImageUtil import com.wire.android.util.ui.UIText -import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.configuration.FileSharingStatus import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.ConversationDetails @@ -52,34 +47,19 @@ import com.wire.kalium.logic.data.user.UserAssetId import com.wire.kalium.logic.data.user.UserAvailabilityStatus import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.data.user.type.UserType -import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCase -import com.wire.kalium.logic.feature.asset.ScheduleNewAssetMessageResult -import com.wire.kalium.logic.feature.asset.ScheduleNewAssetMessageUseCase -import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveOngoingCallsUseCase import com.wire.kalium.logic.feature.conversation.InteractionAvailability import com.wire.kalium.logic.feature.conversation.IsInteractionAvailableResult import com.wire.kalium.logic.feature.conversation.MembersToMentionUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationInteractionAvailabilityUseCase -import com.wire.kalium.logic.feature.conversation.ObserveConversationUnderLegalHoldNotifiedUseCase -import com.wire.kalium.logic.feature.conversation.ObserveDegradedConversationNotifiedUseCase import com.wire.kalium.logic.feature.conversation.SendTypingEventUseCase -import com.wire.kalium.logic.feature.conversation.SetNotifiedAboutConversationUnderLegalHoldUseCase -import com.wire.kalium.logic.feature.conversation.SetUserInformedAboutVerificationUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationReadDateUseCase -import com.wire.kalium.logic.feature.message.RetryFailedMessageUseCase -import com.wire.kalium.logic.feature.message.SendEditTextMessageUseCase -import com.wire.kalium.logic.feature.message.SendKnockUseCase -import com.wire.kalium.logic.feature.message.SendLocationUseCase -import com.wire.kalium.logic.feature.message.SendTextMessageUseCase -import com.wire.kalium.logic.feature.message.draft.RemoveMessageDraftUseCase import com.wire.kalium.logic.feature.message.draft.SaveMessageDraftUseCase import com.wire.kalium.logic.feature.message.ephemeral.EnqueueMessageSelfDeletionUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveSelfDeletionTimerSettingsForConversationUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.PersistNewSelfDeletionTimerUseCase import com.wire.kalium.logic.feature.user.IsFileSharingEnabledUseCase -import com.wire.kalium.logic.functional.Either import com.wire.kalium.logic.sync.ObserveSyncStateUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -106,28 +86,11 @@ internal class MessageComposerViewModelArrangement { coEvery { observeOngoingCallsUseCase() } returns flowOf(listOf()) coEvery { observeEstablishedCallsUseCase() } returns flowOf(listOf()) coEvery { observeSyncState() } returns flowOf(SyncState.Live) - every { pingRinger.ping(any(), any()) } returns Unit - coEvery { sendKnockUseCase(any(), any()) } returns Either.Right(Unit) - coEvery { fileManager.getTempWritableVideoUri(any(), any()) } returns Uri.parse("video.mp4") - coEvery { fileManager.getTempWritableImageUri(any(), any()) } returns Uri.parse("image.jpg") - coEvery { setUserInformedAboutVerificationUseCase(any()) } returns Unit - coEvery { observeDegradedConversationNotifiedUseCase(any()) } returns flowOf(true) - coEvery { setNotifiedAboutConversationUnderLegalHold(any()) } returns Unit - coEvery { observeConversationUnderLegalHoldNotified(any()) } returns flowOf(true) } @MockK private lateinit var savedStateHandle: SavedStateHandle - @MockK - lateinit var sendTextMessage: SendTextMessageUseCase - - @MockK - lateinit var sendEditTextMessage: SendEditTextMessageUseCase - - @MockK - lateinit var sendAssetMessage: ScheduleNewAssetMessageUseCase - @MockK lateinit var isFileSharingEnabledUseCase: IsFileSharingEnabledUseCase @@ -140,36 +103,18 @@ internal class MessageComposerViewModelArrangement { @MockK private lateinit var observeConversationInteractionAvailabilityUseCase: ObserveConversationInteractionAvailabilityUseCase - @MockK - private lateinit var endCall: EndCallUseCase - @MockK private lateinit var updateConversationReadDateUseCase: UpdateConversationReadDateUseCase - @MockK - lateinit var sendKnockUseCase: SendKnockUseCase - - @MockK - lateinit var fileManager: FileManager - @MockK private lateinit var observeSyncState: ObserveSyncStateUseCase @MockK private lateinit var contactMapper: ContactMapper - @MockK - lateinit var pingRinger: PingRinger - - @MockK - private lateinit var imageUtil: ImageUtil - @MockK private lateinit var membersToMention: MembersToMentionUseCase - @MockK - private lateinit var getAssetSizeLimitUseCase: GetAssetSizeLimitUseCase - @MockK private lateinit var enqueueMessageSelfDeletionUseCase: EnqueueMessageSelfDeletionUseCase @@ -179,65 +124,28 @@ internal class MessageComposerViewModelArrangement { @MockK lateinit var persistSelfDeletionStatus: PersistNewSelfDeletionTimerUseCase - @MockK - lateinit var retryFailedMessageUseCase: RetryFailedMessageUseCase - @MockK lateinit var sendTypingEvent: SendTypingEventUseCase - @MockK - lateinit var setUserInformedAboutVerificationUseCase: SetUserInformedAboutVerificationUseCase - - @MockK - lateinit var observeDegradedConversationNotifiedUseCase: ObserveDegradedConversationNotifiedUseCase - - @MockK - lateinit var setNotifiedAboutConversationUnderLegalHold: SetNotifiedAboutConversationUnderLegalHoldUseCase - - @MockK - lateinit var observeConversationUnderLegalHoldNotified: ObserveConversationUnderLegalHoldNotifiedUseCase - @MockK lateinit var saveMessageDraftUseCase: SaveMessageDraftUseCase - @MockK - lateinit var removeMessageDraftUseCase: RemoveMessageDraftUseCase - - @MockK - lateinit var sendLocation: SendLocationUseCase - private val fakeKaliumFileSystem = FakeKaliumFileSystem() private val viewModel by lazy { MessageComposerViewModel( savedStateHandle = savedStateHandle, - sendTextMessage = sendTextMessage, - sendEditTextMessage = sendEditTextMessage, - sendAssetMessage = sendAssetMessage, dispatchers = TestDispatcherProvider(), isFileSharingEnabled = isFileSharingEnabledUseCase, - kaliumFileSystem = fakeKaliumFileSystem, updateConversationReadDate = updateConversationReadDateUseCase, observeConversationInteractionAvailability = observeConversationInteractionAvailabilityUseCase, contactMapper = contactMapper, membersToMention = membersToMention, - getAssetSizeLimit = getAssetSizeLimitUseCase, - imageUtil = imageUtil, - pingRinger = pingRinger, - sendKnockUseCase = sendKnockUseCase, - fileManager = fileManager, enqueueMessageSelfDeletion = enqueueMessageSelfDeletionUseCase, observeSelfDeletingMessages = observeConversationSelfDeletionStatus, persistNewSelfDeletingStatus = persistSelfDeletionStatus, - retryFailedMessage = retryFailedMessageUseCase, sendTypingEvent = sendTypingEvent, - setUserInformedAboutVerification = setUserInformedAboutVerificationUseCase, - observeDegradedConversationNotified = observeDegradedConversationNotifiedUseCase, - setNotifiedAboutConversationUnderLegalHold = setNotifiedAboutConversationUnderLegalHold, - observeConversationUnderLegalHoldNotified = observeConversationUnderLegalHoldNotified, - sendLocation = sendLocation, saveMessageDraft = saveMessageDraftUseCase, - removeMessageDraft = removeMessageDraftUseCase ) } @@ -245,7 +153,6 @@ internal class MessageComposerViewModelArrangement { coEvery { isFileSharingEnabledUseCase() } returns FileSharingStatus(FileSharingStatus.Value.EnabledAll, null) coEvery { observeOngoingCallsUseCase() } returns emptyFlow() coEvery { observeEstablishedCallsUseCase() } returns emptyFlow() - coEvery { imageUtil.extractImageWidthAndHeight(any(), any()) } returns (1 to 1) coEvery { observeConversationSelfDeletionStatus(any(), any()) } returns emptyFlow() coEvery { observeConversationInteractionAvailabilityUseCase(any()) } returns flowOf( IsInteractionAvailableResult.Success( @@ -260,89 +167,6 @@ internal class MessageComposerViewModelArrangement { } } - fun withSuccessfulSendAttachmentMessage() = apply { - coEvery { - sendAssetMessage( - any(), - any(), - any(), - any(), - any(), - any(), - any(), - any() - ) - } returns ScheduleNewAssetMessageResult.Success("some-message-id") - } - - fun withSuccessfulSendTextMessage() = apply { - coEvery { - sendTextMessage( - any(), - any(), - any(), - any() - ) - } returns Either.Right(Unit) - } - - fun withFailedSendTextMessage(failure: CoreFailure) = apply { - coEvery { - sendTextMessage( - any(), - any(), - any(), - any() - ) - } returns Either.Left(failure) - } - - fun withSuccessfulSendEditTextMessage() = apply { - coEvery { - sendEditTextMessage( - any(), - any(), - any(), - any(), - any() - ) - } returns Either.Right(Unit) - } - - fun withSuccessfulSendLocationMessage() = apply { - coEvery { - sendLocation( - any(), - any(), - any(), - any(), - any() - ) - } returns Either.Right(Unit) - } - - fun withSuccessfulSendTypingEvent() = apply { - coEvery { - sendTypingEvent( - any(), - any() - ) - } returns Unit - } - - fun withGetAssetSizeLimitUseCase(isImage: Boolean, assetSizeLimit: Long) = apply { - coEvery { getAssetSizeLimitUseCase(eq(isImage)) } returns assetSizeLimit - return this - } - - fun withGetAssetBundleFromUri(assetBundle: AssetBundle?) = apply { - coEvery { fileManager.getAssetBundleFromUri(any(), any(), any(), any()) } returns assetBundle - } - - fun withSaveToExternalMediaStorage(resultFileName: String?) = apply { - coEvery { fileManager.saveToExternalMediaStorage(any(), any(), any(), any(), any()) } returns resultFileName - } - fun withObserveSelfDeletingStatus(expectedSelfDeletionTimer: SelfDeletionTimer) = apply { coEvery { observeConversationSelfDeletionStatus(conversationId, true) } returns flowOf(expectedSelfDeletionTimer) } @@ -351,18 +175,6 @@ internal class MessageComposerViewModelArrangement { coEvery { persistSelfDeletionStatus(any(), any()) } returns Unit } - fun withInformAboutVerificationBeforeMessagingFlag(flag: Boolean) = apply { - coEvery { observeDegradedConversationNotifiedUseCase(any()) } returns flowOf(flag) - } - - fun withObserveConversationUnderLegalHoldNotified(flag: Boolean) = apply { - coEvery { observeConversationUnderLegalHoldNotified(any()) } returns flowOf(flag) - } - - fun withSuccessfulRetryFailedMessage() = apply { - coEvery { retryFailedMessageUseCase(any(), any()) } returns Either.Right(Unit) - } - fun withSaveDraftMessage() = apply { coEvery { saveMessageDraftUseCase(any(), any()) } returns Unit } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelTest.kt new file mode 100644 index 00000000000..3d058e88995 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelTest.kt @@ -0,0 +1,133 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.conversations.composer + +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.NavigationTestExtension +import com.wire.kalium.logic.data.conversation.Conversation +import com.wire.kalium.logic.data.message.SelfDeletionTimer +import com.wire.kalium.logic.data.message.draft.MessageDraft +import io.mockk.coVerify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.internal.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutineTestExtension::class) +@ExtendWith(NavigationTestExtension::class) +@Suppress("LargeClass") +class MessageComposerViewModelTest { + + @Test + fun `given that a user updates the self-deleting message timer, when invoked, then the timer gets successfully updated`() = + runTest { + // Given + val expectedDuration = 1.toDuration(DurationUnit.HOURS) + val expectedTimer = SelfDeletionTimer.Enabled(expectedDuration) + val (arrangement, viewModel) = MessageComposerViewModelArrangement() + .withSuccessfulViewModelInit() + .withPersistSelfDeletionStatus() + .arrange() + + // When + viewModel.updateSelfDeletingMessages(expectedTimer) + + // Then + coVerify(exactly = 1) { + arrangement.persistSelfDeletionStatus.invoke( + arrangement.conversationId, + expectedTimer + ) + } + assertInstanceOf(SelfDeletionTimer.Enabled::class.java, viewModel.messageComposerViewState.value.selfDeletionTimer) + assertEquals(expectedDuration, viewModel.messageComposerViewState.value.selfDeletionTimer.duration) + } + + @Test + fun `given a valid observed enforced self-deleting message timer, when invoked, then the timer gets successfully updated`() = + runTest { + // Given + val expectedDuration = 1.toDuration(DurationUnit.DAYS) + val expectedTimer = SelfDeletionTimer.Enabled(expectedDuration) + val (arrangement, viewModel) = MessageComposerViewModelArrangement() + .withSuccessfulViewModelInit() + .withObserveSelfDeletingStatus(expectedTimer) + .arrange() + + // When + + // Then + coVerify(exactly = 1) { + arrangement.observeConversationSelfDeletionStatus.invoke( + arrangement.conversationId, + true + ) + } + assertInstanceOf(SelfDeletionTimer.Enabled::class.java, viewModel.messageComposerViewState.value.selfDeletionTimer) + assertEquals(expectedDuration, viewModel.messageComposerViewState.value.selfDeletionTimer.duration) + } + + @Test + fun `given that user types a text message, when invoked typing invoked, then send typing event is called`() = runTest { + // given + val (arrangement, viewModel) = MessageComposerViewModelArrangement() + .withSuccessfulViewModelInit() + .arrange() + + // when + viewModel.sendTypingEvent(Conversation.TypingIndicatorMode.STARTED) + + // then + coVerify(exactly = 1) { + arrangement.sendTypingEvent.invoke( + any(), + eq(Conversation.TypingIndicatorMode.STARTED) + ) + } + } + + @Test + fun `given that user saves a draft message, then save draft use case is triggered`() = + runTest { + // given + val messageDraft = MessageDraft( + text = "hello", + editMessageId = null, + quotedMessageId = null, + selectedMentionList = listOf() + ) + val (arrangement, viewModel) = MessageComposerViewModelArrangement() + .withSuccessfulViewModelInit() + .withSaveDraftMessage() + .arrange() + + // when + viewModel.saveDraft(messageDraft) + advanceUntilIdle() + + // then + coVerify(exactly = 1) { arrangement.saveMessageDraftUseCase.invoke(eq(viewModel.conversationId), eq(messageDraft)) } + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelTest.kt index b91db18e387..d4243d490e4 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelTest.kt @@ -21,8 +21,8 @@ package com.wire.android.ui.home.conversations.info import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension import com.wire.android.framework.TestUser -import com.wire.android.ui.home.conversations.mockConversationDetailsGroup -import com.wire.android.ui.home.conversations.withMockConversationDetailsOneOnOne +import com.wire.android.ui.home.conversations.composer.mockConversationDetailsGroup +import com.wire.android.ui.home.conversations.composer.withMockConversationDetailsOneOnOne import com.wire.android.util.EMPTY import com.wire.android.util.ui.UIText import com.wire.kalium.logic.StorageFailure diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt index 6682ce19aba..491c2fd2e56 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt @@ -161,7 +161,8 @@ class ConversationMessagesViewModelArrangement { assetSize: Long, messageId: String ) = apply { - val assetBundle = AssetBundle(assetMimeType, assetDataPath, assetSize, assetName, AttachmentType.fromMimeTypeString(assetMimeType)) + val assetBundle = + AssetBundle("key", assetMimeType, assetDataPath, assetSize, assetName, AttachmentType.fromMimeTypeString(assetMimeType)) viewModel.showOnAssetDownloadedDialog(assetBundle, messageId) every { fileManager.openWithExternalApp(any(), any(), any()) }.answers { viewModel.hideOnAssetDownloadedDialog() @@ -205,7 +206,10 @@ class ConversationMessagesViewModelArrangement { assetSize: Long, messageId: String ) = apply { - val assetBundle = AssetBundle(assetMimeType, assetDataPath, assetSize, assetName, AttachmentType.fromMimeTypeString(assetMimeType)) + val assetBundle = AssetBundle( + "key", + assetMimeType, assetDataPath, assetSize, assetName, AttachmentType.fromMimeTypeString(assetMimeType) + ) viewModel.showOnAssetDownloadedDialog(assetBundle, messageId) coEvery { fileManager.saveToExternalStorage(any(), any(), any(), any(), any()) }.answers { viewModel.hideOnAssetDownloadedDialog() diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt index 294fbee27d8..157d0d0196e 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt @@ -28,7 +28,7 @@ import com.wire.android.framework.TestMessage.GENERIC_ASSET_CONTENT import com.wire.android.ui.home.conversations.ConversationSnackbarMessages import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogActiveState import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogsState -import com.wire.android.ui.home.conversations.mockUITextMessage +import com.wire.android.ui.home.conversations.composer.mockUITextMessage import com.wire.kalium.logic.StorageFailure import com.wire.kalium.logic.data.message.MessageContent import com.wire.kalium.logic.data.user.UserId diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt new file mode 100644 index 00000000000..85ae072246a --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelArrangement.kt @@ -0,0 +1,253 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.conversations.sendmessage + +import androidx.lifecycle.SavedStateHandle +import com.wire.android.config.TestDispatcherProvider +import com.wire.android.config.mockUri +import com.wire.android.framework.FakeKaliumFileSystem +import com.wire.android.media.PingRinger +import com.wire.android.ui.home.conversations.ConversationNavArgs +import com.wire.android.ui.home.conversations.usecase.HandleUriAssetUseCase +import com.wire.android.ui.navArgs +import com.wire.android.util.ImageUtil +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.sync.SyncState +import com.wire.kalium.logic.feature.asset.ScheduleNewAssetMessageResult +import com.wire.kalium.logic.feature.asset.ScheduleNewAssetMessageUseCase +import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase +import com.wire.kalium.logic.feature.call.usecase.ObserveOngoingCallsUseCase +import com.wire.kalium.logic.feature.conversation.ObserveConversationUnderLegalHoldNotifiedUseCase +import com.wire.kalium.logic.feature.conversation.ObserveDegradedConversationNotifiedUseCase +import com.wire.kalium.logic.feature.conversation.SendTypingEventUseCase +import com.wire.kalium.logic.feature.conversation.SetNotifiedAboutConversationUnderLegalHoldUseCase +import com.wire.kalium.logic.feature.conversation.SetUserInformedAboutVerificationUseCase +import com.wire.kalium.logic.feature.message.RetryFailedMessageUseCase +import com.wire.kalium.logic.feature.message.SendEditTextMessageUseCase +import com.wire.kalium.logic.feature.message.SendKnockUseCase +import com.wire.kalium.logic.feature.message.SendLocationUseCase +import com.wire.kalium.logic.feature.message.SendTextMessageUseCase +import com.wire.kalium.logic.feature.message.draft.RemoveMessageDraftUseCase +import com.wire.kalium.logic.functional.Either +import com.wire.kalium.logic.sync.ObserveSyncStateUseCase +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import okio.Path +import okio.buffer + +internal class SendMessageViewModelArrangement { + + val conversationId: ConversationId = ConversationId("some-dummy-value", "some.dummy.domain") + + init { + // Tests setup + MockKAnnotations.init(this, relaxUnitFun = true) + mockUri() + every { savedStateHandle.navArgs() } returns ConversationNavArgs(conversationId = conversationId) + + // Default empty values + coEvery { observeOngoingCallsUseCase() } returns flowOf(listOf()) + coEvery { observeEstablishedCallsUseCase() } returns flowOf(listOf()) + coEvery { observeSyncState() } returns flowOf(SyncState.Live) + every { pingRinger.ping(any(), any()) } returns Unit + coEvery { sendKnockUseCase(any(), any()) } returns Either.Right(Unit) + coEvery { setUserInformedAboutVerificationUseCase(any()) } returns Unit + coEvery { observeDegradedConversationNotifiedUseCase(any()) } returns flowOf(true) + coEvery { setNotifiedAboutConversationUnderLegalHold(any()) } returns Unit + coEvery { observeConversationUnderLegalHoldNotified(any()) } returns flowOf(true) + } + + @MockK + private lateinit var savedStateHandle: SavedStateHandle + + @MockK + lateinit var sendTextMessage: SendTextMessageUseCase + + @MockK + lateinit var sendEditTextMessage: SendEditTextMessageUseCase + + @MockK + lateinit var sendAssetMessage: ScheduleNewAssetMessageUseCase + + @MockK + lateinit var observeOngoingCallsUseCase: ObserveOngoingCallsUseCase + + @MockK + private lateinit var observeEstablishedCallsUseCase: ObserveEstablishedCallsUseCase + + @MockK + lateinit var sendKnockUseCase: SendKnockUseCase + + @MockK + private lateinit var observeSyncState: ObserveSyncStateUseCase + + @MockK + lateinit var pingRinger: PingRinger + + @MockK + private lateinit var imageUtil: ImageUtil + + @MockK + private lateinit var handleUriAssetUseCase: HandleUriAssetUseCase + + @MockK + lateinit var retryFailedMessageUseCase: RetryFailedMessageUseCase + + @MockK + lateinit var sendTypingEvent: SendTypingEventUseCase + + @MockK + lateinit var setUserInformedAboutVerificationUseCase: SetUserInformedAboutVerificationUseCase + + @MockK + lateinit var observeDegradedConversationNotifiedUseCase: ObserveDegradedConversationNotifiedUseCase + + @MockK + lateinit var setNotifiedAboutConversationUnderLegalHold: SetNotifiedAboutConversationUnderLegalHoldUseCase + + @MockK + lateinit var observeConversationUnderLegalHoldNotified: ObserveConversationUnderLegalHoldNotifiedUseCase + + @MockK + lateinit var removeMessageDraftUseCase: RemoveMessageDraftUseCase + + @MockK + lateinit var sendLocation: SendLocationUseCase + + private val fakeKaliumFileSystem = FakeKaliumFileSystem() + + private val viewModel by lazy { + SendMessageViewModel( + savedStateHandle = savedStateHandle, + sendTextMessage = sendTextMessage, + sendEditTextMessage = sendEditTextMessage, + sendAssetMessage = sendAssetMessage, + dispatchers = TestDispatcherProvider(), + kaliumFileSystem = fakeKaliumFileSystem, + handleUriAsset = handleUriAssetUseCase, + imageUtil = imageUtil, + pingRinger = pingRinger, + sendKnockUseCase = sendKnockUseCase, + retryFailedMessage = retryFailedMessageUseCase, + sendTypingEvent = sendTypingEvent, + setUserInformedAboutVerification = setUserInformedAboutVerificationUseCase, + observeDegradedConversationNotified = observeDegradedConversationNotifiedUseCase, + setNotifiedAboutConversationUnderLegalHold = setNotifiedAboutConversationUnderLegalHold, + observeConversationUnderLegalHoldNotified = observeConversationUnderLegalHoldNotified, + sendLocation = sendLocation, + removeMessageDraft = removeMessageDraftUseCase + ) + } + + fun withSuccessfulViewModelInit() = apply { + coEvery { observeOngoingCallsUseCase() } returns emptyFlow() + coEvery { observeEstablishedCallsUseCase() } returns emptyFlow() + coEvery { imageUtil.extractImageWidthAndHeight(any(), any()) } returns (1 to 1) + } + + fun withStoredAsset(dataPath: Path, dataContent: ByteArray) = apply { + fakeKaliumFileSystem.sink(dataPath).buffer().use { + it.write(dataContent) + } + } + + fun withSuccessfulSendAttachmentMessage() = apply { + coEvery { + sendAssetMessage( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns ScheduleNewAssetMessageResult.Success("some-message-id") + } + + fun withSuccessfulSendTextMessage() = apply { + coEvery { + sendTextMessage( + any(), + any(), + any(), + any() + ) + } returns Either.Right(Unit) + } + + fun withFailedSendTextMessage(failure: CoreFailure) = apply { + coEvery { + sendTextMessage( + any(), + any(), + any(), + any() + ) + } returns Either.Left(failure) + } + + fun withSuccessfulSendEditTextMessage() = apply { + coEvery { + sendEditTextMessage( + any(), + any(), + any(), + any(), + any() + ) + } returns Either.Right(Unit) + } + + fun withSuccessfulSendLocationMessage() = apply { + coEvery { + sendLocation( + any(), + any(), + any(), + any(), + any() + ) + } returns Either.Right(Unit) + } + + fun withHandleUriAsset(result: HandleUriAssetUseCase.Result) = apply { + coEvery { handleUriAssetUseCase.invoke(any(), any(), any()) } returns result + } + + fun withInformAboutVerificationBeforeMessagingFlag(flag: Boolean) = apply { + coEvery { observeDegradedConversationNotifiedUseCase(any()) } returns flowOf(flag) + } + + fun withObserveConversationUnderLegalHoldNotified(flag: Boolean) = apply { + coEvery { observeConversationUnderLegalHoldNotified(any()) } returns flowOf(flag) + } + + fun withSuccessfulRetryFailedMessage() = apply { + coEvery { retryFailedMessageUseCase(any(), any()) } returns Either.Right(Unit) + } + + fun arrange() = this to viewModel +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt similarity index 66% rename from app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelTest.kt rename to app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt index f39e10ee0d0..6740fb8209b 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/MessageComposerViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/sendmessage/SendMessageViewModelTest.kt @@ -16,23 +16,24 @@ * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.ui.home.conversations +package com.wire.android.ui.home.conversations.sendmessage import android.location.Location import androidx.core.net.toUri import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension +import com.wire.android.ui.home.conversations.AssetTooLargeDialogState +import com.wire.android.ui.home.conversations.ConversationSnackbarMessages +import com.wire.android.ui.home.conversations.SureAboutMessagingDialogState import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.model.UriAsset +import com.wire.android.ui.home.conversations.usecase.HandleUriAssetUseCase import com.wire.android.ui.home.messagecomposer.state.ComposableMessageBundle import com.wire.android.ui.home.messagecomposer.state.Ping import com.wire.kalium.logic.data.asset.AttachmentType import com.wire.kalium.logic.data.conversation.Conversation -import com.wire.kalium.logic.data.message.SelfDeletionTimer -import com.wire.kalium.logic.data.message.draft.MessageDraft import com.wire.kalium.logic.failure.LegalHoldEnabledForConversationFailure -import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCaseImpl.Companion.ASSET_SIZE_DEFAULT_LIMIT_BYTES import io.mockk.coVerify import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -40,35 +41,32 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import okio.Path.Companion.toPath import org.amshove.kluent.internal.assertEquals -import org.junit.jupiter.api.Assertions.assertInstanceOf import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith -import kotlin.time.DurationUnit -import kotlin.time.toDuration @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) @ExtendWith(NavigationTestExtension::class) @Suppress("LargeClass") -class MessageComposerViewModelTest { +class SendMessageViewModelTest { @Test fun `given the user sends an asset message, when invoked, then sendAssetMessageUseCase gets called`() = runTest { // Given - val limit = ASSET_SIZE_DEFAULT_LIMIT_BYTES - val (arrangement, viewModel) = MessageComposerViewModelArrangement() - .withSuccessfulViewModelInit() - .withSuccessfulSendAttachmentMessage() - .withGetAssetSizeLimitUseCase(false, limit) - .arrange() val mockedAttachment = AssetBundle( + "key", "file/x-zip", "Mocked-data-path".toPath(), 1L, "mocked_file.zip", AttachmentType.GENERIC_FILE ) + val (arrangement, viewModel) = SendMessageViewModelArrangement() + .withSuccessfulViewModelInit() + .withSuccessfulSendAttachmentMessage() + .withHandleUriAsset(HandleUriAssetUseCase.Result.Success(mockedAttachment)) + .arrange() // When viewModel.sendAttachment(mockedAttachment) @@ -92,20 +90,20 @@ class MessageComposerViewModelTest { fun `given the user sends an image message, when invoked, then sendAssetMessageUseCase gets called`() = runTest { // Given - val limit = ASSET_SIZE_DEFAULT_LIMIT_BYTES val assetPath = "mocked-asset-data-path".toPath() val assetContent = "some-dummy-image".toByteArray() val assetName = "mocked_image.jpeg" val assetSize = 1L - val (arrangement, viewModel) = MessageComposerViewModelArrangement() + val mockedAttachment = AssetBundle( + "key", + "image/jpeg", assetPath, assetSize, assetName, AttachmentType.IMAGE + ) + val (arrangement, viewModel) = SendMessageViewModelArrangement() .withSuccessfulViewModelInit() .withStoredAsset(assetPath, assetContent) .withSuccessfulSendAttachmentMessage() - .withGetAssetSizeLimitUseCase(true, limit) + .withHandleUriAsset(HandleUriAssetUseCase.Result.Success(mockedAttachment)) .arrange() - val mockedAttachment = AssetBundle( - "image/jpeg", assetPath, assetSize, assetName, AttachmentType.IMAGE - ) // When viewModel.sendAttachment(mockedAttachment) @@ -129,7 +127,7 @@ class MessageComposerViewModelTest { fun `given the user picks a null attachment, when invoking sendAttachmentMessage, no use case gets called`() = runTest { // Given - val (arrangement, viewModel) = MessageComposerViewModelArrangement() + val (arrangement, viewModel) = SendMessageViewModelArrangement() .withSuccessfulViewModelInit() .withSuccessfulSendAttachmentMessage() .arrange() @@ -153,22 +151,22 @@ class MessageComposerViewModelTest { } @Test - fun `given a user picks an image asset larger than 15MB, when invoked, then sendAssetMessageUseCase isn't called`() = + fun `given a user picks an too large image asset, when invoked, then sendAssetMessageUseCase isn't called`() = runTest { // Given - val limit = ASSET_SIZE_DEFAULT_LIMIT_BYTES + val limit = 25 val mockedAttachment = AssetBundle( + "key", "image/jpeg", "some-data-path".toPath(), limit + 1L, "mocked_image.jpeg", AttachmentType.IMAGE ) - val (arrangement, viewModel) = MessageComposerViewModelArrangement() + val (arrangement, viewModel) = SendMessageViewModelArrangement() .withSuccessfulViewModelInit() .withSuccessfulSendAttachmentMessage() - .withGetAssetSizeLimitUseCase(true, limit) - .withGetAssetBundleFromUri(mockedAttachment) + .withHandleUriAsset(HandleUriAssetUseCase.Result.Failure.AssetTooLarge(mockedAttachment, 25)) .arrange() val mockedMessageBundle = ComposableMessageBundle.AttachmentPickedBundle( attachmentUri = UriAsset("mocked_image.jpeg".toUri(), false) @@ -197,19 +195,19 @@ class MessageComposerViewModelTest { fun `given that a free user picks an asset larger than 25MB, when invoked, then sendAssetMessageUseCase isn't called`() = runTest { // Given - val limit = ASSET_SIZE_DEFAULT_LIMIT_BYTES + val limit = 25 val mockedAttachment = AssetBundle( + "key", "file/x-zip", "some-data-path".toPath(), limit + 1L, "mocked_asset.zip", AttachmentType.GENERIC_FILE ) - val (arrangement, viewModel) = MessageComposerViewModelArrangement() + val (arrangement, viewModel) = SendMessageViewModelArrangement() .withSuccessfulViewModelInit() .withSuccessfulSendAttachmentMessage() - .withGetAssetSizeLimitUseCase(false, limit) - .withGetAssetBundleFromUri(mockedAttachment) + .withHandleUriAsset(HandleUriAssetUseCase.Result.Failure.AssetTooLarge(mockedAttachment, limit)) .arrange() val mockedMessageBundle = ComposableMessageBundle.AttachmentPickedBundle( attachmentUri = UriAsset("mocked_image.jpeg".toUri(), false) @@ -235,67 +233,13 @@ class MessageComposerViewModelTest { } @Test - fun `given that a user picks too large asset that needs saving if invalid, when invoked, then saveToExternalMediaStorage is called`() = + fun `given attachment picked and error when handling asset from uri, then show message to user`() = runTest { // Given - val limit = ASSET_SIZE_DEFAULT_LIMIT_BYTES - val mockedAttachment = AssetBundle( - "file/x-zip", - "some-data-path".toPath(), - limit + 1L, - "mocked_asset.zip", - AttachmentType.GENERIC_FILE - ) - val (arrangement, viewModel) = MessageComposerViewModelArrangement() + val (arrangement, viewModel) = SendMessageViewModelArrangement() .withSuccessfulViewModelInit() .withSuccessfulSendAttachmentMessage() - .withGetAssetSizeLimitUseCase(false, limit) - .withGetAssetBundleFromUri(mockedAttachment) - .withSaveToExternalMediaStorage("mocked_image.jpeg") - .arrange() - val mockedMessageBundle = ComposableMessageBundle.AttachmentPickedBundle( - attachmentUri = UriAsset("mocked_image.jpeg".toUri(), true) - ) - - // When - viewModel.trySendMessage(mockedMessageBundle) - - // Then - coVerify(inverse = true) { - arrangement.sendAssetMessage.invoke( - any(), - any(), - any(), - any(), - any(), - any(), - any(), - any() - ) - } - coVerify { - arrangement.fileManager.saveToExternalMediaStorage( - any(), - any(), - any(), - any(), - any() - ) - } - assert(viewModel.assetTooLargeDialogState is AssetTooLargeDialogState.Visible) - } - - @Test - fun `given attachment picked and null when getting asset bundle from uri, then show message to user`() = - runTest { - // Given - val limit = ASSET_SIZE_DEFAULT_LIMIT_BYTES - val (arrangement, viewModel) = MessageComposerViewModelArrangement() - .withSuccessfulViewModelInit() - .withSuccessfulSendAttachmentMessage() - .withGetAssetSizeLimitUseCase(false, limit) - .withGetAssetBundleFromUri(null) - .withSaveToExternalMediaStorage("mocked_image.jpeg") + .withHandleUriAsset(HandleUriAssetUseCase.Result.Failure.Unknown) .arrange() val mockedMessageBundle = ComposableMessageBundle.AttachmentPickedBundle( attachmentUri = UriAsset("mocked_image.jpeg".toUri(), false) @@ -322,48 +266,11 @@ class MessageComposerViewModelTest { } } - @Test - fun `given that a team user sends an asset message larger than 25MB, when invoked, then sendAssetMessageUseCase is called`() = - runTest { - // Given - val limit = ASSET_SIZE_DEFAULT_LIMIT_BYTES - val (arrangement, viewModel) = MessageComposerViewModelArrangement() - .withSuccessfulViewModelInit() - .withSuccessfulSendAttachmentMessage() - .withGetAssetSizeLimitUseCase(false, limit) - .arrange() - val mockedAttachment = AssetBundle( - "file/x-zip", - "some-data-path".toPath(), - limit + 1, - "mocked_asset.jpeg", - AttachmentType.GENERIC_FILE - ) - - // When - viewModel.sendAttachment(mockedAttachment) - - // Then - coVerify(exactly = 1) { - arrangement.sendAssetMessage.invoke( - any(), - any(), - any(), - any(), - any(), - any(), - any(), - any() - ) - } - assert(viewModel.assetTooLargeDialogState is AssetTooLargeDialogState.Hidden) - } - @Test fun `given that a user sends an ping message, when invoked, then sendKnockUseCase and pingRinger are called`() = runTest { // Given - val (arrangement, viewModel) = MessageComposerViewModelArrangement() + val (arrangement, viewModel) = SendMessageViewModelArrangement() .withSuccessfulViewModelInit() .arrange() @@ -377,73 +284,24 @@ class MessageComposerViewModelTest { verify(exactly = 1) { arrangement.pingRinger.ping(any(), isReceivingPing = false) } } - @Test - fun `given that a user updates the self-deleting message timer, when invoked, then the timer gets successfully updated`() = - runTest { - // Given - val expectedDuration = 1.toDuration(DurationUnit.HOURS) - val expectedTimer = SelfDeletionTimer.Enabled(expectedDuration) - val (arrangement, viewModel) = MessageComposerViewModelArrangement() - .withSuccessfulViewModelInit() - .withPersistSelfDeletionStatus() - .arrange() - - // When - viewModel.updateSelfDeletingMessages(expectedTimer) - - // Then - coVerify(exactly = 1) { - arrangement.persistSelfDeletionStatus.invoke( - arrangement.conversationId, - expectedTimer - ) - } - assertInstanceOf(SelfDeletionTimer.Enabled::class.java, viewModel.messageComposerViewState.value.selfDeletionTimer) - assertEquals(expectedDuration, viewModel.messageComposerViewState.value.selfDeletionTimer.duration) - } - - @Test - fun `given a valid observed enforced self-deleting message timer, when invoked, then the timer gets successfully updated`() = - runTest { - // Given - val expectedDuration = 1.toDuration(DurationUnit.DAYS) - val expectedTimer = SelfDeletionTimer.Enabled(expectedDuration) - val (arrangement, viewModel) = MessageComposerViewModelArrangement() - .withSuccessfulViewModelInit() - .withObserveSelfDeletingStatus(expectedTimer) - .arrange() - - // When - - // Then - coVerify(exactly = 1) { - arrangement.observeConversationSelfDeletionStatus.invoke( - arrangement.conversationId, - true - ) - } - assertInstanceOf(SelfDeletionTimer.Enabled::class.java, viewModel.messageComposerViewState.value.selfDeletionTimer) - assertEquals(expectedDuration, viewModel.messageComposerViewState.value.selfDeletionTimer.duration) - } - @Test fun `given the user sends an audio message, when invoked, then sendAssetMessageUseCase gets called`() = runTest { // Given - val limit = ASSET_SIZE_DEFAULT_LIMIT_BYTES val assetPath = "mocked-asset-data-path".toPath() val assetContent = "some-dummy-audio".toByteArray() val assetName = "mocked_audio.m4a" val assetSize = 1L - val (arrangement, viewModel) = MessageComposerViewModelArrangement() + val mockedAttachment = AssetBundle( + "key", + "audio/mp4", assetPath, assetSize, assetName, AttachmentType.AUDIO + ) + val (arrangement, viewModel) = SendMessageViewModelArrangement() .withSuccessfulViewModelInit() .withStoredAsset(assetPath, assetContent) .withSuccessfulSendAttachmentMessage() - .withGetAssetSizeLimitUseCase(false, limit) + .withHandleUriAsset(HandleUriAssetUseCase.Result.Success(mockedAttachment)) .arrange() - val mockedAttachment = AssetBundle( - "audio/mp4", assetPath, assetSize, assetName, AttachmentType.AUDIO - ) // When viewModel.sendAttachment(mockedAttachment) @@ -466,7 +324,7 @@ class MessageComposerViewModelTest { @Test fun `given that user sends a text message, when invoked, then send typing stopped event and remove draft are called`() = runTest { // given - val (arrangement, viewModel) = MessageComposerViewModelArrangement() + val (arrangement, viewModel) = SendMessageViewModelArrangement() .withSuccessfulViewModelInit() .withSuccessfulSendTextMessage() .arrange() @@ -498,7 +356,7 @@ class MessageComposerViewModelTest { fun `given that user sends an edited text message, when invoked, then send typing stopped event and remove draft are called`() = runTest { // given - val (arrangement, viewModel) = MessageComposerViewModelArrangement() + val (arrangement, viewModel) = SendMessageViewModelArrangement() .withSuccessfulViewModelInit() .withSuccessfulSendEditTextMessage() .arrange() @@ -533,31 +391,12 @@ class MessageComposerViewModelTest { } } - @Test - fun `given that user types a text message, when invoked typing invoked, then send typing event is called`() = runTest { - // given - val (arrangement, viewModel) = MessageComposerViewModelArrangement() - .withSuccessfulViewModelInit() - .arrange() - - // when - viewModel.sendTypingEvent(Conversation.TypingIndicatorMode.STARTED) - - // then - coVerify(exactly = 1) { - arrangement.sendTypingEvent.invoke( - any(), - eq(Conversation.TypingIndicatorMode.STARTED) - ) - } - } - @Test fun `given that user need to be informed about verification, when invoked sending, then message is not sent and dialog shown`() = runTest { // given val messageBundle = ComposableMessageBundle.SendTextMessageBundle("mocked-text-message", emptyList()) - val (arrangement, viewModel) = MessageComposerViewModelArrangement() + val (arrangement, viewModel) = SendMessageViewModelArrangement() .withSuccessfulViewModelInit() .withInformAboutVerificationBeforeMessagingFlag(false) .arrange() @@ -585,7 +424,7 @@ class MessageComposerViewModelTest { runTest { // given val messageBundle = ComposableMessageBundle.SendTextMessageBundle("mocked-text-message", emptyList()) - val (arrangement, viewModel) = MessageComposerViewModelArrangement() + val (arrangement, viewModel) = SendMessageViewModelArrangement() .withSuccessfulViewModelInit() .withObserveConversationUnderLegalHoldNotified(false) .arrange() @@ -604,7 +443,7 @@ class MessageComposerViewModelTest { runTest { // given val messageBundle = ComposableMessageBundle.SendTextMessageBundle("mocked-text-message", emptyList()) - val (arrangement, viewModel) = MessageComposerViewModelArrangement() + val (arrangement, viewModel) = SendMessageViewModelArrangement() .withSuccessfulViewModelInit() .withObserveConversationUnderLegalHoldNotified(false) .arrange() @@ -623,7 +462,7 @@ class MessageComposerViewModelTest { runTest { // given val messageBundle = ComposableMessageBundle.SendTextMessageBundle("mocked-text-message", emptyList()) - val (arrangement, viewModel) = MessageComposerViewModelArrangement() + val (arrangement, viewModel) = SendMessageViewModelArrangement() .withSuccessfulViewModelInit() .withObserveConversationUnderLegalHoldNotified(false) .withSuccessfulSendTextMessage() @@ -644,7 +483,7 @@ class MessageComposerViewModelTest { // given val messageBundle = ComposableMessageBundle.SendTextMessageBundle("mocked-text-message", emptyList()) val messageId = "messageId" - val (arrangement, viewModel) = MessageComposerViewModelArrangement() + val (arrangement, viewModel) = SendMessageViewModelArrangement() .withSuccessfulViewModelInit() .withFailedSendTextMessage(LegalHoldEnabledForConversationFailure(messageId)) .arrange() @@ -664,7 +503,7 @@ class MessageComposerViewModelTest { // given val messageBundle = ComposableMessageBundle.SendTextMessageBundle("mocked-text-message", emptyList()) val messageId = "messageId" - val (arrangement, viewModel) = MessageComposerViewModelArrangement() + val (arrangement, viewModel) = SendMessageViewModelArrangement() .withSuccessfulViewModelInit() .withObserveConversationUnderLegalHoldNotified(true) .withFailedSendTextMessage(LegalHoldEnabledForConversationFailure(messageId)) @@ -684,7 +523,7 @@ class MessageComposerViewModelTest { // given val messageBundle = ComposableMessageBundle.SendTextMessageBundle("mocked-text-message", emptyList()) val messageId = "messageId" - val (arrangement, viewModel) = MessageComposerViewModelArrangement() + val (arrangement, viewModel) = SendMessageViewModelArrangement() .withSuccessfulViewModelInit() .withFailedSendTextMessage(LegalHoldEnabledForConversationFailure(messageId)) .withSuccessfulRetryFailedMessage() @@ -706,7 +545,7 @@ class MessageComposerViewModelTest { "mocked-location-message", Location("mocked-provider") ) - val (arrangement, viewModel) = MessageComposerViewModelArrangement() + val (arrangement, viewModel) = SendMessageViewModelArrangement() .withSuccessfulViewModelInit() .withSuccessfulSendLocationMessage() .arrange() @@ -718,27 +557,4 @@ class MessageComposerViewModelTest { coVerify(exactly = 1) { arrangement.sendLocation.invoke(any(), any(), any(), any(), any()) } assertEquals(SureAboutMessagingDialogState.Hidden, viewModel.sureAboutMessagingDialogState) } - - @Test - fun `given that user saves a draft message, then save draft use case is triggered`() = - runTest { - // given - val messageDraft = MessageDraft( - text = "hello", - editMessageId = null, - quotedMessageId = null, - selectedMentionList = listOf() - ) - val (arrangement, viewModel) = MessageComposerViewModelArrangement() - .withSuccessfulViewModelInit() - .withSaveDraftMessage() - .arrange() - - // when - viewModel.saveDraft(messageDraft) - advanceUntilIdle() - - // then - coVerify(exactly = 1) { arrangement.saveMessageDraftUseCase.invoke(eq(viewModel.conversationId), eq(messageDraft)) } - } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/HandleUriAssetUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/HandleUriAssetUseCaseTest.kt new file mode 100644 index 00000000000..5230823809b --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/HandleUriAssetUseCaseTest.kt @@ -0,0 +1,180 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.usecase + +import androidx.core.net.toUri +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.TestDispatcherProvider +import com.wire.android.framework.FakeKaliumFileSystem +import com.wire.android.ui.home.conversations.model.AssetBundle +import com.wire.android.util.FileManager +import com.wire.kalium.logic.data.asset.AttachmentType +import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCase +import com.wire.kalium.logic.feature.asset.GetAssetSizeLimitUseCaseImpl +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toPath +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutineTestExtension::class) +class HandleUriAssetUseCaseTest { + + @Test + fun `given a user picks an image asset less than limit, when invoked, then result should succeed`() = + runTest { + // Given + val limit = GetAssetSizeLimitUseCaseImpl.ASSET_SIZE_DEFAULT_LIMIT_BYTES + val mockedAttachment = AssetBundle( + "key", + "image/jpeg", + "some-data-path".toPath(), + limit - 1L, + "mocked_image.jpeg", + AttachmentType.IMAGE + ) + val (_, useCase) = Arrangement() + .withGetAssetSizeLimitUseCase(true, limit) + .withGetAssetBundleFromUri(mockedAttachment) + .arrange() + + // When + val result = useCase.invoke("mocked_image.jpeg".toUri(), false) + + // Then + assert(result is HandleUriAssetUseCase.Result.Success) + } + + @Test + fun `given a user picks an image asset larger than limit, when invoked, then result is asset too large failure`() = + runTest { + // Given + val limit = GetAssetSizeLimitUseCaseImpl.ASSET_SIZE_DEFAULT_LIMIT_BYTES + val mockedAttachment = AssetBundle( + "key", + "image/jpeg", + "some-data-path".toPath(), + limit + 1L, + "mocked_image.jpeg", + AttachmentType.IMAGE + ) + val (_, useCase) = Arrangement() + .withGetAssetSizeLimitUseCase(true, limit) + .withGetAssetBundleFromUri(mockedAttachment) + .arrange() + + // When + val result = useCase.invoke("mocked_image.jpeg".toUri(), false) + + // Then + assert(result is HandleUriAssetUseCase.Result.Failure.AssetTooLarge) + } + + @Test + fun `given that a user picks too large asset that needs saving if invalid, when invoked, then saveToExternalMediaStorage is called`() = + runTest { + // Given + val limit = GetAssetSizeLimitUseCaseImpl.ASSET_SIZE_DEFAULT_LIMIT_BYTES + val mockedAttachment = AssetBundle( + "key", + "file/x-zip", + "some-data-path".toPath(), + limit + 1L, + "mocked_asset.zip", + AttachmentType.GENERIC_FILE + ) + val (arrangement, useCase) = Arrangement() + .withGetAssetBundleFromUri(mockedAttachment) + .withGetAssetSizeLimitUseCase(false, limit) + .withSaveToExternalMediaStorage("mocked_image.jpeg") + .arrange() + + // When + val result = useCase.invoke("mocked_image.jpeg".toUri(), true) + + // Then + coVerify { + arrangement.fileManager.saveToExternalMediaStorage( + any(), + any(), + any(), + any(), + any() + ) + } + assert(result is HandleUriAssetUseCase.Result.Failure.AssetTooLarge) + } + + @Test + fun `given that a user picks asset, when getting uri returns null, then it should return error`() = + runTest { + // Given + val limit = GetAssetSizeLimitUseCaseImpl.ASSET_SIZE_DEFAULT_LIMIT_BYTES + val (_, useCase) = Arrangement() + .withGetAssetBundleFromUri(null) + .withGetAssetSizeLimitUseCase(false, limit) + .withSaveToExternalMediaStorage("mocked_image.jpeg") + .arrange() + + // When + val result = useCase.invoke("mocked_image.jpeg".toUri(), false) + + // Then + assert(result is HandleUriAssetUseCase.Result.Failure.Unknown) + } + + private class Arrangement { + + @MockK + lateinit var getAssetSizeLimitUseCase: GetAssetSizeLimitUseCase + + @MockK + lateinit var fileManager: FileManager + + private val fakeKaliumFileSystem = FakeKaliumFileSystem() + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + } + + fun withGetAssetBundleFromUri(assetBundle: AssetBundle?) = apply { + coEvery { fileManager.getAssetBundleFromUri(any(), any(), any(), any()) } returns assetBundle + } + + fun withGetAssetSizeLimitUseCase(isImage: Boolean, assetSizeLimit: Long) = apply { + coEvery { getAssetSizeLimitUseCase(eq(isImage)) } returns assetSizeLimit + return this + } + + fun withSaveToExternalMediaStorage(resultFileName: String?) = apply { + coEvery { fileManager.saveToExternalMediaStorage(any(), any(), any(), any(), any()) } returns resultFileName + } + + fun arrange() = this to HandleUriAssetUseCase( + getAssetSizeLimitUseCase, + fileManager, + fakeKaliumFileSystem, + TestDispatcherProvider(), + ) + } +}