From f4b936c6c1fc3f8b6c72343cf3098eeeaa84ec2e Mon Sep 17 00:00:00 2001 From: Jens Zalzala Date: Thu, 30 Apr 2026 12:24:02 -0500 Subject: [PATCH 1/3] Show file upload progress and placeholder media message in chat. Signed-off-by: Jens Zalzala # Conflicts: # gradle/verification-keyring.keys --- .../java/com/nextcloud/talk/api/NcApi.java | 3 +- .../com/nextcloud/talk/chat/ChatActivity.kt | 9 +- .../talk/chat/data/ChatMessageRepository.kt | 11 + .../network/OfflineFirstChatRepository.kt | 74 ++++++ .../talk/chat/ui/model/ChatMessageUi.kt | 26 +- .../talk/chat/viewmodels/ChatViewModel.kt | 83 +++++- .../talk/jobs/ShareOperationWorker.kt | 3 +- .../talk/jobs/UploadAndShareFilesWorker.kt | 70 +++++- .../nextcloud/talk/ui/chat/ChatMessageView.kt | 18 +- .../com/nextcloud/talk/ui/chat/ChatView.kt | 3 +- .../nextcloud/talk/ui/chat/MediaMessage.kt | 238 ++++++++++++++++-- .../upload/chunked/ChunkedFileUploader.kt | 2 + .../talk/upload/normal/FileUploader.kt | 7 - 13 files changed, 506 insertions(+), 41 deletions(-) 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 82b44342bec..3efbce1444f 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApi.java +++ b/app/src/main/java/com/nextcloud/talk/api/NcApi.java @@ -410,7 +410,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 2b622b7a804..4b3758b2dbd 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -159,6 +159,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.GetPinnedOptionsDialog import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment @@ -790,10 +791,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) @@ -848,7 +852,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 9a55fac9bd9..dc5074e441c 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 ef2f10ead0c..8fa19381570 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 @@ -973,6 +973,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 6b9826bb3db..bc045155a01 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) @@ -78,6 +79,13 @@ sealed interface MessageTypeContent { val isClassified: Boolean = false ) : 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 @@ -155,7 +163,8 @@ fun ChatMessage.toUiModel( isSilent = silent, isExpandableParent = expandableParent, expandableChildrenAmount = expandableChildrenAmount, - isHiddenByCollapse = hiddenByCollapse + isHiddenByCollapse = hiddenByCollapse, + referenceId = referenceId ) fun ChatMessage.toScheduledMessageUiModel( @@ -251,6 +260,8 @@ fun getMessageTypeContent(user: User, message: ChatMessage, isClassified: Boolea MessageTypeContent.SystemMessage } else if (message.isVoiceMessage) { getVoiceContent(message) + } else if (message.hasFileAttachment && message.isTemporary) { + getUploadingMediaContent(message) } else if (message.hasFileAttachment) { getMediaContent(user, message, isClassified) } else if (message.hasGeoLocation) { @@ -265,6 +276,17 @@ fun getMessageTypeContent(user: User, message: ChatMessage, isClassified: Boolea ?: 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, isClassified: Boolean = false): 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 d0dc70fc1cb..60dbe75d69d 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.logger.Logger import com.nextcloud.talk.messagesearch.MessageSearchHelper @@ -122,7 +125,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( @@ -217,6 +222,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) { @@ -2034,24 +2054,83 @@ 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, - compressImages + compressImages, + 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 c7f473e41e9..447af56a194 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 9dffb5cca7d..1a4feb20bcb 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 @@ -56,6 +59,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 @@ -88,6 +93,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 @@ -100,6 +108,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 { @@ -111,6 +121,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) @@ -133,12 +145,20 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa currentUser.capabilities!!.spreedCapability!! ) file?.let { isChunkedUploading = it.length() > CHUNK_UPLOAD_THRESHOLD_SIZE } - val uploadSuccess: Boolean = uploadFile(sourceFileUri, metaData, remotePath, useConversationSubfolders) + val uploadSuccess: Boolean = uploadFile(sourceFileUri, remotePath, 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() @@ -146,10 +166,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() } } @@ -201,7 +223,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa okHttpClient, currentUser, roomToken, - metaData, + null, this, ncApiCoroutines, useConversationSubfolders @@ -218,7 +240,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa file!!, ncApiCoroutines ) - .upload(sourceFileUri, fileName, remotePath, metaData) + .upload(sourceFileUri, fileName, remotePath, null) .blockingFirst() } @@ -287,6 +309,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 @@ -298,6 +338,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)) @@ -306,6 +348,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 { @@ -488,6 +537,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 COMPRESS_IMAGES = "COMPRESS_IMAGES" private const val CHUNK_UPLOAD_THRESHOLD_SIZE: Long = 1024 * 1024 private const val NOTIFICATION_FILE_NAME_MAX_LENGTH = 20 @@ -541,24 +593,30 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } } + @Suppress("LongParameterList") fun upload( fileUri: String, roomToken: String, conversationName: String, metaData: String?, + referenceId: String = "", + internalConversationId: String = "", compressImages: Boolean = false - ) { + ): 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) .putBoolean(COMPRESS_IMAGES, compressImages) .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 f439969e9d6..45ab01fe6c8 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 bc57a4a163e..2706aff5efa 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.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 d07efe2ca7c..2290cd2c911 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( @@ -220,3 +219,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/chunked/ChunkedFileUploader.kt b/app/src/main/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploader.kt index 7e8b2b1e347..3423363577f 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 @@ -32,6 +32,8 @@ import com.nextcloud.talk.filebrowser.models.properties.OCFavorite import com.nextcloud.talk.filebrowser.models.properties.OCId import com.nextcloud.talk.filebrowser.models.properties.OCSize import com.nextcloud.talk.jobs.ShareOperationWorker +import com.nextcloud.talk.dagger.modules.RestModule +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.remotefilebrowser.model.RemoteFileBrowserItem import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.FileUtils 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 55e1d334b8c..000cf522ec2 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 { From 3ce6f9cc516b16d2ce0a711fde42cefd2841e240 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 9 Jun 2026 11:41:29 +0200 Subject: [PATCH 2/3] fixes after resolving merge conflicts App compiles but the upload progress is buggy Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt | 7 ++++++- app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt | 2 +- .../nextcloud/talk/upload/chunked/ChunkedFileUploader.kt | 2 -- 3 files changed, 7 insertions(+), 4 deletions(-) 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 1a4feb20bcb..2661c06e889 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -145,7 +145,12 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa currentUser.capabilities!!.spreedCapability!! ) file?.let { isChunkedUploading = it.length() > CHUNK_UPLOAD_THRESHOLD_SIZE } - val uploadSuccess: Boolean = uploadFile(sourceFileUri, remotePath, useConversationSubfolders) + val uploadSuccess: Boolean = uploadFile( + sourceFileUri = sourceFileUri, + metaData = metaData, + remotePath = remotePath, + useConversationSubfolders = useConversationSubfolders + ) if (uploadSuccess) { val shareSuccess = shareFile(remotePath, metaData) 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 2706aff5efa..07e191dfa15 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 @@ -429,7 +429,7 @@ fun ChatView( onQuotedMessageClick = handleQuotedMessageClick, onSystemMessageExpandClick = callbacks.messageCallbacks.onSystemMessageExpandClick, onAvatarClick = callbacks.messageCallbacks.onAvatarClick, - onCancelUpload = callbacks.onCancelUpload + onCancelUpload = callbacks.messageCallbacks.onCancelUpload ) ) } 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 3423363577f..7e8b2b1e347 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 @@ -32,8 +32,6 @@ import com.nextcloud.talk.filebrowser.models.properties.OCFavorite import com.nextcloud.talk.filebrowser.models.properties.OCId import com.nextcloud.talk.filebrowser.models.properties.OCSize import com.nextcloud.talk.jobs.ShareOperationWorker -import com.nextcloud.talk.dagger.modules.RestModule -import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.remotefilebrowser.model.RemoteFileBrowserItem import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.FileUtils From 361a69cb04687ab06f4c987199a20c293a7eef19 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Sat, 8 Aug 2026 21:03:43 +0200 Subject: [PATCH 3/3] fix and improve upload progress in chat Bundles the fixes and follow-up polish for the upload-progress/placeholder feature from the recent merge-conflict cleanup: Correctness fixes: - UploadAndShareFilesWorker called shareFile() unconditionally after every successful upload, even though two other paths already share the file themselves: ChunkedFileUploader still had a leftover ShareOperationWorker.shareFile() call, so any chunked upload (files >1MB) without conversation subfolders posted the attachment twice; and conversation-subfolder uploads already share via postConversationAttachment, so the extra call tried to share a path the file was never uploaded to, failed, and incorrectly marked successful uploads as FAILED. - uploadUsingConversationSubfolders() sent a freshly generated UUID as the message's referenceId instead of the placeholder's actual referenceId, so the server echoed back the wrong id and the temp placeholder could never be matched against the real incoming message, leaving it stuck forever. - sendUnsentChatMessages() (resend-on-reconnect) picked up FAILED upload placeholders and reposted their "{file}" sentinel text as a bogus new message. Placeholders with a file attachment are now excluded from that resend path. - The "upload completed" signal that triggers an immediate message refetch was commented out, so a successfully uploaded video's placeholder could spin forever until the chat was closed and reopened. - Coil's AsyncImage never showed a composable-supplied fallback painter when passed a pre-built ImageRequest with null data, so previews without a server URL (e.g. video with no server preview) silently fell back to Coil's own null-data handling instead of our local first-frame image. Reliability: - UploadAndShareFilesWorker now retries transient network failures (socket resets, timeouts) with backoff and a network-connected constraint instead of failing immediately, up to a bounded number of attempts. UI/UX: - Replaced the linear upload progress bar with a WhatsApp-style circular spinner overlay (with cancel button) centered on the thumbnail, and fixed a metadata-layout bug that left a padding gap next to the placeholder. - Removed the persistent Android notifications duplicating in-chat upload/ compression progress; kept the upload-failed notification. - Stopped treating a file's name as its caption; only real captions are shown, matching how sent messages already behave. - Sized the video upload placeholder to the video's real aspect ratio (16:9 fallback) instead of collapsing to a small generic icon. - Added a local-first-frame fallback, cached to disk keyed by referenceId, for videos whose server preview is unavailable, so they don't show a generic icon indefinitely. - The play button overlay now shows on all video messages (not just ones with a server preview) with a WhatsApp-style semi-transparent dark circle behind it. Assisted-by: Claude:claude-sonnet-5 --- .../com/nextcloud/talk/chat/ChatActivity.kt | 5 +- .../talk/chat/data/ChatMessageRepository.kt | 1 + .../network/OfflineFirstChatRepository.kt | 12 +- .../talk/chat/ui/model/ChatMessageUi.kt | 8 +- .../talk/chat/viewmodels/ChatViewModel.kt | 30 +- .../talk/jobs/UploadAndShareFilesWorker.kt | 250 +++---------- .../nextcloud/talk/ui/chat/ChatMessageView.kt | 21 +- .../nextcloud/talk/ui/chat/MediaMessage.kt | 350 ++++++++++++++---- .../upload/chunked/ChunkedFileUploader.kt | 21 +- .../talk/utils/VideoThumbnailCache.kt | 49 +++ 10 files changed, 428 insertions(+), 319 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/utils/VideoThumbnailCache.kt 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 4b3758b2dbd..15a34897928 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -160,6 +160,7 @@ 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.chat.LocalUploadedLocalPreviewProvider import com.nextcloud.talk.ui.dialog.DateTimeCompose import com.nextcloud.talk.ui.dialog.GetPinnedOptionsDialog import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment @@ -792,12 +793,14 @@ class ChatActivity : SideEffect { chatListState = listState } val uploadProgressMap by chatViewModel.uploadProgressMap.collectAsStateWithLifecycle() + val uploadedLocalPreviewMap by chatViewModel.uploadedLocalPreviewMap.collectAsStateWithLifecycle() CompositionLocalProvider( LocalViewThemeUtils provides viewThemeUtils, LocalMessageUtils provides messageUtils, LocalOpenGraphFetcher provides { url -> chatViewModel.fetchOpenGraph(url) }, - LocalUploadProgressProvider provides { refId -> uploadProgressMap[refId] } + LocalUploadProgressProvider provides { refId -> uploadProgressMap[refId] }, + LocalUploadedLocalPreviewProvider provides { refId -> uploadedLocalPreviewMap[refId] } ) { val currentlyPlayingId by chatViewModel.currentlyPlayedMessageId.collectAsState(null) 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 dc5074e441c..6583fcabb20 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 @@ -135,6 +135,7 @@ interface ChatMessageRepository : LifecycleAwareManager { @Suppress("LongParameterList") suspend fun addUploadPlaceholderMessage( localFileUri: String, + fileName: String, caption: String, mimeType: String?, fileSize: Long, 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 8fa19381570..453d13d88f1 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 @@ -976,6 +976,7 @@ class OfflineFirstChatRepository @Inject constructor( @Suppress("Detekt.TooGenericExceptionCaught", "LongMethod") override suspend fun addUploadPlaceholderMessage( localFileUri: String, + fileName: String, caption: String, mimeType: String?, fileSize: Long, @@ -1003,7 +1004,7 @@ class OfflineFirstChatRepository @Inject constructor( val fileParams = hashMapOf( "type" to "file", - "name" to caption, + "name" to fileName, "mimetype" to (mimeType ?: ""), "size" to fileSize.toString(), "path" to localFileUri @@ -1017,7 +1018,8 @@ class OfflineFirstChatRepository @Inject constructor( internalConversationId = internalConversationId, id = placeholderId, threadId = threadId, - message = "{file}", + // "{file}" is the sentinel the server (and rest of this app) uses for "no caption" + message = caption.ifEmpty { "{file}" }, deleted = false, token = conversationModel.token, actorId = currentUser.userId!!, @@ -1087,7 +1089,11 @@ class OfflineFirstChatRepository @Inject constructor( override suspend fun sendUnsentChatMessages(credentials: String, url: String) { val tempMessages = chatDao.getTempUnsentMessagesForConversation(internalConversationId, threadId).first() - tempMessages.sortedBy { it.internalId }.onEach { + // File-upload placeholders are also temporary messages, but they must never be resent as plain + // text here: their "message" field is just the "{file}" sentinel, and a failed/interrupted upload + // needs a real re-upload, not a bogus text message reusing its referenceId. + val unsentTextMessages = tempMessages.filterNot { it.messageParameters?.containsKey("file") == true } + unsentTextMessages.sortedBy { it.internalId }.onEach { sendChatMessage( credentials, url, 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 bc045155a01..8a25f459626 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 @@ -81,7 +81,8 @@ sealed interface MessageTypeContent { data class UploadingMedia( val localFileUri: String, - val caption: String, + val fileName: String, + val caption: String?, val mimeType: String?, val drawableResourceId: Int ) : MessageTypeContent @@ -276,12 +277,15 @@ fun getMessageTypeContent(user: User, message: ChatMessage, isClassified: Boolea ?: MessageTypeContent.RegularText } +private const val FILE_PLACEHOLDER_MESSAGE = "{file}" + 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(), + fileName = message.fileParameters.name.orEmpty(), + caption = message.message.takeIf { it != FILE_PLACEHOLDER_MESSAGE }, mimeType = mimetype.takeIf { !it.isNullOrEmpty() }, drawableResourceId = drawableResourceId ) 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 60dbe75d69d..83001466d0c 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 @@ -225,6 +225,12 @@ class ChatViewModel @AssistedInject constructor( private val _uploadProgressMap = MutableStateFlow>(emptyMap()) val uploadProgressMap: StateFlow> = _uploadProgressMap + // Maps referenceId -> local device fileUri, kept around for a while after the upload finishes so the + // final message can show the file we already have on disk instead of a generic mimetype icon while it + // waits for the server-side preview to load for the first time. + private val _uploadedLocalPreviewMap = MutableStateFlow>(emptyMap()) + val uploadedLocalPreviewMap: StateFlow> = _uploadedLocalPreviewMap + // Maps referenceId -> fileUri for cancellation support private val uploadReferenceToUri = mutableMapOf() @@ -235,6 +241,7 @@ class ChatViewModel @AssistedInject constructor( chatRepository.deleteTempMessageByReferenceId(referenceId) } _uploadProgressMap.update { it - referenceId } + _uploadedLocalPreviewMap.update { it - referenceId } } fun getChatRepository(): ChatMessageRepository = chatRepository @@ -2069,7 +2076,8 @@ class ChatViewModel @AssistedInject constructor( viewModelScope.launch { chatRepository.addUploadPlaceholderMessage( localFileUri = fileUri, - caption = caption.ifEmpty { fileName }, + fileName = fileName, + caption = caption, mimeType = mimeType, fileSize = fileSize, referenceId = referenceId @@ -2079,17 +2087,18 @@ class ChatViewModel @AssistedInject constructor( val internalConversationId = "${currentUser.id}@$chatRoomToken" val workerId = UploadAndShareFilesWorker.upload( - fileUri, - room, - displayName, - metaData, - compressImages, - referenceId, - internalConversationId + fileUri = fileUri, + roomToken = room, + conversationName = displayName, + metaData = metaData, + referenceId = referenceId, + internalConversationId = internalConversationId, + compressImages = compressImages ) if (!isVoiceMessage) { uploadReferenceToUri[referenceId] = fileUri + _uploadedLocalPreviewMap.update { it + (referenceId to fileUri) } observeUploadProgress(workerId, referenceId) } } catch (e: IllegalArgumentException) { @@ -2126,6 +2135,10 @@ class ChatViewModel @AssistedInject constructor( if (workInfo.state.isFinished) { _uploadProgressMap.update { it - referenceId } uploadReferenceToUri.remove(referenceId) + viewModelScope.launch { + delay(LOCAL_PREVIEW_GRACE_PERIOD_MS) + _uploadedLocalPreviewMap.update { it - referenceId } + } } } .launchIn(viewModelScope) @@ -2485,6 +2498,7 @@ class ChatViewModel @AssistedInject constructor( private const val LOAD_MORE_MESSAGES_LIMIT = 100 private const val POST_UPLOAD_FETCH_MAX_ATTEMPTS = 4 private const val POST_UPLOAD_FETCH_RETRY_DELAY_MS = 1_500L + private const val LOCAL_PREVIEW_GRACE_PERIOD_MS = 15_000L } sealed class OutOfOfficeUIState { 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 2661c06e889..222e4c9fd18 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -11,25 +11,25 @@ import android.Manifest import android.annotation.SuppressLint import android.app.Activity import android.app.NotificationManager -import android.app.PendingIntent import android.content.Context -import android.content.Intent import android.net.Uri import android.os.Build -import android.os.Bundle import android.os.SystemClock import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.net.toUri +import androidx.work.BackoffPolicy +import androidx.work.Constraints import androidx.work.Data import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager +import androidx.work.WorkRequest import androidx.work.Worker import androidx.work.WorkerParameters import autodagger.AutoInjector import com.nextcloud.talk.R -import com.nextcloud.talk.activities.MainActivity import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.application.NextcloudTalkApplication @@ -50,21 +50,20 @@ import com.nextcloud.talk.utils.ImageCompressor import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.RemoteFileUtils import com.nextcloud.talk.utils.VideoCompressor -import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID -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.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 +import java.io.IOException import java.util.UUID +import java.util.concurrent.TimeUnit import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -99,8 +98,6 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa lateinit var fileName: String private var mNotifyManager: NotificationManager? = null - private var mBuilder: NotificationCompat.Builder? = null - private var notificationId: Int = 0 lateinit var roomToken: String lateinit var conversationName: String @@ -111,7 +108,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa private var referenceId: String? = null private var internalConversationId: String? = null - @Suppress("Detekt.TooGenericExceptionCaught") + @Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod") override fun doWork(): Result { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) @@ -153,34 +150,49 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa ) if (uploadSuccess) { - val shareSuccess = shareFile(remotePath, metaData) - cancelNotification() + // useConversationSubfolders already shares as part of uploadFile() via + // postConversationAttachment, so only share explicitly for the plain upload path. + val shareSuccess = useConversationSubfolders || shareFile(remotePath, metaData) if (shareSuccess) { updatePlaceholderStatus(SendStatus.SENT_PENDING_ACK) - // _uploadCompletedFlow.tryEmit(roomToken) <- Check if this still makes sense! + _uploadCompletedFlow.tryEmit(roomToken) return Result.success() } Log.e(TAG, "Share operation failed after upload") - showFailedToUploadNotification() - updatePlaceholderStatus(SendStatus.FAILED) - return Result.failure() + return failUpload() } else if (isStopped) { // since work is cancelled the result would be ignored anyways return Result.failure() } Log.e(TAG, "Something went wrong when trying to upload file") - showFailedToUploadNotification() - updatePlaceholderStatus(SendStatus.FAILED) - return Result.failure() + failUpload() + } catch (e: IOException) { + // Transient network failures (connection reset, timeout, dropped Wi-Fi, ...) shouldn't + // require the user to manually resend - retry a few times with backoff instead, and only + // give up once we've exhausted the allowed attempts. + Log.w( + TAG, + "Network error while uploading file (attempt ${runAttemptCount + 1}/$MAX_UPLOAD_ATTEMPTS)", + e + ) + if (runAttemptCount < MAX_UPLOAD_ATTEMPTS - 1) { + Result.retry() + } else { + failUpload() + } } catch (e: Exception) { Log.e(TAG, "Something went wrong when trying to upload file", e) - showFailedToUploadNotification() - updatePlaceholderStatus(SendStatus.FAILED) - return Result.failure() + failUpload() } } + private fun failUpload(): Result { + showFailedToUploadNotification() + updatePlaceholderStatus(SendStatus.FAILED) + return Result.failure() + } + /** * Replaces [file] and [fileName] with a compressed copy if [sourceFileUri] points to a * compressible image or video, returning the [Uri] that should be uploaded. @@ -192,7 +204,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa val compressedFile = when { ImageCompressor.isCompressible(mimeType) -> ImageCompressor.compress(context, originalFile) - VideoCompressor.isCompressible(mimeType) -> compressVideoWithProgress(originalFile) + VideoCompressor.isCompressible(mimeType) -> VideoCompressor.compress(context, originalFile) else -> null } ?: return sourceFileUri @@ -201,15 +213,6 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa return Uri.fromFile(compressedFile) } - /** - * Video compression can take a while, so the upload notification is repurposed to show its - * progress before it transitions into the actual upload progress. - */ - private fun compressVideoWithProgress(originalFile: File): File? { - showCompressionStartedNotification() - return VideoCompressor.compress(context, originalFile, onProgress = ::onCompressionProgress) - } - private fun uploadFile( sourceFileUri: Uri, metaData: String?, @@ -222,16 +225,12 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa uploadUsingConversationSubfolders(sourceFileUri, metaData) } else if (isChunkedUploading) { Log.d(TAG, "starting chunked upload because size is " + file!!.length()) - initNotificationWithPercentage() val mimeType = context.contentResolver.getType(sourceFileUri)?.toMediaTypeOrNull() chunkedFileUploader = ChunkedFileUploader( okHttpClient, currentUser, - roomToken, - null, this, - ncApiCoroutines, - useConversationSubfolders + ncApiCoroutines ) chunkedFileUploader!!.upload(file!!, mimeType, remotePath) } else { @@ -275,16 +274,12 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa val tempRemotePath = "/$draftFolderPath/$uploadId-$fileName" val uploadSuccess = if (isChunkedUploading) { - initNotificationWithPercentage() val mimeType = context.contentResolver.getType(sourceFileUri)?.toMediaTypeOrNull() chunkedFileUploader = ChunkedFileUploader( okHttpClient, currentUser, - roomToken, - metaData, this@UploadAndShareFilesWorker, - ncApiCoroutines, - true + ncApiCoroutines ) chunkedFileUploader!!.upload(file!!, mimeType, tempRemotePath) } else { @@ -298,7 +293,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa val params = PostConversationAttachmentRequest().apply { filePath = tempRemotePath - referenceId = uploadId + referenceId = this@UploadAndShareFilesWorker.referenceId.orEmpty() talkMetaData = metaData fileName = predictedName } @@ -344,13 +339,6 @@ 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)) - .build() - - mNotifyManager!!.notify(notificationId, progressUpdateNotification) } private fun updatePlaceholderStatus(status: SendStatus) { @@ -362,153 +350,13 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa override fun onStopped() { if (file != null && isChunkedUploading) { - chunkedFileUploader?.abortUpload { - mNotifyManager?.cancel(notificationId) - } + chunkedFileUploader?.abortUpload {} } super.onStopped() } private fun initNotificationSetup() { mNotifyManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - mBuilder = NotificationCompat.Builder( - context, - NotificationUtils.NotificationChannels - .NOTIFICATION_CHANNEL_UPLOADS.name - ) - notificationId = SystemClock.uptimeMillis().toInt() - } - - private fun initNotificationWithPercentage() { - val initNotification = mBuilder!! - .setContentTitle(context.resources.getString(R.string.nc_upload_in_progess)) - .setContentText(getNotificationContentText(ZERO_PERCENT)) - .setSmallIcon(R.drawable.upload_white) - .setOngoing(true) - .setProgress(HUNDRED_PERCENT, ZERO_PERCENT, false) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setGroup(NotificationUtils.KEY_UPLOAD_GROUP) - .setContentIntent(getIntentToOpenConversation()) - .addAction( - R.drawable.ic_cancel_white_24dp, - getResourceString(context, R.string.nc_cancel), - getCancelUploadIntent() - ) - .build() - - mNotifyManager!!.notify(notificationId, initNotification) - // only need one summary notification but multiple upload worker can call it more than once but it is safe - // because of the same notification object config and id. - makeSummaryNotification() - } - - /** - * Shows the same upload notification, but reflecting the compression phase that precedes the - * actual upload. Reuses [notificationId] so it later morphs into the upload progress notification - * instead of appearing as a separate entry. - */ - private fun showCompressionStartedNotification() { - val compressionNotification = mBuilder!! - .setContentTitle(context.resources.getString(R.string.nc_compress_in_progress)) - .setContentText(getCompressionNotificationContentText(ZERO_PERCENT)) - .setSmallIcon(R.drawable.upload_white) - .setOngoing(true) - .setProgress(HUNDRED_PERCENT, ZERO_PERCENT, false) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setGroup(NotificationUtils.KEY_UPLOAD_GROUP) - .setContentIntent(getIntentToOpenConversation()) - .addAction( - R.drawable.ic_cancel_white_24dp, - getResourceString(context, R.string.nc_cancel), - getCancelUploadIntent() - ) - .build() - - mNotifyManager!!.notify(notificationId, compressionNotification) - makeSummaryNotification() - } - - private fun onCompressionProgress(percentage: Int) { - val progressUpdateNotification = mBuilder!! - .setProgress(HUNDRED_PERCENT, percentage, false) - .setContentText(getCompressionNotificationContentText(percentage)) - .build() - - mNotifyManager!!.notify(notificationId, progressUpdateNotification) - } - - private fun getCompressionNotificationContentText(percentage: Int): String = - String.format( - getResourceString(context, R.string.nc_compress_notification_text), - getShortenedFileName(), - percentage - ) - - private fun makeSummaryNotification() { - // summary notification encapsulating the group of notifications - val summaryNotification = NotificationCompat.Builder( - context, - NotificationUtils.NotificationChannels - .NOTIFICATION_CHANNEL_UPLOADS.name - ).setSmallIcon(R.drawable.upload_white) - .setGroup(NotificationUtils.KEY_UPLOAD_GROUP) - .setGroupSummary(true) - .build() - - mNotifyManager?.notify(NotificationUtils.GROUP_SUMMARY_NOTIFICATION_ID, summaryNotification) - } - - private fun getActiveUploadNotifications(): Int? { - // filter out active notifications that are upload notifications using group - return mNotifyManager?.activeNotifications?.filter { - it.notification.group == NotificationUtils - .KEY_UPLOAD_GROUP - }?.size - } - - private fun cancelNotification() { - mNotifyManager?.cancel(notificationId) - // summary notification would not get dismissed automatically - // if child notifications are cancelled programmatically - // so check if only 1 notification left if yes - // then cancel it (which is summary notification) - if (getActiveUploadNotifications() == 1) { - mNotifyManager?.cancel(NotificationUtils.GROUP_SUMMARY_NOTIFICATION_ID) - } - } - - private fun getNotificationContentText(percentage: Int): String = - String.format( - getResourceString(context, R.string.nc_upload_notification_text), - getShortenedFileName(), - conversationName, - percentage - ) - - private fun getShortenedFileName(): String = - if (fileName.length > NOTIFICATION_FILE_NAME_MAX_LENGTH) { - THREE_DOTS + fileName.takeLast(NOTIFICATION_FILE_NAME_MAX_LENGTH) - } else { - fileName - } - - private fun getCancelUploadIntent(): PendingIntent = - WorkManager.getInstance(applicationContext) - .createCancelPendingIntent(id) - - private fun getIntentToOpenConversation(): PendingIntent? { - val bundle = Bundle() - val intent = Intent(context, MainActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK - - bundle.putString(KEY_ROOM_TOKEN, roomToken) - bundle.putLong(KEY_INTERNAL_USER_ID, currentUser.id!!) - - intent.putExtras(bundle) - - val requestCode = System.currentTimeMillis().toInt() - val intentFlag = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - return PendingIntent.getActivity(context, requestCode, intent, intentFlag) } private fun showFailedToUploadNotification() { @@ -529,8 +377,6 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa .setOngoing(false) .build() - mNotifyManager?.cancel(notificationId) - // update current notification with failure info mNotifyManager!!.notify(SystemClock.uptimeMillis().toInt(), failureNotification) } @@ -547,10 +393,10 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa const val PROGRESS_KEY = "UPLOAD_PROGRESS" private const val COMPRESS_IMAGES = "COMPRESS_IMAGES" private const val CHUNK_UPLOAD_THRESHOLD_SIZE: Long = 1024 * 1024 - private const val NOTIFICATION_FILE_NAME_MAX_LENGTH = 20 - private const val THREE_DOTS = "…" - private const val HUNDRED_PERCENT = 100 - private const val ZERO_PERCENT = 0 + + // Total attempts allowed for a single upload (1 initial run + retries) before giving up on a + // transient network failure and marking the placeholder FAILED. + private const val MAX_UPLOAD_ATTEMPTS = 4 const val REQUEST_PERMISSION = 3123 private val _uploadCompletedFlow: MutableSharedFlow = MutableSharedFlow( @@ -619,6 +465,16 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa .build() val uploadWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(UploadAndShareFilesWorker::class.java) .setInputData(data) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + WorkRequest.MIN_BACKOFF_MILLIS, + TimeUnit.MILLISECONDS + ) .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 45ab01fe6c8..fb472379697 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 @@ -210,17 +210,18 @@ fun ChatMessageView( } is MessageTypeContent.UploadingMedia -> { - UploadingMediaMessage( - typeContent = content, - message = message, - isOneToOneConversation = context.isOneToOneConversation, - conversationThreadId = context.conversationThreadId, - onCancelUpload = callbacks.onCancelUpload - ) - } + UploadingMediaMessage( + typeContent = content, + message = message, + isOneToOneConversation = context.isOneToOneConversation, + conversationThreadId = context.conversationThreadId, + onCancelUpload = callbacks.onCancelUpload + ) + } - else -> { - Log.d("ChatView", "Unknown message type: ${'$'}content")} + 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/MediaMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt index 2290cd2c911..d77f9469afa 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 @@ -7,28 +7,32 @@ package com.nextcloud.talk.ui.chat +import android.graphics.Bitmap import android.util.Log +import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape 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.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -44,10 +48,14 @@ 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 androidx.compose.ui.unit.dp import coil.compose.AsyncImage +import coil.compose.rememberAsyncImagePainter import coil.network.HttpException import com.nextcloud.talk.R +import com.nextcloud.talk.attachmentpreview.FileDescription +import com.nextcloud.talk.attachmentpreview.describeFile import com.nextcloud.talk.chat.data.model.FileParameters import com.nextcloud.talk.chat.data.model.decodeBlurhashPlaceholder import com.nextcloud.talk.chat.ui.model.ChatMessageUi @@ -56,12 +64,20 @@ 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 com.nextcloud.talk.utils.VideoThumbnailCache +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import androidx.core.net.toUri val LocalUploadProgressProvider = compositionLocalOf<(referenceId: String) -> Int?> { { null } } +// Local device URI of a just-finished-uploading message, keyed by referenceId. Used to bridge the gap +// between the upload placeholder disappearing and the server-side preview finishing its first load, so +// we show the image we already have on disk instead of a generic mimetype icon. +val LocalUploadedLocalPreviewProvider = compositionLocalOf<(referenceId: String) -> String?> { { null } } + private const val FILE_PLACEHOLDER_MESSAGE = "{file}" private const val PREVIEW_MAX_RETRIES = 3 private const val PREVIEW_RETRY_DELAY_MS = 2_000L @@ -70,6 +86,20 @@ private const val TAG = "MediaMessage" private val mediaRadiusBig = 8.dp private val mediaRadiusSmall = 2.dp +private val uploadSpinnerSize = 56.dp +private val uploadSpinnerStrokeWidth = 3.dp +private const val UPLOAD_SCRIM_ALPHA = 0.25f +private const val UPLOAD_SPINNER_TRACK_ALPHA = 0.3f + +// Used to size the uploading-video placeholder before its real aspect ratio is known (or if it can't +// be read at all), so the bubble doesn't collapse to icon-size. 16:9 is the most common video shape. +private const val DEFAULT_VIDEO_ASPECT_RATIO = 16f / 9f +private const val VIDEO_PLACEHOLDER_BACKGROUND_ALPHA = 0.4f + +private val playButtonCircleSize = 56.dp +private val playButtonIconSize = 32.dp +private const val PLAY_BUTTON_CIRCLE_ALPHA = 0.45f + @Suppress("Detekt.LongMethod", "LongParameterList", "CyclomaticComplexMethod") @Composable fun MediaMessage( @@ -83,8 +113,50 @@ fun MediaMessage( val fileParameters = remember { FileParameters(message.messageParameters as HashMap>?) } + val context = LocalContext.current + val isVideo = typeContent.mimeType.startsWith(Mimetype.VIDEO_PREFIX) + val hasServerPreview = !typeContent.previewUrl.isNullOrEmpty() + + val getLocalPreviewUri = LocalUploadedLocalPreviewProvider.current + val localPreviewUri = if (typeContent.mimeType.startsWith(Mimetype.IMAGE_PREFIX) || isVideo) { + message.referenceId?.let(getLocalPreviewUri) + } else { + null + } + val localPreviewPainter = if (!isVideo && !localPreviewUri.isNullOrEmpty()) { + rememberAsyncImagePainter(model = localPreviewUri.toUri()) + } else { + null + } + + // The server didn't generate a preview for this video (unsupported codec, previews disabled, ...) + // - fall back to its first frame instead of a plain icon. Prefer the durable on-disk cache + // (survives leaving/reopening the chat or an app restart, unlike the in-memory localPreviewUri + // bridge, which only lives for the current upload); if it's not cached yet, re-extract from the + // local file while we still have it and cache it for next time. Coil has no built-in video frame + // decoding, so this reads the frame directly via MediaMetadataRetriever, same as the + // uploading-placeholder state. + val localVideoFramePainter = if (isVideo && !hasServerPreview) { + val refId = message.referenceId + val videoFrame by produceState(initialValue = null, key1 = refId, key2 = localPreviewUri) { + value = withContext(Dispatchers.IO) { + refId?.let { VideoThumbnailCache.get(context, it) } + ?: localPreviewUri?.let { uri -> + describeFile(context, uri, compress = false).videoThumbnail?.also { bitmap -> + refId?.let { VideoThumbnailCache.put(context, it, bitmap) } + } + } + } + } + videoFrame?.let { BitmapPainter(it.asImageBitmap()) } + } else { + null + } + + // A video shown via its local first frame counts as "has a preview" too, so the filename caption + // stays suppressed just like it would once the server's own preview becomes available. + val hasPreview = hasServerPreview || localVideoFramePainter != null val hasExplicitCaption = message.plainMessage != FILE_PLACEHOLDER_MESSAGE - val hasPreview = !typeContent.previewUrl.isNullOrEmpty() val captionText = when { hasExplicitCaption -> message.message !hasPreview -> message.message @@ -105,14 +177,17 @@ fun MediaMessage( forceTimeOverlay = !hasCaption, content = { Column { - val context = LocalContext.current val scope = rememberCoroutineScope() val isGif = MimetypeUtils.isGif(typeContent.mimeType) - val showPlayButton = !typeContent.previewUrl.isNullOrEmpty() && + // Every video gets a play button overlay, regardless of whether its preview came from + // the server or our own local-first-frame fallback (or neither, yet). + val showPlayButton = isVideo || ( - typeContent.mimeType.startsWith(Mimetype.VIDEO_PREFIX) || - typeContent.mimeType.startsWith(Mimetype.AUDIO_PREFIX) || - (isGif && !typeContent.animateGif) + !typeContent.previewUrl.isNullOrEmpty() && + ( + typeContent.mimeType.startsWith(Mimetype.AUDIO_PREFIX) || + (isGif && !typeContent.animateGif) + ) ) var retryCount by remember(typeContent.previewUrl) { mutableIntStateOf(0) } @@ -139,7 +214,10 @@ fun MediaMessage( if (w != null && h != null && w > 0 && h > 0) w.toFloat() / h else null } val loadedImage = remember(retryAwarePreviewUrl, typeContent.isClassified) { - if (typeContent.isClassified) { + if (typeContent.isClassified || retryAwarePreviewUrl == null) { + // Passing an ImageRequest built with null data (rather than a null model) here + // would make Coil resolve its own null-data handling instead of ever showing the + // fallback painter passed to AsyncImage below. null } else { load( @@ -152,58 +230,82 @@ fun MediaMessage( } val fallbackPainter = painterResource(typeContent.drawableResourceId) + val ownUploadPlaceholder = blurhashPainter ?: localPreviewPainter ?: fallbackPainter + + val mediaModifier = Modifier + .fillMaxWidth() + .then(if (aspectRatio != null) Modifier.aspectRatio(aspectRatio) else Modifier) + .padding(mediaInset) + .clip(mediaShape) + Box(modifier = Modifier.fillMaxWidth()) { val messageLongClickHandler = LocalMessageLongClickHandler.current - AsyncImage( - model = loadedImage, - contentDescription = stringResource(R.string.media_message_content_description), - placeholder = blurhashPainter ?: fallbackPainter, - error = blurhashPainter ?: fallbackPainter, - fallback = blurhashPainter ?: fallbackPainter, - modifier = Modifier - .fillMaxWidth() - .then(if (aspectRatio != null) Modifier.aspectRatio(aspectRatio) else Modifier) - .padding(mediaInset) - .clip(mediaShape) - .combinedClickable( - onClick = { onImageClick(message.id) }, - onLongClick = { messageLongClickHandler(message.id) } - ), - contentScale = ContentScale.FillWidth, - onError = { state -> - val cause = state.result.throwable - val isServerError = cause is HttpException && cause.response.code in 500..599 - if ( - isServerError && - !typeContent.previewUrl.isNullOrEmpty() && - retryCount < PREVIEW_MAX_RETRIES && - !retryPending - ) { - retryPending = true - scope.launch { - Log.d( - TAG, - "Preview returned HTTP ${(cause as HttpException).response.code}, " + - "scheduling retry ${retryCount + 1}/$PREVIEW_MAX_RETRIES " + - "for ${typeContent.previewUrl}" - ) - delay(PREVIEW_RETRY_DELAY_MS) - retryCount++ - retryPending = false + val clickableModifier = mediaModifier.combinedClickable( + onClick = { onImageClick(message.id) }, + onLongClick = { messageLongClickHandler(message.id) } + ) + + // Rendered directly instead of routed through Coil's placeholder/fallback painters, + // since Coil's own null-data handling on a pre-built ImageRequest (see load() below) + // takes priority and never shows a composable-supplied fallback painter here. + if (localVideoFramePainter != null) { + Image( + painter = localVideoFramePainter, + contentDescription = stringResource(R.string.media_message_content_description), + modifier = clickableModifier, + contentScale = ContentScale.FillWidth + ) + } else { + AsyncImage( + model = loadedImage, + contentDescription = stringResource(R.string.media_message_content_description), + placeholder = ownUploadPlaceholder, + error = ownUploadPlaceholder, + fallback = ownUploadPlaceholder, + modifier = clickableModifier, + contentScale = ContentScale.FillWidth, + onError = { state -> + val cause = state.result.throwable + val isServerError = cause is HttpException && cause.response.code in 500..599 + if ( + isServerError && + !typeContent.previewUrl.isNullOrEmpty() && + retryCount < PREVIEW_MAX_RETRIES && + !retryPending + ) { + retryPending = true + scope.launch { + Log.d( + TAG, + "Preview returned HTTP ${(cause as HttpException).response.code}, " + + "scheduling retry ${retryCount + 1}/$PREVIEW_MAX_RETRIES " + + "for ${typeContent.previewUrl}" + ) + delay(PREVIEW_RETRY_DELAY_MS) + retryCount++ + retryPending = false + } } } - } - ) + ) + } if (showPlayButton) { - Icon( - painter = painterResource(R.drawable.ic_baseline_play_arrow_voice_message_24), - contentDescription = stringResource(R.string.media_message_content_play), + Box( modifier = Modifier .align(Alignment.Center) - .size(48.dp), - tint = Color.White - ) + .size(playButtonCircleSize) + .clip(CircleShape) + .background(Color.Black.copy(alpha = PLAY_BUTTON_CIRCLE_ALPHA)), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_baseline_play_arrow_voice_message_24), + contentDescription = stringResource(R.string.media_message_content_play), + modifier = Modifier.size(playButtonIconSize), + tint = Color.White + ) + } } if (chatViewDownloadingFileState.contains(fileParameters.id)) { @@ -233,6 +335,7 @@ fun UploadingMediaMessage( val progress = getProgress(message.referenceId.orEmpty()) val isFailed = message.statusIcon == MessageStatusIcon.FAILED val isSent = message.statusIcon == MessageStatusIcon.SENT + val hasCaption = typeContent.caption != null val mediaInset = 4.dp val mediaShape = remember(message.incoming) { @@ -245,14 +348,16 @@ fun UploadingMediaMessage( conversationThreadId = conversationThreadId, includePadding = false, captionText = typeContent.caption, + forceTimeOverlay = !hasCaption, content = { Column(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) { - val isImage = typeContent.mimeType?.startsWith("image") == true + val isImage = typeContent.mimeType?.startsWith(Mimetype.IMAGE_PREFIX) == true + val isVideo = typeContent.mimeType?.startsWith(Mimetype.VIDEO_PREFIX) == true if (isImage && typeContent.localFileUri.isNotEmpty()) { AsyncImage( model = typeContent.localFileUri.toUri(), - contentDescription = typeContent.caption, + contentDescription = typeContent.fileName, modifier = Modifier .fillMaxWidth() .blur(4.dp) @@ -260,10 +365,17 @@ fun UploadingMediaMessage( .clip(mediaShape), contentScale = ContentScale.FillWidth ) + } else if (isVideo && typeContent.localFileUri.isNotEmpty()) { + UploadingVideoPreview( + typeContent = typeContent, + referenceId = message.referenceId, + mediaInset = mediaInset, + mediaShape = mediaShape + ) } else { Icon( painter = painterResource(typeContent.drawableResourceId), - contentDescription = typeContent.caption, + contentDescription = typeContent.fileName, modifier = Modifier .size(64.dp) .padding(mediaInset) @@ -280,15 +392,42 @@ fun UploadingMediaMessage( .size(24.dp) ) } else if (!isFailed) { - IconButton( - onClick = { onCancelUpload(message.referenceId.orEmpty()) }, - modifier = Modifier.align(Alignment.TopEnd) + Box( + modifier = Modifier + .matchParentSize() + .background(Color.Black.copy(alpha = UPLOAD_SCRIM_ALPHA)) + ) + Box( + modifier = Modifier + .align(Alignment.Center) + .size(uploadSpinnerSize) ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.nc_cancel), - tint = Color.White - ) + if (progress != null) { + CircularProgressIndicator( + progress = { progress / 100f }, + modifier = Modifier.fillMaxSize(), + color = Color.White, + trackColor = Color.White.copy(alpha = UPLOAD_SPINNER_TRACK_ALPHA), + strokeWidth = uploadSpinnerStrokeWidth + ) + } else { + CircularProgressIndicator( + modifier = Modifier.fillMaxSize(), + color = Color.White, + trackColor = Color.White.copy(alpha = UPLOAD_SPINNER_TRACK_ALPHA), + strokeWidth = uploadSpinnerStrokeWidth + ) + } + IconButton( + onClick = { onCancelUpload(message.referenceId.orEmpty()) }, + modifier = Modifier.align(Alignment.Center) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.nc_cancel), + tint = Color.White + ) + } } } } @@ -299,27 +438,74 @@ fun UploadingMediaMessage( 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) - ) - } } } } ) } +/** + * Sized to the video's real aspect ratio (read locally from the file being uploaded, so it matches + * what the final sent message will look like) with its first frame as a blurred thumbnail. Falls + * back to a fixed 16:9 box with the generic file icon while that's being read, or if it can't be + * read at all. + */ +@Composable +private fun UploadingVideoPreview( + typeContent: MessageTypeContent.UploadingMedia, + referenceId: String?, + mediaInset: Dp, + mediaShape: RoundedCornerShape +) { + val context = LocalContext.current + val videoDescription by produceState( + initialValue = null, + key1 = typeContent.localFileUri + ) { + value = withContext(Dispatchers.IO) { + describeFile(context, typeContent.localFileUri, compress = false).also { description -> + description.videoThumbnail?.let { bitmap -> + referenceId?.let { VideoThumbnailCache.put(context, it, bitmap) } + } + } + } + } + val aspectRatio = videoDescription?.aspectRatio ?: DEFAULT_VIDEO_ASPECT_RATIO + val thumbnail = videoDescription?.videoThumbnail + + if (thumbnail != null) { + Image( + bitmap = thumbnail.asImageBitmap(), + contentDescription = typeContent.fileName, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(aspectRatio) + .blur(4.dp) + .padding(mediaInset) + .clip(mediaShape), + contentScale = ContentScale.Crop + ) + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(aspectRatio) + .padding(mediaInset) + .clip(mediaShape) + .background(Color.Black.copy(alpha = VIDEO_PLACEHOLDER_BACKGROUND_ALPHA)) + ) { + Icon( + painter = painterResource(typeContent.drawableResourceId), + contentDescription = typeContent.fileName, + modifier = Modifier + .size(64.dp) + .align(Alignment.Center), + tint = Color.Unspecified + ) + } + } +} + fun shape(incoming: Boolean): RoundedCornerShape = if (incoming) { RoundedCornerShape( @@ -340,7 +526,8 @@ fun shape(incoming: Boolean): RoundedCornerShape = private fun previewUploadingContent(mimeType: String? = "image/jpeg") = MessageTypeContent.UploadingMedia( localFileUri = "", - caption = "photo.jpg", + fileName = "photo.jpg", + caption = null, mimeType = mimeType, drawableResourceId = R.drawable.ic_mimetype_image ) @@ -420,7 +607,8 @@ private fun UploadingMediaMessageNonImagePreview() { UploadingMediaMessage( typeContent = MessageTypeContent.UploadingMedia( localFileUri = "", - caption = "document.pdf", + fileName = "document.pdf", + caption = null, mimeType = "application/pdf", drawableResourceId = R.drawable.ic_mimetype_application_pdf ), 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 7e8b2b1e347..48475401401 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 @@ -31,7 +31,6 @@ import com.nextcloud.talk.filebrowser.models.properties.NCPreview import com.nextcloud.talk.filebrowser.models.properties.OCFavorite import com.nextcloud.talk.filebrowser.models.properties.OCId import com.nextcloud.talk.filebrowser.models.properties.OCSize -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 @@ -51,11 +50,8 @@ import java.util.Locale class ChunkedFileUploader( okHttpClient: OkHttpClient, val currentUser: User, - val roomToken: String, - val metaData: String?, val listener: OnDataTransferProgressListener, - val ncApiCoroutines: NcApiCoroutines, - val supportsConversationFolders: Boolean + val ncApiCoroutines: NcApiCoroutines ) { private var okHttpClientNoRedirects: OkHttpClient? = null @@ -95,7 +91,7 @@ class ChunkedFileUploader( } if (isUploadSuccessful) { - assembleChunks(uploadFolderUri, targetPath, supportsConversationFolders) + assembleChunks(uploadFolderUri, targetPath) } return isUploadSuccessful } catch (e: Exception) { @@ -298,7 +294,7 @@ class ChunkedFileUploader( this.okHttpClientNoRedirects = builder.build() } - private fun assembleChunks(uploadFolderUri: String, targetPath: String, useConversationSubfolders: Boolean) { + private fun assembleChunks(uploadFolderUri: String, targetPath: String) { val destinationUri = ApiUtils.getUrlForFileUpload( currentUser.baseUrl!!, currentUser.userId!!, @@ -315,16 +311,7 @@ class ChunkedFileUploader( destinationUri.toHttpUrlOrNull()!!, true ) { response: Response -> - if (response.isSuccessful) { - if (!useConversationSubfolders) { - 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/utils/VideoThumbnailCache.kt b/app/src/main/java/com/nextcloud/talk/utils/VideoThumbnailCache.kt new file mode 100644 index 00000000000..b9cc9d0a644 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/VideoThumbnailCache.kt @@ -0,0 +1,49 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2017-2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import java.io.File +import java.io.FileOutputStream + +/** + * Disk cache for locally-extracted video first frames, keyed by the message's referenceId. + * + * Used when a video's server-side preview is unavailable: the frame extracted from the local file + * right after upload would otherwise only live in memory for the current chat session, disappearing + * as soon as the chat is left and reopened. Persisting it here lets that fallback survive across + * chat sessions (and app restarts) without needing the original local file anymore. + */ +object VideoThumbnailCache { + private val TAG = VideoThumbnailCache::class.simpleName + private const val CACHE_DIR_NAME = "video_thumbnails" + private const val JPEG_QUALITY = 80 + + private fun cacheDir(context: Context): File = + File(context.cacheDir, CACHE_DIR_NAME) + .apply { mkdirs() } + + fun get(context: Context, referenceId: String): Bitmap? { + val file = File(cacheDir(context), "$referenceId.jpg") + if (!file.exists()) return null + return BitmapFactory.decodeFile(file.absolutePath) + } + + @Suppress("TooGenericExceptionCaught") + fun put(context: Context, referenceId: String, bitmap: Bitmap) { + try { + FileOutputStream(File(cacheDir(context), "$referenceId.jpg")).use { out -> + bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to cache video thumbnail for referenceId=$referenceId", e) + } + } +}