Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/src/main/java/com/nextcloud/talk/api/NcApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,8 @@ Observable<GenericOverall> 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
Expand Down
12 changes: 10 additions & 2 deletions app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,18 @@ interface ChatMessageRepository : LifecycleAwareManager {
referenceId: String
): Flow<Result<ChatMessage?>>

@Suppress("LongParameterList")
suspend fun addUploadPlaceholderMessage(
localFileUri: String,
fileName: String,
caption: String,
mimeType: String?,
fileSize: Long,
referenceId: String
): Flow<Result<ChatMessage?>>

suspend fun deleteTempMessageByReferenceId(referenceId: String)

suspend fun editChatMessage(credentials: String, url: String, text: String): Flow<Result<ChatOverallSingleMessage>>

suspend fun editTempChatMessage(message: ChatMessage, editedMessageText: String): Flow<Boolean>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result<ChatMessage?>> =
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鈫扞nt 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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uh, this gave me some headace while testing other branches.
I was wondering why enriching notifications always failed for one conversation.
There was always a followup notification with delete=true which removed the notifiaction on server and enriching resulted in 404.
Same symptom as described in #6330

Apparently i tested the current branch a few days ago and my lastReadMessage was set to 1963726147. So all notifications were immediately deleted, of course also when testing other branches.

  • This placeholderId must be changed!!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies 馃槼


Log.d(
TAG,
"addUploadPlaceholderMessage: referenceId=$referenceId " +
"placeholderId=$placeholderId caption=$caption"
)

val fileParams = hashMapOf<String?, String?>(
"type" to "file",
"name" to fileName,
"mimetype" to (mimeType ?: ""),
"size" to fileSize.toString(),
"path" to localFileUri
)
val messageParameters = hashMapOf<String?, HashMap<String?, String?>>(
"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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -155,7 +164,8 @@ fun ChatMessage.toUiModel(
isSilent = silent,
isExpandableParent = expandableParent,
expandableChildrenAmount = expandableChildrenAmount,
isHiddenByCollapse = hiddenByCollapse
isHiddenByCollapse = hiddenByCollapse,
referenceId = referenceId
)

fun ChatMessage.toScheduledMessageUiModel(
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
105 changes: 99 additions & 6 deletions app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -217,6 +222,28 @@ class ChatViewModel @AssistedInject constructor(

private var lobbyPollingJob: Job? = null

private val _uploadProgressMap = MutableStateFlow<Map<String, Int>>(emptyMap())
val uploadProgressMap: StateFlow<Map<String, Int>> = _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<Map<String, String>>(emptyMap())
val uploadedLocalPreviewMap: StateFlow<Map<String, String>> = _uploadedLocalPreviewMap

// Maps referenceId -> fileUri for cancellation support
private val uploadReferenceToUri = mutableMapOf<String, String>()

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) {
Expand Down Expand Up @@ -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<String, String?, Long> {
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)
}
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ class ShareOperationWorker(context: Context, workerParams: WorkerParameters) : W
filePath,
roomToken,
"10",
metaData
metaData,
"" // no reference id
)
.subscribeOn(Schedulers.io())
.blockingSubscribe(
Expand Down
Loading
Loading