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..15a34897928 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,8 @@ 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.chat.LocalUploadedLocalPreviewProvider import com.nextcloud.talk.ui.dialog.DateTimeCompose import com.nextcloud.talk.ui.dialog.GetPinnedOptionsDialog import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment @@ -790,10 +792,15 @@ 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) } + LocalOpenGraphFetcher provides { url -> chatViewModel.fetchOpenGraph(url) }, + LocalUploadProgressProvider provides { refId -> uploadProgressMap[refId] }, + LocalUploadedLocalPreviewProvider provides { refId -> uploadedLocalPreviewMap[refId] } ) { val currentlyPlayingId by chatViewModel.currentlyPlayedMessageId.collectAsState(null) @@ -848,7 +855,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..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 @@ -132,6 +132,18 @@ interface ChatMessageRepository : LifecycleAwareManager { referenceId: String ): Flow> + @Suppress("LongParameterList") + suspend fun addUploadPlaceholderMessage( + localFileUri: String, + fileName: 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..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 @@ -973,6 +973,82 @@ class OfflineFirstChatRepository @Inject constructor( } } + @Suppress("Detekt.TooGenericExceptionCaught", "LongMethod") + override suspend fun addUploadPlaceholderMessage( + localFileUri: String, + fileName: 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 fileName, + "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, + // "{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!!, + 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, @@ -1013,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 6b9826bb3db..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 @@ -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,14 @@ sealed interface MessageTypeContent { val isClassified: Boolean = false ) : MessageTypeContent + data class UploadingMedia( + val localFileUri: String, + val fileName: 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 +164,8 @@ fun ChatMessage.toUiModel( isSilent = silent, isExpandableParent = expandableParent, expandableChildrenAmount = expandableChildrenAmount, - isHiddenByCollapse = hiddenByCollapse + isHiddenByCollapse = hiddenByCollapse, + referenceId = referenceId ) fun ChatMessage.toScheduledMessageUiModel( @@ -251,6 +261,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 +277,20 @@ 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(), + fileName = message.fileParameters.name.orEmpty(), + caption = message.message.takeIf { it != FILE_PLACEHOLDER_MESSAGE }, + 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..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 @@ -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,28 @@ class ChatViewModel @AssistedInject constructor( private var lobbyPollingJob: Job? = null + 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() + + 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 } + _uploadedLocalPreviewMap.update { it - referenceId } + } + fun getChatRepository(): ChatMessageRepository = chatRepository override fun onResume(owner: LifecycleOwner) { @@ -2034,24 +2061,89 @@ 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( - fileUri, - room, - displayName, - metaData, - compressImages + + if (!isVoiceMessage) { + val (fileName, mimeType, fileSize) = resolveFileInfo(fileUri) + viewModelScope.launch { + chatRepository.addUploadPlaceholderMessage( + localFileUri = fileUri, + fileName = fileName, + caption = caption, + mimeType = mimeType, + fileSize = fileSize, + referenceId = referenceId + ).collect {} + } + } + + val internalConversationId = "${currentUser.id}@$chatRoomToken" + val workerId = UploadAndShareFilesWorker.upload( + 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) { 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) + viewModelScope.launch { + delay(LOCAL_PREVIEW_GRACE_PERIOD_MS) + _uploadedLocalPreviewMap.update { it - referenceId } + } + } + } + .launchIn(viewModelScope) + } + fun postToRecordTouchObserver(float: Float) { _recordTouchObserver.postValue(float) } @@ -2406,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/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..222e4c9fd18 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -8,30 +8,33 @@ package com.nextcloud.talk.jobs 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 +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 @@ -47,19 +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.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) @@ -88,11 +92,12 @@ 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 - private var mBuilder: NotificationCompat.Builder? = null - private var notificationId: Int = 0 lateinit var roomToken: String lateinit var conversationName: String @@ -100,8 +105,10 @@ 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") + @Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod") override fun doWork(): Result { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) @@ -111,6 +118,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,27 +142,57 @@ 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 = sourceFileUri, + metaData = metaData, + remotePath = remotePath, + useConversationSubfolders = useConversationSubfolders + ) if (uploadSuccess) { - cancelNotification() - _uploadCompletedFlow.tryEmit(roomToken) - return Result.success() + // 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) + return Result.success() + } + Log.e(TAG, "Share operation failed after upload") + 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() - 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() - 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. @@ -165,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 @@ -174,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?, @@ -195,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, - metaData, this, - ncApiCoroutines, - useConversationSubfolders + ncApiCoroutines ) chunkedFileUploader!!.upload(file!!, mimeType, remotePath) } else { @@ -218,7 +244,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa file!!, ncApiCoroutines ) - .upload(sourceFileUri, fileName, remotePath, metaData) + .upload(sourceFileUri, fileName, remotePath, null) .blockingFirst() } @@ -248,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 { @@ -271,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 } @@ -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,163 +338,25 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } override fun onTransferProgress(percentage: Int) { - val progressUpdateNotification = mBuilder!! - .setProgress(HUNDRED_PERCENT, percentage, false) - .setContentText(getNotificationContentText(percentage)) - .build() + setProgressAsync(Data.Builder().putInt(PROGRESS_KEY, percentage).build()) + } - 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 { - 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() { @@ -475,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) } @@ -488,12 +388,15 @@ 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 - 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( @@ -541,24 +444,40 @@ 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) + .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 f439969e9d6..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 @@ -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,8 +209,18 @@ 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") + 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 bc57a4a163e..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 @@ -428,7 +428,8 @@ fun ChatView( onOpenThreadClick = callbacks.messageCallbacks.onOpenThreadClick, onQuotedMessageClick = handleQuotedMessageClick, onSystemMessageExpandClick = callbacks.messageCallbacks.onSystemMessageExpandClick, - onAvatarClick = callbacks.messageCallbacks.onAvatarClick + onAvatarClick = callbacks.messageCallbacks.onAvatarClick, + onCancelUpload = callbacks.messageCallbacks.onCancelUpload ) ) } diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaMessage.kt index d07efe2ca7c..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,21 +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.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 @@ -35,19 +46,37 @@ 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 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 +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 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 @@ -57,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( @@ -70,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 @@ -80,21 +165,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( @@ -106,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) } @@ -140,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( @@ -153,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)) { @@ -220,3 +321,298 @@ 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 hasCaption = typeContent.caption != null + + val mediaInset = 4.dp + val mediaShape = remember(message.incoming) { + shape(message.incoming) + } + + MessageScaffold( + uiMessage = message, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId, + includePadding = false, + captionText = typeContent.caption, + forceTimeOverlay = !hasCaption, + content = { + Column(modifier = Modifier.fillMaxWidth()) { + Box(modifier = Modifier.fillMaxWidth()) { + 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.fileName, + modifier = Modifier + .fillMaxWidth() + .blur(4.dp) + .padding(mediaInset) + .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.fileName, + 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) { + Box( + modifier = Modifier + .matchParentSize() + .background(Color.Black.copy(alpha = UPLOAD_SCRIM_ALPHA)) + ) + Box( + modifier = Modifier + .align(Alignment.Center) + .size(uploadSpinnerSize) + ) { + 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 + ) + } + } + } + } + + 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 + ) + } + } + } + ) +} + +/** + * 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( + 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 = "", + fileName = "photo.jpg", + caption = null, + 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 = "", + fileName = "document.pdf", + caption = null, + 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..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/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 { 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) + } + } +}