Skip to content

Commit d6e3f69

Browse files
committed
Show file upload progress and placeholder media message in chat.
Signed-off-by: Jens Zalzala <jens@shakingearthdigital.com>
1 parent d01e2ce commit d6e3f69

13 files changed

Lines changed: 509 additions & 48 deletions

File tree

app/src/main/java/com/nextcloud/talk/api/NcApi.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,8 @@ Observable<GenericOverall> createRemoteShare(@Nullable @Header("Authorization")
386386
@Field("path") String remotePath,
387387
@Field("shareWith") String roomToken,
388388
@Field("shareType") String shareType,
389-
@Field("talkMetaData") String talkMetaData);
389+
@Field("talkMetaData") String talkMetaData,
390+
@Field("referenceId") String referenceId);
390391

391392
@FormUrlEncoded
392393
@PUT

app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ import com.nextcloud.talk.ui.StatusDrawable
157157
import com.nextcloud.talk.ui.chat.ChatView
158158
import com.nextcloud.talk.ui.chat.ChatViewCallbacks
159159
import com.nextcloud.talk.ui.chat.ChatViewState
160+
import com.nextcloud.talk.ui.chat.LocalUploadProgressProvider
160161
import com.nextcloud.talk.ui.dialog.DateTimeCompose
161162
import com.nextcloud.talk.ui.dialog.FileAttachmentPreviewFragment
162163
import com.nextcloud.talk.ui.dialog.GetPinnedOptionsDialog
@@ -605,10 +606,13 @@ class ChatActivity :
605606
val listState = rememberLazyListState()
606607
SideEffect { chatListState = listState }
607608

609+
val uploadProgressMap by chatViewModel.uploadProgressMap.collectAsStateWithLifecycle()
610+
608611
CompositionLocalProvider(
609612
LocalViewThemeUtils provides viewThemeUtils,
610613
LocalMessageUtils provides messageUtils,
611-
LocalOpenGraphFetcher provides { url -> chatViewModel.fetchOpenGraph(url) }
614+
LocalOpenGraphFetcher provides { url -> chatViewModel.fetchOpenGraph(url) },
615+
LocalUploadProgressProvider provides { refId -> uploadProgressMap[refId] }
612616
) {
613617
val isOneToOneConversation = uiState.isOneToOneConversation
614618
Log.d(TAG, "isOneToOneConversation=" + isOneToOneConversation)
@@ -635,7 +639,8 @@ class ChatActivity :
635639
onReactionClick = { messageId, emoji -> handleReactionClick(messageId, emoji) },
636640
onReactionLongClick = { messageId -> openReactionsDialog(messageId) },
637641
onOpenThreadClick = { messageId -> openThread(messageId.toLong()) },
638-
onLoadQuotedMessageClick = { messageId -> onLoadQuotedMessage(messageId) }
642+
onLoadQuotedMessageClick = { messageId -> onLoadQuotedMessage(messageId) },
643+
onCancelUpload = { referenceId -> chatViewModel.cancelUpload(referenceId) }
639644
),
640645
listState = listState
641646
)

app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,17 @@ interface ChatMessageRepository : LifecycleAwareManager {
119119
referenceId: String
120120
): Flow<Result<ChatMessage?>>
121121

122+
@Suppress("LongParameterList")
123+
suspend fun addUploadPlaceholderMessage(
124+
localFileUri: String,
125+
caption: String,
126+
mimeType: String?,
127+
fileSize: Long,
128+
referenceId: String
129+
): Flow<Result<ChatMessage?>>
130+
131+
suspend fun deleteTempMessageByReferenceId(referenceId: String)
132+
122133
suspend fun editChatMessage(credentials: String, url: String, text: String): Flow<Result<ChatOverallSingleMessage>>
123134

124135
suspend fun editTempChatMessage(message: ChatMessage, editedMessageText: String): Flow<Boolean>

app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,6 +860,77 @@ class OfflineFirstChatRepository @Inject constructor(
860860
}
861861
}
862862

863+
@Suppress("Detekt.TooGenericExceptionCaught", "LongMethod")
864+
override suspend fun addUploadPlaceholderMessage(
865+
localFileUri: String,
866+
caption: String,
867+
mimeType: String?,
868+
fileSize: Long,
869+
referenceId: String
870+
): Flow<Result<ChatMessage?>> =
871+
flow {
872+
try {
873+
val currentTimeMillis = System.currentTimeMillis()
874+
875+
// Use the first 15 hex chars so the value always fits in a signed Long.
876+
// Use referenceId.hashCode() as the placeholder id so that:
877+
// 1. It is unique per file even when multiple files are selected simultaneously
878+
// 2. It fits in an Int, so it survives the Long→Int cast in ChatMessageUi.id without
879+
// truncation, keeping DB lookups consistent when the message is tapped.
880+
// 3. It is always positive, because getMessagesEqualOrNewerThan expects it to be larger
881+
// than oldestMessageId
882+
@Suppress("MagicNumber")
883+
val placeholderId = (referenceId.hashCode().toLong() and 0x7FFF_FFFFL)
884+
885+
Log.d(TAG, "addUploadPlaceholderMessage: referenceId=$referenceId " +
886+
"placeholderId=$placeholderId caption=$caption")
887+
888+
val fileParams = hashMapOf<String?, String?>(
889+
"type" to "file",
890+
"name" to caption,
891+
"mimetype" to (mimeType ?: ""),
892+
"size" to fileSize.toString(),
893+
"path" to localFileUri
894+
)
895+
val messageParameters = hashMapOf<String?, HashMap<String?, String?>>(
896+
"file" to fileParams
897+
)
898+
899+
val entity = ChatMessageEntity(
900+
internalId = "$internalConversationId@_temp_$referenceId",
901+
internalConversationId = internalConversationId,
902+
id = placeholderId,
903+
threadId = threadId,
904+
message = "{file}",
905+
deleted = false,
906+
token = conversationModel.token,
907+
actorId = currentUser.userId!!,
908+
actorType = EnumActorTypeConverter().convertToString(Participant.ActorType.USERS),
909+
accountId = currentUser.id!!,
910+
messageParameters = messageParameters,
911+
messageType = "comment",
912+
parentMessageId = null,
913+
systemMessageType = ChatMessage.SystemMessageType.DUMMY,
914+
replyable = false,
915+
timestamp = currentTimeMillis / MILLIES,
916+
expirationTimestamp = 0,
917+
actorDisplayName = currentUser.displayName!!,
918+
referenceId = referenceId,
919+
isTemporary = true,
920+
sendStatus = SendStatus.PENDING,
921+
silent = false
922+
)
923+
chatDao.upsertChatMessage(entity)
924+
} catch (e: Exception) {
925+
Log.e(TAG, "addUploadPlaceholderMessage failed for referenceId=$referenceId", e)
926+
emit(Result.failure(e))
927+
}
928+
}
929+
930+
override suspend fun deleteTempMessageByReferenceId(referenceId: String) {
931+
chatDao.deleteTempChatMessages(internalConversationId, listOf(referenceId))
932+
}
933+
863934
@Suppress("Detekt.TooGenericExceptionCaught")
864935
override suspend fun editChatMessage(
865936
credentials: String,

app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ data class ChatMessageUi(
4343
val reactions: List<MessageReactionUi> = emptyList(),
4444
val isEdited: Boolean = false,
4545
val parentMessage: ChatMessageUi? = null,
46-
val replyable: Boolean = false
46+
val replyable: Boolean = false,
47+
val referenceId: String? = null
4748
)
4849

4950
data class MessageReactionUi(val emoji: String, val amount: Int, val isSelfReaction: Boolean)
@@ -56,6 +57,13 @@ sealed interface MessageTypeContent {
5657

5758
data class Media(val previewUrl: String?, val drawableResourceId: Int) : MessageTypeContent
5859

60+
data class UploadingMedia(
61+
val localFileUri: String,
62+
val caption: String,
63+
val mimeType: String?,
64+
val drawableResourceId: Int
65+
) : MessageTypeContent
66+
5967
data class Geolocation(val id: String, val name: String, val lat: Double, val lon: Double) : MessageTypeContent
6068

6169
data class Poll(val pollId: String, val pollName: String) : MessageTypeContent
@@ -125,7 +133,8 @@ fun ChatMessage.toUiModel(
125133
lastCommonReadMessageId = 0,
126134
parentMessage = null
127135
),
128-
replyable = replyable
136+
replyable = replyable,
137+
referenceId = referenceId
129138
)
130139

131140
private fun ChatMessage.normalizeMessageParameters(): Map<String, Map<String, String>> =
@@ -184,6 +193,8 @@ fun getMessageTypeContent(user: User, message: ChatMessage): MessageTypeContent?
184193
MessageTypeContent.SystemMessage
185194
} else if (message.isVoiceMessage) {
186195
getVoiceContent(message)
196+
} else if (message.hasFileAttachment && message.isTemporary) {
197+
getUploadingMediaContent(message)
187198
} else if (message.hasFileAttachment) {
188199
getMediaContent(user, message)
189200
} else if (message.hasGeoLocation) {
@@ -198,6 +209,17 @@ fun getMessageTypeContent(user: User, message: ChatMessage): MessageTypeContent?
198209
?: MessageTypeContent.RegularText
199210
}
200211

212+
fun getUploadingMediaContent(message: ChatMessage): MessageTypeContent.UploadingMedia {
213+
val mimetype = message.fileParameters.mimetype
214+
val drawableResourceId = DrawableUtils.getDrawableResourceIdForMimeType(mimetype)
215+
return MessageTypeContent.UploadingMedia(
216+
localFileUri = message.fileParameters.path.orEmpty(),
217+
caption = message.fileParameters.name.orEmpty(),
218+
mimeType = mimetype.takeIf { !it.isNullOrEmpty() },
219+
drawableResourceId = drawableResourceId
220+
)
221+
}
222+
201223
fun getMediaContent(user: User, message: ChatMessage): MessageTypeContent.Media {
202224
val previewUrl = getPreviewImageUrl(user, message)
203225
val mimetype = message.fileParameters.mimetype

app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ package com.nextcloud.talk.chat.viewmodels
1010
import android.content.Context
1111
import android.net.Uri
1212
import android.os.Bundle
13+
import android.provider.OpenableColumns
1314
import android.util.Log
1415
import androidx.lifecycle.DefaultLifecycleObserver
1516
import androidx.lifecycle.LifecycleOwner
@@ -37,6 +38,9 @@ import com.nextcloud.talk.data.database.mappers.toDomainModel
3738
import com.nextcloud.talk.data.database.model.ChatMessageEntity
3839
import com.nextcloud.talk.data.user.model.User
3940
import com.nextcloud.talk.extensions.toIntOrZero
41+
import androidx.lifecycle.asFlow
42+
import androidx.work.WorkManager
43+
import com.nextcloud.talk.application.NextcloudTalkApplication
4044
import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
4145
import com.nextcloud.talk.models.MessageDraft
4246
import com.nextcloud.talk.models.domain.ConversationModel
@@ -103,7 +107,9 @@ import retrofit2.HttpException
103107
import java.io.File
104108
import java.io.IOException
105109
import java.time.LocalDate
110+
import java.util.UUID
106111
import javax.inject.Inject
112+
import androidx.core.net.toUri
107113

108114
@Suppress("TooManyFunctions", "LongParameterList")
109115
class ChatViewModel @AssistedInject constructor(
@@ -148,6 +154,21 @@ class ChatViewModel @AssistedInject constructor(
148154
var hiddenUpcomingEvent: String? = null
149155
lateinit var participantPermissions: ParticipantPermissions
150156

157+
private val _uploadProgressMap = MutableStateFlow<Map<String, Int>>(emptyMap())
158+
val uploadProgressMap: StateFlow<Map<String, Int>> = _uploadProgressMap
159+
160+
// Maps referenceId -> fileUri for cancellation support
161+
private val uploadReferenceToUri = mutableMapOf<String, String>()
162+
163+
fun cancelUpload(referenceId: String) {
164+
val fileUri = uploadReferenceToUri.remove(referenceId) ?: return
165+
WorkManager.getInstance(NextcloudTalkApplication.sharedApplication!!).cancelUniqueWork(fileUri)
166+
viewModelScope.launch {
167+
chatRepository.deleteTempMessageByReferenceId(referenceId)
168+
}
169+
_uploadProgressMap.update { it - referenceId }
170+
}
171+
151172
fun getChatRepository(): ChatMessageRepository = chatRepository
152173

153174
override fun onResume(owner: LifecycleOwner) {
@@ -1413,23 +1434,82 @@ class ChatViewModel @AssistedInject constructor(
14131434
metaDataMap["caption"] = caption
14141435
}
14151436

1437+
val referenceId = UUID.randomUUID().toString().replace("-", "")
1438+
metaDataMap["referenceId"] = referenceId
1439+
14161440
val metaData = Gson().toJson(metaDataMap)
14171441

14181442
room = if (roomToken == "") chatRoomToken else roomToken
14191443

14201444
try {
14211445
require(fileUri.isNotEmpty())
1422-
UploadAndShareFilesWorker.upload(
1446+
1447+
if (!isVoiceMessage) {
1448+
val (fileName, mimeType, fileSize) = resolveFileInfo(fileUri)
1449+
viewModelScope.launch {
1450+
chatRepository.addUploadPlaceholderMessage(
1451+
localFileUri = fileUri,
1452+
caption = caption.ifEmpty { fileName },
1453+
mimeType = mimeType,
1454+
fileSize = fileSize,
1455+
referenceId = referenceId
1456+
).collect {}
1457+
}
1458+
}
1459+
1460+
val internalConversationId = "${currentUser.id}@$chatRoomToken"
1461+
val workerId = UploadAndShareFilesWorker.upload(
14231462
fileUri,
14241463
room,
14251464
displayName,
1426-
metaData
1465+
metaData,
1466+
referenceId,
1467+
internalConversationId
14271468
)
1469+
1470+
if (!isVoiceMessage) {
1471+
uploadReferenceToUri[referenceId] = fileUri
1472+
observeUploadProgress(workerId, referenceId)
1473+
}
14281474
} catch (e: IllegalArgumentException) {
14291475
Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e)
14301476
}
14311477
}
14321478

1479+
private fun resolveFileInfo(fileUri: String): Triple<String, String?, Long> {
1480+
val uri = fileUri.toUri()
1481+
val mimeType = NextcloudTalkApplication.sharedApplication!!.contentResolver.getType(uri)
1482+
val cursor = NextcloudTalkApplication.sharedApplication!!.contentResolver.query(uri, null, null, null, null)
1483+
cursor?.use {
1484+
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
1485+
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
1486+
if (it.moveToFirst()) {
1487+
val name = if (nameIndex >= 0) it.getString(nameIndex).orEmpty() else uri.lastPathSegment.orEmpty()
1488+
val size = if (sizeIndex >= 0) it.getLong(sizeIndex) else 0L
1489+
return Triple(name, mimeType, size)
1490+
}
1491+
}
1492+
return Triple(uri.lastPathSegment.orEmpty(), mimeType, 0L)
1493+
}
1494+
1495+
private fun observeUploadProgress(workerId: UUID, referenceId: String) {
1496+
WorkManager.getInstance(NextcloudTalkApplication.sharedApplication!!)
1497+
.getWorkInfoByIdLiveData(workerId)
1498+
.asFlow()
1499+
.onEach { workInfo ->
1500+
if (workInfo == null) return@onEach
1501+
val progress = workInfo.progress.getInt(UploadAndShareFilesWorker.PROGRESS_KEY, -1)
1502+
if (progress >= 0) {
1503+
_uploadProgressMap.update { it + (referenceId to progress) }
1504+
}
1505+
if (workInfo.state.isFinished) {
1506+
_uploadProgressMap.update { it - referenceId }
1507+
uploadReferenceToUri.remove(referenceId)
1508+
}
1509+
}
1510+
.launchIn(viewModelScope)
1511+
}
1512+
14331513
fun postToRecordTouchObserver(float: Float) {
14341514
_recordTouchObserver.postValue(float)
14351515
}

app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ class ShareOperationWorker(context: Context, workerParams: WorkerParameters) : W
5252
filePath,
5353
roomToken,
5454
"10",
55-
metaData
55+
metaData,
56+
"" // no reference id
5657
)
5758
.subscribeOn(Schedulers.io())
5859
.blockingSubscribe(

0 commit comments

Comments
 (0)