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 b8d6681961..09cbd21789 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApi.java +++ b/app/src/main/java/com/nextcloud/talk/api/NcApi.java @@ -386,7 +386,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 7d68797814..e5eb8f6cab 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -157,6 +157,7 @@ import com.nextcloud.talk.ui.StatusDrawable 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 @@ -605,10 +606,13 @@ class ChatActivity : val listState = rememberLazyListState() 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 isOneToOneConversation = uiState.isOneToOneConversation Log.d(TAG, "isOneToOneConversation=" + isOneToOneConversation) @@ -635,7 +639,8 @@ class ChatActivity : onReactionClick = { messageId, emoji -> handleReactionClick(messageId, emoji) }, onReactionLongClick = { messageId -> openReactionsDialog(messageId) }, onOpenThreadClick = { messageId -> openThread(messageId.toLong()) }, - onLoadQuotedMessageClick = { messageId -> onLoadQuotedMessage(messageId) } + onLoadQuotedMessageClick = { messageId -> onLoadQuotedMessage(messageId) }, + 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 12c035c5c1..5abda5388e 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 @@ -117,6 +117,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 445363b103..77cfab2537 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 @@ -855,6 +855,77 @@ 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 bd823378c4..5c2d4f6756 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 @@ -43,7 +43,8 @@ data class ChatMessageUi( val reactions: List = emptyList(), val isEdited: Boolean = false, val parentMessage: ChatMessageUi? = null, - val replyable: Boolean = false + val replyable: Boolean = false, + val referenceId: String? = null ) data class MessageReactionUi(val emoji: String, val amount: Int, val isSelfReaction: Boolean) @@ -56,6 +57,13 @@ sealed interface MessageTypeContent { data class Media(val previewUrl: String?, val drawableResourceId: Int) : 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 @@ -125,7 +133,8 @@ fun ChatMessage.toUiModel( lastCommonReadMessageId = 0, parentMessage = null ), - replyable = replyable + replyable = replyable, + referenceId = referenceId ) private fun ChatMessage.normalizeMessageParameters(): Map> = @@ -173,6 +182,8 @@ fun resolveStatusIcon( ): MessageStatusIcon { val status = if (sendStatus == SendStatus.FAILED) { MessageStatusIcon.FAILED + } else if (isTemporary && sendStatus == SendStatus.SENT_PENDING_ACK) { + MessageStatusIcon.SENT } else if (isTemporary) { MessageStatusIcon.SENDING } else if (jsonMessageId <= lastCommonReadMessageId) { @@ -188,6 +199,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) { @@ -202,6 +215,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 previewUrl = getPreviewImageUrl(user, message) val mimetype = message.fileParameters.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 42775b17b6..d1f963ba5c 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 @@ -35,6 +36,9 @@ import com.nextcloud.talk.data.database.mappers.toDomainModel import com.nextcloud.talk.data.database.model.ChatMessageEntity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.extensions.toIntOrZero +import androidx.lifecycle.asFlow +import androidx.work.WorkManager +import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.jobs.UploadAndShareFilesWorker import com.nextcloud.talk.models.MessageDraft import com.nextcloud.talk.models.domain.ConversationModel @@ -100,7 +104,9 @@ import retrofit2.HttpException import java.io.File import java.io.IOException import java.time.LocalDate +import java.util.UUID import javax.inject.Inject +import androidx.core.net.toUri @Suppress("TooManyFunctions", "LongParameterList") class ChatViewModel @AssistedInject constructor( @@ -145,6 +151,21 @@ class ChatViewModel @AssistedInject constructor( var hiddenUpcomingEvent: String? = null lateinit var participantPermissions: ParticipantPermissions + 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) { @@ -1392,23 +1413,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 0aed90350f..0812ec2ef7 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.kt @@ -52,7 +52,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 1a40d646d0..19c7525c03 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 @@ -31,11 +32,14 @@ import com.nextcloud.talk.R import com.nextcloud.talk.activities.MainActivity import com.nextcloud.talk.api.NcApi 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.upload.chunked.ChunkedFileUploader import com.nextcloud.talk.upload.chunked.OnDataTransferProgressListener import com.nextcloud.talk.upload.normal.FileUploader import com.nextcloud.talk.users.UserManager +import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.NotificationUtils @@ -45,9 +49,12 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil import com.nextcloud.talk.utils.preferences.AppPreferences +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.runBlocking import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import java.io.File +import java.util.UUID import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -73,6 +80,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 @@ -85,6 +95,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 { @@ -96,6 +108,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) @@ -109,11 +123,19 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa initNotificationSetup() file?.let { isChunkedUploading = it.length() > CHUNK_UPLOAD_THRESHOLD_SIZE } - val uploadSuccess: Boolean = uploadFile(sourceFileUri, metaData, remotePath) + val uploadSuccess: Boolean = uploadFile(sourceFileUri, remotePath) if (uploadSuccess) { + val shareSuccess = shareFile(remotePath, metaData) cancelNotification() - return Result.success() + if (shareSuccess) { + updatePlaceholderStatus(SendStatus.SENT_PENDING_ACK) + 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() @@ -121,15 +143,17 @@ 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() } } - private fun uploadFile(sourceFileUri: Uri, metaData: String?, remotePath: String): Boolean = + private fun uploadFile(sourceFileUri: Uri, remotePath: String): Boolean = if (file == null) { false } else if (isChunkedUploading) { @@ -138,16 +162,35 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa initNotificationWithPercentage() val mimeType = context.contentResolver.getType(sourceFileUri)?.toMediaTypeOrNull() - chunkedFileUploader = ChunkedFileUploader(okHttpClient, currentUser, roomToken, metaData, this) + chunkedFileUploader = ChunkedFileUploader(okHttpClient, currentUser, roomToken, null, this) chunkedFileUploader!!.upload(file!!, mimeType, remotePath) } else { Log.d(TAG, "starting normal upload (not chunked) of $fileName") FileUploader(okHttpClient, context, currentUser, roomToken, ncApi, file!!) - .upload(sourceFileUri, fileName, remotePath, metaData) + .upload(sourceFileUri, fileName, remotePath, null) .blockingFirst() } + @SuppressLint("CheckResult") + private fun shareFile(remotePath: String, metaData: String?): Boolean { + return 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 getRemotePath(currentUser: User): String { val remotePath = CapabilitiesUtil.getAttachmentFolder( currentUser.capabilities!!.spreedCapability!! @@ -156,6 +199,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)) @@ -164,6 +209,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 { @@ -308,6 +360,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 = 1024000 private const val NOTIFICATION_FILE_NAME_MAX_LENGTH = 20 private const val THREE_DOTS = "…" @@ -349,17 +404,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 e766b6ee17..3fd527bb36 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 @@ -65,7 +65,8 @@ class ChatMessageCallbacks( val onReactionClick: (Int, String) -> Unit = { _, _ -> }, val onReactionLongClick: (Int) -> Unit = {}, val onOpenThreadClick: (Int) -> Unit = {}, - val onQuotedMessageClick: (Int) -> Unit = {} + val onQuotedMessageClick: (Int) -> Unit = {}, + val onCancelUpload: (String) -> Unit = {} ) @Composable @@ -183,6 +184,16 @@ fun ChatMessageView( ) } + 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") } 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 59c7741900..c4c36ab6f2 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 @@ -105,7 +105,8 @@ class ChatViewCallbacks( val onReactionLongClick: (Int) -> Unit = {}, val onOpenThreadClick: (Int) -> Unit = {}, val onLoadQuotedMessageClick: (Int) -> Unit = {}, - val onSwipeReply: ((Int) -> Unit)? = null + val onSwipeReply: ((Int) -> Unit)? = null, + val onCancelUpload: (String) -> Unit = {} ) @Suppress("Detekt.LongMethod", "Detekt.ComplexMethod") @@ -324,7 +325,8 @@ fun ChatView( onReactionClick = callbacks.onReactionClick, onReactionLongClick = callbacks.onReactionLongClick, onOpenThreadClick = callbacks.onOpenThreadClick, - onQuotedMessageClick = handleQuotedMessageClick + onQuotedMessageClick = handleQuotedMessageClick, + onCancelUpload = callbacks.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 d0964326ca..74527ed5a6 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 @@ -14,8 +14,15 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +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.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -25,12 +32,18 @@ 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 com.nextcloud.talk.R 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 androidx.core.net.toUri + +val LocalUploadProgressProvider = compositionLocalOf<(referenceId: String) -> Int?> { { null } } private const val FILE_PLACEHOLDER_MESSAGE = "{file}" @@ -50,21 +63,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( @@ -116,3 +115,210 @@ 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/chunked/ChunkedFileUploader.kt b/app/src/main/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploader.kt index 6725fbec24..4262c0d76f 100644 --- a/app/src/main/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploader.kt +++ b/app/src/main/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploader.kt @@ -30,7 +30,6 @@ import com.nextcloud.talk.filebrowser.models.properties.OCId import com.nextcloud.talk.filebrowser.models.properties.OCSize 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.remotefilebrowser.model.RemoteFileBrowserItem import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.FileUtils @@ -303,14 +302,7 @@ class ChunkedFileUploader( destinationUri.toHttpUrlOrNull()!!, true ) { response: Response -> - if (response.isSuccessful) { - ShareOperationWorker.shareFile( - roomToken, - currentUser, - targetPath, - metaData - ) - } else { + if (!response.isSuccessful) { throw IOException("Failed to assemble chunks. response code: " + response.code) } } 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 89fd9fcbb6..6284d0c60d 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 @@ -15,7 +15,6 @@ import at.bitfire.dav4jvm.exception.HttpException import com.nextcloud.talk.api.NcApi 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 @@ -64,12 +63,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 {