diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApi.java b/app/src/main/java/com/nextcloud/talk/api/NcApi.java index 8834665309..054dd7e8a5 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApi.java +++ b/app/src/main/java/com/nextcloud/talk/api/NcApi.java @@ -413,7 +413,8 @@ Observable createRemoteShare(@Nullable @Header("Authorization") @Field("path") String remotePath, @Field("shareWith") String roomToken, @Field("shareType") String shareType, - @Field("talkMetaData") String talkMetaData); + @Field("talkMetaData") String talkMetaData, + @Field("referenceId") String referenceId); @FormUrlEncoded @PUT diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 4cf4bf3452..26dd5ae921 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -158,6 +158,7 @@ import com.nextcloud.talk.ui.chat.ChatMessageCallbacks import com.nextcloud.talk.ui.chat.ChatView import com.nextcloud.talk.ui.chat.ChatViewCallbacks import com.nextcloud.talk.ui.chat.ChatViewState +import com.nextcloud.talk.ui.chat.LocalUploadProgressProvider import com.nextcloud.talk.ui.dialog.DateTimeCompose import com.nextcloud.talk.ui.dialog.FileAttachmentPreviewFragment import com.nextcloud.talk.ui.dialog.GetPinnedOptionsDialog @@ -783,10 +784,13 @@ class ChatActivity : SideEffect { chatListState = listState } + val uploadProgressMap by chatViewModel.uploadProgressMap.collectAsStateWithLifecycle() + CompositionLocalProvider( LocalViewThemeUtils provides viewThemeUtils, LocalMessageUtils provides messageUtils, - LocalOpenGraphFetcher provides { url -> chatViewModel.fetchOpenGraph(url) } + LocalOpenGraphFetcher provides { url -> chatViewModel.fetchOpenGraph(url) }, + LocalUploadProgressProvider provides { refId -> uploadProgressMap[refId] } ) { val currentlyPlayingId by chatViewModel.currentlyPlayedMessageId.collectAsState(null) @@ -842,7 +846,8 @@ class ChatActivity : onSystemMessageExpandClick = { messageId -> chatViewModel.toggleSystemMessageCollapse(messageId) }, - onAvatarClick = { messageId -> chatViewModel.showProfileSheet(messageId.toLong()) } + onAvatarClick = { messageId -> chatViewModel.showProfileSheet(messageId.toLong()) }, + onCancelUpload = { referenceId -> chatViewModel.cancelUpload(referenceId) } ) ), listState = listState diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt b/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt index 9a55fac9bd..dc5074e441 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt @@ -132,6 +132,17 @@ interface ChatMessageRepository : LifecycleAwareManager { referenceId: String ): Flow> + @Suppress("LongParameterList") + suspend fun addUploadPlaceholderMessage( + localFileUri: String, + caption: String, + mimeType: String?, + fileSize: Long, + referenceId: String + ): Flow> + + suspend fun deleteTempMessageByReferenceId(referenceId: String) + suspend fun editChatMessage(credentials: String, url: String, text: String): Flow> suspend fun editTempChatMessage(message: ChatMessage, editedMessageText: String): Flow diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt index ee584249f1..0ee0cc2828 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt @@ -956,6 +956,80 @@ class OfflineFirstChatRepository @Inject constructor( } } + @Suppress("Detekt.TooGenericExceptionCaught", "LongMethod") + override suspend fun addUploadPlaceholderMessage( + localFileUri: String, + caption: String, + mimeType: String?, + fileSize: Long, + referenceId: String + ): Flow> = + flow { + try { + val currentTimeMillis = System.currentTimeMillis() + + // Use the first 15 hex chars so the value always fits in a signed Long. + // Use referenceId.hashCode() as the placeholder id so that: + // 1. It is unique per file even when multiple files are selected simultaneously + // 2. It fits in an Int, so it survives the Long→Int cast in ChatMessageUi.id without + // truncation, keeping DB lookups consistent when the message is tapped. + // 3. It is always positive, because getMessagesEqualOrNewerThan expects it to be larger + // than oldestMessageId + @Suppress("MagicNumber") + val placeholderId = (referenceId.hashCode().toLong() and 0x7FFF_FFFFL) + + Log.d( + TAG, + "addUploadPlaceholderMessage: referenceId=$referenceId " + + "placeholderId=$placeholderId caption=$caption" + ) + + val fileParams = hashMapOf( + "type" to "file", + "name" to caption, + "mimetype" to (mimeType ?: ""), + "size" to fileSize.toString(), + "path" to localFileUri + ) + val messageParameters = hashMapOf>( + "file" to fileParams + ) + + val entity = ChatMessageEntity( + internalId = "$internalConversationId@_temp_$referenceId", + internalConversationId = internalConversationId, + id = placeholderId, + threadId = threadId, + message = "{file}", + deleted = false, + token = conversationModel.token, + actorId = currentUser.userId!!, + actorType = EnumActorTypeConverter().convertToString(Participant.ActorType.USERS), + accountId = currentUser.id!!, + messageParameters = messageParameters, + messageType = "comment", + parentMessageId = null, + systemMessageType = ChatMessage.SystemMessageType.DUMMY, + replyable = false, + timestamp = currentTimeMillis / MILLIES, + expirationTimestamp = 0, + actorDisplayName = currentUser.displayName!!, + referenceId = referenceId, + isTemporary = true, + sendStatus = SendStatus.PENDING, + silent = false + ) + chatDao.upsertChatMessage(entity) + } catch (e: Exception) { + Log.e(TAG, "addUploadPlaceholderMessage failed for referenceId=$referenceId", e) + emit(Result.failure(e)) + } + } + + override suspend fun deleteTempMessageByReferenceId(referenceId: String) { + chatDao.deleteTempChatMessages(internalConversationId, listOf(referenceId)) + } + @Suppress("Detekt.TooGenericExceptionCaught") override suspend fun editChatMessage( credentials: String, diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt index ea5f4ea6c8..90e9ccdb43 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt @@ -56,7 +56,8 @@ data class ChatMessageUi( val isExpandableParent: Boolean = false, val expandableChildrenAmount: Int = 0, val isHiddenByCollapse: Boolean = false, - val isExpanded: Boolean = false + val isExpanded: Boolean = false, + val referenceId: String? = null ) data class MessageReactionUi(val emoji: String, val amount: Int, val isSelfReaction: Boolean) @@ -77,6 +78,13 @@ sealed interface MessageTypeContent { val height: Int? = null ) : MessageTypeContent + data class UploadingMedia( + val localFileUri: String, + val caption: String, + val mimeType: String?, + val drawableResourceId: Int + ) : MessageTypeContent + data class Geolocation(val id: String, val name: String, val lat: Double, val lon: Double) : MessageTypeContent data class Poll(val pollId: String, val pollName: String) : MessageTypeContent @@ -153,7 +161,8 @@ fun ChatMessage.toUiModel( isSilent = silent, isExpandableParent = expandableParent, expandableChildrenAmount = expandableChildrenAmount, - isHiddenByCollapse = hiddenByCollapse + isHiddenByCollapse = hiddenByCollapse, + referenceId = referenceId ) fun ChatMessage.toScheduledMessageUiModel( @@ -249,6 +258,8 @@ fun getMessageTypeContent(user: User, message: ChatMessage): MessageTypeContent? MessageTypeContent.SystemMessage } else if (message.isVoiceMessage) { getVoiceContent(message) + } else if (message.hasFileAttachment && message.isTemporary) { + getUploadingMediaContent(message) } else if (message.hasFileAttachment) { getMediaContent(user, message) } else if (message.hasGeoLocation) { @@ -263,6 +274,17 @@ fun getMessageTypeContent(user: User, message: ChatMessage): MessageTypeContent? ?: MessageTypeContent.RegularText } +fun getUploadingMediaContent(message: ChatMessage): MessageTypeContent.UploadingMedia { + val mimetype = message.fileParameters.mimetype + val drawableResourceId = DrawableUtils.getDrawableResourceIdForMimeType(mimetype) + return MessageTypeContent.UploadingMedia( + localFileUri = message.fileParameters.path.orEmpty(), + caption = message.fileParameters.name.orEmpty(), + mimeType = mimetype.takeIf { !it.isNullOrEmpty() }, + drawableResourceId = drawableResourceId + ) +} + fun getMediaContent(user: User, message: ChatMessage): MessageTypeContent.Media { val mimetype = message.fileParameters.mimetype val drawableResourceId = DrawableUtils.getDrawableResourceIdForMimeType(mimetype) diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 0827e467e0..f39de56f33 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -10,6 +10,7 @@ package com.nextcloud.talk.chat.viewmodels import android.content.Context import android.net.Uri import android.os.Bundle +import android.provider.OpenableColumns import android.util.Log import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -40,6 +41,8 @@ import com.nextcloud.talk.data.database.model.ChatMessageEntity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.extensions.toIntOrZero import com.nextcloud.talk.jobs.ShareOperationWorker +import androidx.lifecycle.asFlow +import androidx.work.WorkManager import com.nextcloud.talk.jobs.UploadAndShareFilesWorker import com.nextcloud.talk.messagesearch.MessageSearchHelper import com.nextcloud.talk.models.MessageDraft @@ -118,7 +121,9 @@ import java.io.IOException import java.time.Instant import java.time.LocalDate import java.time.ZoneId +import java.util.UUID import javax.inject.Inject +import androidx.core.net.toUri @Suppress("TooManyFunctions", "LongParameterList") class ChatViewModel @AssistedInject constructor( @@ -211,6 +216,21 @@ class ChatViewModel @AssistedInject constructor( private var lobbyPollingJob: Job? = null + private val _uploadProgressMap = MutableStateFlow>(emptyMap()) + val uploadProgressMap: StateFlow> = _uploadProgressMap + + // Maps referenceId -> fileUri for cancellation support + private val uploadReferenceToUri = mutableMapOf() + + fun cancelUpload(referenceId: String) { + val fileUri = uploadReferenceToUri.remove(referenceId) ?: return + WorkManager.getInstance(NextcloudTalkApplication.sharedApplication!!).cancelUniqueWork(fileUri) + viewModelScope.launch { + chatRepository.deleteTempMessageByReferenceId(referenceId) + } + _uploadProgressMap.update { it - referenceId } + } + fun getChatRepository(): ChatMessageRepository = chatRepository override fun onResume(owner: LifecycleOwner) { @@ -1934,23 +1954,82 @@ class ChatViewModel @AssistedInject constructor( metaDataMap["caption"] = caption } + val referenceId = UUID.randomUUID().toString().replace("-", "") + metaDataMap["referenceId"] = referenceId + val metaData = Gson().toJson(metaDataMap) room = if (roomToken == "") chatRoomToken else roomToken try { require(fileUri.isNotEmpty()) - UploadAndShareFilesWorker.upload( + + if (!isVoiceMessage) { + val (fileName, mimeType, fileSize) = resolveFileInfo(fileUri) + viewModelScope.launch { + chatRepository.addUploadPlaceholderMessage( + localFileUri = fileUri, + caption = caption.ifEmpty { fileName }, + mimeType = mimeType, + fileSize = fileSize, + referenceId = referenceId + ).collect {} + } + } + + val internalConversationId = "${currentUser.id}@$chatRoomToken" + val workerId = UploadAndShareFilesWorker.upload( fileUri, room, displayName, - metaData + metaData, + referenceId, + internalConversationId ) + + if (!isVoiceMessage) { + uploadReferenceToUri[referenceId] = fileUri + observeUploadProgress(workerId, referenceId) + } } catch (e: IllegalArgumentException) { Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) } } + private fun resolveFileInfo(fileUri: String): Triple { + val uri = fileUri.toUri() + val mimeType = NextcloudTalkApplication.sharedApplication!!.contentResolver.getType(uri) + val cursor = NextcloudTalkApplication.sharedApplication!!.contentResolver.query(uri, null, null, null, null) + cursor?.use { + val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) + if (it.moveToFirst()) { + val name = if (nameIndex >= 0) it.getString(nameIndex).orEmpty() else uri.lastPathSegment.orEmpty() + val size = if (sizeIndex >= 0) it.getLong(sizeIndex) else 0L + return Triple(name, mimeType, size) + } + } + return Triple(uri.lastPathSegment.orEmpty(), mimeType, 0L) + } + + private fun observeUploadProgress(workerId: UUID, referenceId: String) { + WorkManager.getInstance(NextcloudTalkApplication.sharedApplication!!) + .getWorkInfoByIdLiveData(workerId) + .asFlow() + .onEach { workInfo -> + if (workInfo == null) return@onEach + val progress = workInfo.progress.getInt(UploadAndShareFilesWorker.PROGRESS_KEY, -1) + if (progress >= 0) { + _uploadProgressMap.update { it + (referenceId to progress) } + } + if (workInfo.state.isFinished) { + _uploadProgressMap.update { it - referenceId } + uploadReferenceToUri.remove(referenceId) + } + } + .launchIn(viewModelScope) + } + fun postToRecordTouchObserver(float: Float) { _recordTouchObserver.postValue(float) } diff --git a/app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.kt index c7f473e41e..447af56a19 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.kt @@ -66,7 +66,8 @@ class ShareOperationWorker(context: Context, workerParams: WorkerParameters) : W filePath, roomToken, "10", - metaData + metaData, + "" // no reference id ) .subscribeOn(Schedulers.io()) .blockingSubscribe( diff --git a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt index e84edc0537..0b8b92acfb 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -8,6 +8,7 @@ package com.nextcloud.talk.jobs import android.Manifest +import android.annotation.SuppressLint import android.app.Activity import android.app.NotificationManager import android.app.PendingIntent @@ -32,6 +33,8 @@ import com.nextcloud.talk.activities.MainActivity import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.data.database.dao.ChatMessagesDao +import com.nextcloud.talk.data.database.model.SendStatus import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.chatpostattachment.PostConversationAttachmentRequest import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ChatProbeAttachmentData @@ -54,6 +57,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.runBlocking import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import java.io.File @@ -86,6 +91,9 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa @Inject lateinit var platformPermissionUtil: PlatformPermissionUtil + @Inject + lateinit var chatDao: ChatMessagesDao + lateinit var fileName: String private var mNotifyManager: NotificationManager? = null @@ -98,6 +106,8 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa private var isChunkedUploading = false private var file: File? = null private var chunkedFileUploader: ChunkedFileUploader? = null + private var referenceId: String? = null + private var internalConversationId: String? = null @Suppress("Detekt.TooGenericExceptionCaught") override fun doWork(): Result { @@ -109,6 +119,8 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa roomToken = inputData.getString(ROOM_TOKEN)!! conversationName = inputData.getString(CONVERSATION_NAME)!! val metaData = inputData.getString(META_DATA) + referenceId = inputData.getString(KEY_REFERENCE_ID) + internalConversationId = inputData.getString(KEY_INTERNAL_CONVERSATION_ID) checkNotNull(currentUser) checkNotNull(sourceFile) @@ -125,12 +137,25 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa ) initNotificationSetup() file?.let { isChunkedUploading = it.length() > CHUNK_UPLOAD_THRESHOLD_SIZE } - val uploadSuccess: Boolean = uploadFile(sourceFileUri, metaData, remotePath, useConversationSubfolders) + val uploadSuccess: Boolean = uploadFile( + sourceFileUri = sourceFileUri, + metaData = metaData, + remotePath = remotePath, + useConversationSubfolders = useConversationSubfolders + ) if (uploadSuccess) { + val shareSuccess = shareFile(remotePath, metaData) cancelNotification() - _uploadCompletedFlow.tryEmit(roomToken) - return Result.success() + if (shareSuccess) { + updatePlaceholderStatus(SendStatus.SENT_PENDING_ACK) + // _uploadCompletedFlow.tryEmit(roomToken) <- Check if this still makes sense! + return Result.success() + } + Log.e(TAG, "Share operation failed after upload") + showFailedToUploadNotification() + updatePlaceholderStatus(SendStatus.FAILED) + return Result.failure() } else if (isStopped) { // since work is cancelled the result would be ignored anyways return Result.failure() @@ -138,10 +163,12 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa Log.e(TAG, "Something went wrong when trying to upload file") showFailedToUploadNotification() + updatePlaceholderStatus(SendStatus.FAILED) return Result.failure() } catch (e: Exception) { Log.e(TAG, "Something went wrong when trying to upload file", e) showFailedToUploadNotification() + updatePlaceholderStatus(SendStatus.FAILED) return Result.failure() } } @@ -164,7 +191,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa okHttpClient, currentUser, roomToken, - metaData, + null, this, ncApiCoroutines, useConversationSubfolders @@ -181,7 +208,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa file!!, ncApiCoroutines ) - .upload(sourceFileUri, fileName, remotePath, metaData) + .upload(sourceFileUri, fileName, remotePath, null) .blockingFirst() } @@ -250,6 +277,24 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa .isSuccess } + @SuppressLint("CheckResult") + private fun shareFile(remotePath: String, metaData: String?): Boolean = + try { + ncApi.createRemoteShare( + ApiUtils.getCredentials(currentUser.username, currentUser.token), + ApiUtils.getSharingUrl(currentUser.baseUrl!!), + remotePath, + roomToken, + "10", + metaData, + referenceId.orEmpty() + ).blockingFirst() + true + } catch (e: NoSuchElementException) { + Log.e(TAG, "Failed to share file to room", e) + false + } + private fun resolveFinalFileName(originalName: String, probeData: ChatProbeAttachmentData): String = probeData.renames?.get(originalName) ?: originalName @@ -261,6 +306,8 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } override fun onTransferProgress(percentage: Int) { + setProgressAsync(Data.Builder().putInt(PROGRESS_KEY, percentage).build()) + val progressUpdateNotification = mBuilder!! .setProgress(HUNDRED_PERCENT, percentage, false) .setContentText(getNotificationContentText(percentage)) @@ -269,6 +316,13 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa mNotifyManager!!.notify(notificationId, progressUpdateNotification) } + private fun updatePlaceholderStatus(status: SendStatus) { + val refId = referenceId ?: return + val convId = internalConversationId ?: return + val entity = runBlocking { chatDao.getTempMessageForConversation(convId, refId, null).firstOrNull() } + entity?.let { chatDao.updateChatMessage(it.copy(sendStatus = status)) } + } + override fun onStopped() { if (file != null && isChunkedUploading) { chunkedFileUploader?.abortUpload { @@ -413,6 +467,9 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa private const val ROOM_TOKEN = "ROOM_TOKEN" private const val CONVERSATION_NAME = "CONVERSATION_NAME" private const val META_DATA = "META_DATA" + const val KEY_REFERENCE_ID = "REFERENCE_ID" + const val KEY_INTERNAL_CONVERSATION_ID = "INTERNAL_CONVERSATION_ID" + const val PROGRESS_KEY = "UPLOAD_PROGRESS" private const val CHUNK_UPLOAD_THRESHOLD_SIZE: Long = 1024 * 1024 private const val NOTIFICATION_FILE_NAME_MAX_LENGTH = 20 private const val THREE_DOTS = "…" @@ -465,17 +522,28 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } } - fun upload(fileUri: String, roomToken: String, conversationName: String, metaData: String?) { + @Suppress("LongParameterList") + fun upload( + fileUri: String, + roomToken: String, + conversationName: String, + metaData: String?, + referenceId: String = "", + internalConversationId: String = "" + ): UUID { val data: Data = Data.Builder() .putString(DEVICE_SOURCE_FILE, fileUri) .putString(ROOM_TOKEN, roomToken) .putString(CONVERSATION_NAME, conversationName) .putString(META_DATA, metaData) + .putString(KEY_REFERENCE_ID, referenceId) + .putString(KEY_INTERNAL_CONVERSATION_ID, internalConversationId) .build() val uploadWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(UploadAndShareFilesWorker::class.java) .setInputData(data) .build() WorkManager.getInstance().enqueueUniqueWork(fileUri, ExistingWorkPolicy.KEEP, uploadWorker) + return uploadWorker.id } } } diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt index f439969e9d..45ab01fe6c 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt @@ -73,7 +73,8 @@ data class ChatMessageCallbacks( val onOpenThreadClick: (Int) -> Unit = {}, val onQuotedMessageClick: (Int) -> Unit = {}, val onSystemMessageExpandClick: (Int) -> Unit = {}, - val onAvatarClick: (Int) -> Unit = {} + val onAvatarClick: (Int) -> Unit = {}, + val onCancelUpload: (String) -> Unit = {} ) @Suppress("Detekt.LongParameterList", "Detekt.LongMethod", "Detekt.CyclomaticComplexMethod") @@ -208,9 +209,18 @@ fun ChatMessageView( ) } - else -> { - Log.d("ChatView", "Unknown message type: ${'$'}content") - } + is MessageTypeContent.UploadingMedia -> { + UploadingMediaMessage( + typeContent = content, + message = message, + isOneToOneConversation = context.isOneToOneConversation, + conversationThreadId = context.conversationThreadId, + onCancelUpload = callbacks.onCancelUpload + ) + } + + else -> { + Log.d("ChatView", "Unknown message type: ${'$'}content")} } } val useContainerHighlight = highlightSearchTerm.isNullOrBlank() || isSelected diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt index bc57a4a163..07e191dfa1 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -428,7 +428,8 @@ fun ChatView( onOpenThreadClick = callbacks.messageCallbacks.onOpenThreadClick, onQuotedMessageClick = handleQuotedMessageClick, onSystemMessageExpandClick = callbacks.messageCallbacks.onSystemMessageExpandClick, - onAvatarClick = callbacks.messageCallbacks.onAvatarClick + onAvatarClick = callbacks.messageCallbacks.onAvatarClick, + onCancelUpload = callbacks.messageCallbacks.onCancelUpload ) ) } diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt index 6fa5e9d5f7..4b28597c37 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt @@ -17,11 +17,18 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -35,6 +42,8 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.draw.blur import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.network.HttpException @@ -42,12 +51,16 @@ import com.nextcloud.talk.R import com.nextcloud.talk.chat.data.model.FileParameters import com.nextcloud.talk.chat.data.model.decodeBlurhashPlaceholder import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.MessageStatusIcon import com.nextcloud.talk.chat.ui.model.MessageTypeContent import com.nextcloud.talk.contacts.load import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.MimetypeUtils import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import androidx.core.net.toUri + +val LocalUploadProgressProvider = compositionLocalOf<(referenceId: String) -> Int?> { { null } } private const val FILE_PLACEHOLDER_MESSAGE = "{file}" private const val PREVIEW_MAX_RETRIES = 3 @@ -80,21 +93,7 @@ fun MediaMessage( val hasCaption = captionText != null val mediaInset = 4.dp val mediaShape = remember(message.incoming) { - if (message.incoming) { - RoundedCornerShape( - topStart = mediaRadiusSmall, - topEnd = mediaRadiusBig, - bottomEnd = mediaRadiusBig, - bottomStart = mediaRadiusBig - ) - } else { - RoundedCornerShape( - topStart = mediaRadiusBig, - topEnd = mediaRadiusSmall, - bottomEnd = mediaRadiusBig, - bottomStart = mediaRadiusBig - ) - } + shape(message.incoming) } MessageScaffold( @@ -216,3 +215,212 @@ fun MediaMessage( } ) } + +@Suppress("Detekt.LongMethod") +@Composable +fun UploadingMediaMessage( + typeContent: MessageTypeContent.UploadingMedia, + message: ChatMessageUi, + isOneToOneConversation: Boolean = false, + conversationThreadId: Long? = null, + onCancelUpload: (referenceId: String) -> Unit = {} +) { + val getProgress = LocalUploadProgressProvider.current + val progress = getProgress(message.referenceId.orEmpty()) + val isFailed = message.statusIcon == MessageStatusIcon.FAILED + val isSent = message.statusIcon == MessageStatusIcon.SENT + + val mediaInset = 4.dp + val mediaShape = remember(message.incoming) { + shape(message.incoming) + } + + MessageScaffold( + uiMessage = message, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId, + includePadding = false, + captionText = typeContent.caption, + content = { + Column(modifier = Modifier.fillMaxWidth()) { + Box(modifier = Modifier.fillMaxWidth()) { + val isImage = typeContent.mimeType?.startsWith("image") == true + if (isImage && typeContent.localFileUri.isNotEmpty()) { + AsyncImage( + model = typeContent.localFileUri.toUri(), + contentDescription = typeContent.caption, + modifier = Modifier + .fillMaxWidth() + .blur(4.dp) + .padding(mediaInset) + .clip(mediaShape), + contentScale = ContentScale.FillWidth + ) + } else { + Icon( + painter = painterResource(typeContent.drawableResourceId), + contentDescription = typeContent.caption, + modifier = Modifier + .size(64.dp) + .padding(mediaInset) + .align(Alignment.Center), + tint = Color.Unspecified + ) + } + + if (isSent) { + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .size(24.dp) + ) + } else if (!isFailed) { + IconButton( + onClick = { onCancelUpload(message.referenceId.orEmpty()) }, + modifier = Modifier.align(Alignment.TopEnd) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.nc_cancel), + tint = Color.White + ) + } + } + } + + if (isFailed) { + Text( + text = stringResource(R.string.nc_upload_failed_notification_title), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + color = androidx.compose.ui.graphics.Color.Red + ) + } else if (!isSent) { + if (progress != null) { + LinearProgressIndicator( + progress = { progress / 100f }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + } else { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + } + } + ) +} + +fun shape(incoming: Boolean): RoundedCornerShape = + if (incoming) { + RoundedCornerShape( + topStart = mediaRadiusSmall, + topEnd = mediaRadiusBig, + bottomEnd = mediaRadiusBig, + bottomStart = mediaRadiusBig + ) + } else { + RoundedCornerShape( + topStart = mediaRadiusBig, + topEnd = mediaRadiusSmall, + bottomEnd = mediaRadiusBig, + bottomStart = mediaRadiusBig + ) + } + +private fun previewUploadingContent(mimeType: String? = "image/jpeg") = + MessageTypeContent.UploadingMedia( + localFileUri = "", + caption = "photo.jpg", + mimeType = mimeType, + drawableResourceId = R.drawable.ic_mimetype_image + ) + +private fun previewUploadingMessage(statusIcon: MessageStatusIcon = MessageStatusIcon.SENDING) = + ChatMessageUi( + id = 0, + message = "{file}", + plainMessage = "photo.jpg", + renderMarkdown = false, + actorDisplayName = "Jane Doe", + isThread = false, + threadTitle = "", + threadReplies = 0, + incoming = false, + isDeleted = false, + avatarUrl = null, + statusIcon = statusIcon, + timestamp = System.currentTimeMillis() / 1000, + date = java.time.LocalDate.now(), + content = previewUploadingContent(), + reactions = emptyList(), + referenceId = "preview-ref-id" + ) + +@Suppress("MagicNumber") +@ChatMessagePreviews +@Composable +private fun UploadingMediaMessageProgressPreview() { + PreviewContainer { + CompositionLocalProvider(LocalUploadProgressProvider provides { 42 }) { + UploadingMediaMessage( + typeContent = previewUploadingContent(), + message = previewUploadingMessage() + ) + } + } +} + +@ChatMessagePreviews +@Composable +private fun UploadingMediaMessageIndeterminatePreview() { + PreviewContainer { + UploadingMediaMessage( + typeContent = previewUploadingContent(), + message = previewUploadingMessage() + ) + } +} + +@ChatMessagePreviews +@Composable +private fun UploadingMediaMessageFailedPreview() { + PreviewContainer { + UploadingMediaMessage( + typeContent = previewUploadingContent(), + message = previewUploadingMessage(statusIcon = MessageStatusIcon.FAILED) + ) + } +} + +@ChatMessagePreviews +@Composable +private fun UploadingMediaMessageSentPreview() { + PreviewContainer { + UploadingMediaMessage( + typeContent = previewUploadingContent(), + message = previewUploadingMessage(statusIcon = MessageStatusIcon.SENT) + ) + } +} + +@ChatMessagePreviews +@Composable +private fun UploadingMediaMessageNonImagePreview() { + PreviewContainer { + UploadingMediaMessage( + typeContent = MessageTypeContent.UploadingMedia( + localFileUri = "", + caption = "document.pdf", + mimeType = "application/pdf", + drawableResourceId = R.drawable.ic_mimetype_application_pdf + ), + message = previewUploadingMessage() + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/upload/normal/FileUploader.kt b/app/src/main/java/com/nextcloud/talk/upload/normal/FileUploader.kt index 55e1d334b8..000cf522ec 100644 --- a/app/src/main/java/com/nextcloud/talk/upload/normal/FileUploader.kt +++ b/app/src/main/java/com/nextcloud/talk/upload/normal/FileUploader.kt @@ -16,7 +16,6 @@ import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.dagger.modules.RestModule import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.jobs.ShareOperationWorker import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.FileUtils import io.reactivex.Observable @@ -78,12 +77,6 @@ class FileUploader( .observeOn(AndroidSchedulers.mainThread()) .flatMap { response -> if (response.isSuccessful) { - ShareOperationWorker.shareFile( - roomToken, - currentUser, - remotePath, - metaData - ) FileUtils.copyFileToCache(context, sourceFileUri, fileName) Observable.just(true) } else {