diff --git a/app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/13.json b/app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/13.json new file mode 100644 index 000000000..2e2345001 --- /dev/null +++ b/app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/13.json @@ -0,0 +1,749 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "a521f027909f69f4c7d1855f84a2e67f", + "entities": [ + { + "tableName": "User", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `username` TEXT, `baseUrl` TEXT, `token` TEXT, `displayName` TEXT, `pushConfigurationState` TEXT, `capabilities` TEXT, `serverVersion` TEXT DEFAULT '', `clientCertificate` TEXT, `externalSignalingServer` TEXT, `current` INTEGER NOT NULL, `scheduledForDeletion` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pushConfigurationState", + "columnName": "pushConfigurationState", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "capabilities", + "columnName": "capabilities", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverVersion", + "columnName": "serverVersion", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "clientCertificate", + "columnName": "clientCertificate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalSignalingServer", + "columnName": "externalSignalingServer", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "current", + "columnName": "current", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledForDeletion", + "columnName": "scheduledForDeletion", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ArbitraryStorage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountIdentifier` INTEGER NOT NULL, `key` TEXT NOT NULL, `object` TEXT, `value` TEXT, PRIMARY KEY(`accountIdentifier`, `key`))", + "fields": [ + { + "fieldPath": "accountIdentifier", + "columnName": "accountIdentifier", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "storageObject", + "columnName": "object", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountIdentifier", + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`internalId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `token` TEXT NOT NULL, `displayName` TEXT NOT NULL, `actorId` TEXT NOT NULL, `actorType` TEXT NOT NULL, `avatarVersion` TEXT NOT NULL, `callFlag` INTEGER NOT NULL, `callRecording` INTEGER NOT NULL, `callStartTime` INTEGER NOT NULL, `canDeleteConversation` INTEGER NOT NULL, `canLeaveConversation` INTEGER NOT NULL, `canStartCall` INTEGER NOT NULL, `description` TEXT NOT NULL, `hasCall` INTEGER NOT NULL, `hasPassword` INTEGER NOT NULL, `isCustomAvatar` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `lastActivity` INTEGER NOT NULL, `lastCommonReadMessage` INTEGER NOT NULL, `lastMessage` TEXT, `lastPing` INTEGER NOT NULL, `lastReadMessage` INTEGER NOT NULL, `lobbyState` TEXT NOT NULL, `lobbyTimer` INTEGER NOT NULL, `messageExpiration` INTEGER NOT NULL, `name` TEXT NOT NULL, `notificationCalls` INTEGER NOT NULL, `notificationLevel` TEXT NOT NULL, `objectType` TEXT NOT NULL, `participantType` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `readOnly` TEXT NOT NULL, `recordingConsent` INTEGER NOT NULL, `remoteServer` TEXT, `remoteToken` TEXT, `sessionId` TEXT NOT NULL, `status` TEXT, `statusClearAt` INTEGER, `statusIcon` TEXT, `statusMessage` TEXT, `type` TEXT NOT NULL, `unreadMention` INTEGER NOT NULL, `unreadMentionDirect` INTEGER NOT NULL, `unreadMessages` INTEGER NOT NULL, `hasArchived` INTEGER NOT NULL, PRIMARY KEY(`internalId`), FOREIGN KEY(`accountId`) REFERENCES `User`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "internalId", + "columnName": "internalId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorId", + "columnName": "actorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorType", + "columnName": "actorType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatarVersion", + "columnName": "avatarVersion", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "callFlag", + "columnName": "callFlag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "callRecording", + "columnName": "callRecording", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "callStartTime", + "columnName": "callStartTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDeleteConversation", + "columnName": "canDeleteConversation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canLeaveConversation", + "columnName": "canLeaveConversation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canStartCall", + "columnName": "canStartCall", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasCall", + "columnName": "hasCall", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPassword", + "columnName": "hasPassword", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasCustomAvatar", + "columnName": "isCustomAvatar", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCommonReadMessage", + "columnName": "lastCommonReadMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessage", + "columnName": "lastMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastPing", + "columnName": "lastPing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessage", + "columnName": "lastReadMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lobbyState", + "columnName": "lobbyState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lobbyTimer", + "columnName": "lobbyTimer", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageExpiration", + "columnName": "messageExpiration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationCalls", + "columnName": "notificationCalls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLevel", + "columnName": "notificationLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectType", + "columnName": "objectType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantType", + "columnName": "participantType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conversationReadOnlyState", + "columnName": "readOnly", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordingConsentRequired", + "columnName": "recordingConsent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteServer", + "columnName": "remoteServer", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteToken", + "columnName": "remoteToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusClearAt", + "columnName": "statusClearAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "statusIcon", + "columnName": "statusIcon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusMessage", + "columnName": "statusMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unreadMention", + "columnName": "unreadMention", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMentionDirect", + "columnName": "unreadMentionDirect", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMessages", + "columnName": "unreadMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasArchived", + "columnName": "hasArchived", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "internalId" + ] + }, + "indices": [ + { + "name": "index_Conversations_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Conversations_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "User", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`internalId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `token` TEXT NOT NULL, `id` INTEGER NOT NULL, `internalConversationId` TEXT NOT NULL, `actorDisplayName` TEXT NOT NULL, `message` TEXT NOT NULL, `actorId` TEXT NOT NULL, `actorType` TEXT NOT NULL, `deleted` INTEGER NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `isReplyable` INTEGER NOT NULL, `isTemporary` INTEGER NOT NULL, `lastEditActorDisplayName` TEXT, `lastEditActorId` TEXT, `lastEditActorType` TEXT, `lastEditTimestamp` INTEGER, `markdown` INTEGER, `messageParameters` TEXT, `messageType` TEXT NOT NULL, `parent` INTEGER, `reactions` TEXT, `reactionsSelf` TEXT, `referenceId` TEXT, `sendingFailed` INTEGER NOT NULL, `silent` INTEGER NOT NULL, `systemMessage` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`internalId`), FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "internalId", + "columnName": "internalId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalConversationId", + "columnName": "internalConversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorDisplayName", + "columnName": "actorDisplayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorId", + "columnName": "actorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorType", + "columnName": "actorType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTimestamp", + "columnName": "expirationTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "replyable", + "columnName": "isReplyable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTemporary", + "columnName": "isTemporary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEditActorDisplayName", + "columnName": "lastEditActorDisplayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastEditActorId", + "columnName": "lastEditActorId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastEditActorType", + "columnName": "lastEditActorType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastEditTimestamp", + "columnName": "lastEditTimestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "renderMarkdown", + "columnName": "markdown", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "messageParameters", + "columnName": "messageParameters", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "messageType", + "columnName": "messageType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentMessageId", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "reactions", + "columnName": "reactions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reactionsSelf", + "columnName": "reactionsSelf", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referenceId", + "columnName": "referenceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sendingFailed", + "columnName": "sendingFailed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "silent", + "columnName": "silent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "systemMessageType", + "columnName": "systemMessage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "internalId" + ] + }, + "indices": [ + { + "name": "index_ChatMessages_internalId", + "unique": true, + "columnNames": [ + "internalId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ChatMessages_internalId` ON `${TABLE_NAME}` (`internalId`)" + }, + { + "name": "index_ChatMessages_internalConversationId", + "unique": false, + "columnNames": [ + "internalConversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatMessages_internalConversationId` ON `${TABLE_NAME}` (`internalConversationId`)" + } + ], + "foreignKeys": [ + { + "table": "Conversations", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "internalConversationId" + ], + "referencedColumns": [ + "internalId" + ] + } + ] + }, + { + "tableName": "ChatBlocks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalConversationId` TEXT NOT NULL, `accountId` INTEGER, `token` TEXT, `oldestMessageId` INTEGER NOT NULL, `newestMessageId` INTEGER NOT NULL, `hasHistory` INTEGER NOT NULL, FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalConversationId", + "columnName": "internalConversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oldestMessageId", + "columnName": "oldestMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "newestMessageId", + "columnName": "newestMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasHistory", + "columnName": "hasHistory", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ChatBlocks_internalConversationId", + "unique": false, + "columnNames": [ + "internalConversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatBlocks_internalConversationId` ON `${TABLE_NAME}` (`internalConversationId`)" + } + ], + "foreignKeys": [ + { + "table": "Conversations", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "internalConversationId" + ], + "referencedColumns": [ + "internalId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a521f027909f69f4c7d1855f84a2e67f')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingTextMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingTextMessageViewHolder.kt index b4883ffb2..9fab9306c 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingTextMessageViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingTextMessageViewHolder.kt @@ -218,7 +218,6 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) : ) binding.messageQuote.quotedChatMessageView.setOnClickListener { - val chatActivity = commonMessageInterface as ChatActivity chatActivity.jumpToQuotedMessage(parentChatMessage) } } catch (e: Exception) { diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingTextMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingTextMessageViewHolder.kt index b056ec0d8..07ccb5cd3 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingTextMessageViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingTextMessageViewHolder.kt @@ -23,6 +23,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.databinding.ItemCustomOutcomingTextMessageBinding import com.nextcloud.talk.models.json.chat.ReadStatus import com.nextcloud.talk.ui.theme.ViewThemeUtils @@ -58,8 +59,12 @@ class OutcomingTextMessageViewHolder(itemView: View) : @Inject lateinit var dateUtils: DateUtils + @Inject + lateinit var networkMonitor: NetworkMonitor + lateinit var commonMessageInterface: CommonMessageInterface + @Suppress("Detekt.LongMethod") override fun onBind(message: ChatMessage) { super.onBind(message) sharedApplication!!.componentApplication.inject(this) @@ -68,6 +73,7 @@ class OutcomingTextMessageViewHolder(itemView: View) : layoutParams.isWrapBefore = false var textSize = context.resources.getDimension(R.dimen.chat_text_size) viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT) + var processedMessageText = messageUtils.enrichChatMessageText( binding.messageText.context, message, @@ -114,7 +120,27 @@ class OutcomingTextMessageViewHolder(itemView: View) : binding.messageQuote.quotedChatMessageView.visibility = View.GONE } - setReadStatus(message.readStatus) + binding.checkMark.visibility = View.INVISIBLE + binding.sendingProgress.visibility = View.GONE + + if (message.sendingFailed) { + updateStatus(R.drawable.baseline_error_outline_24, context.resources?.getString(R.string.nc_message_failed)) + } else if (message.isTemporary) { + updateStatus(R.drawable.baseline_schedule_24, context.resources?.getString(R.string.nc_message_sending)) + } else if (message.readStatus == ReadStatus.READ) { + updateStatus(R.drawable.ic_check_all, context.resources?.getString(R.string.nc_message_read)) + } else if (message.readStatus == ReadStatus.SENT) { + updateStatus(R.drawable.ic_check, context.resources?.getString(R.string.nc_message_sent)) + } + + CoroutineScope(Dispatchers.Main).launch { + if (message.isTemporary && !networkMonitor.isOnline.first()) { + updateStatus( + R.drawable.ic_signal_wifi_off_white_24dp, + context.resources?.getString(R.string.nc_message_offline) + ) + } + } itemView.setTag(R.string.replyable_message_view_tag, message.replyable) @@ -129,27 +155,16 @@ class OutcomingTextMessageViewHolder(itemView: View) : ) } - private fun setReadStatus(readStatus: Enum) { - val readStatusDrawableInt = when (readStatus) { - ReadStatus.READ -> R.drawable.ic_check_all - ReadStatus.SENT -> R.drawable.ic_check - else -> null - } - - val readStatusContentDescriptionString = when (readStatus) { - ReadStatus.READ -> context.resources?.getString(R.string.nc_message_read) - ReadStatus.SENT -> context.resources?.getString(R.string.nc_message_sent) - else -> null - } - - readStatusDrawableInt?.let { drawableInt -> + private fun updateStatus(readStatusDrawableInt: Int, description: String?) { + binding.sendingProgress.visibility = View.GONE + binding.checkMark.visibility = View.VISIBLE + readStatusDrawableInt.let { drawableInt -> ResourcesCompat.getDrawable(context.resources, drawableInt, null)?.let { binding.checkMark.setImageDrawable(it) viewThemeUtils.talk.themeMessageCheckMark(binding.checkMark) } } - - binding.checkMark.contentDescription = readStatusContentDescriptionString + binding.checkMark.contentDescription = description } private fun longClickOnReaction(chatMessage: ChatMessage) { @@ -180,7 +195,7 @@ class OutcomingTextMessageViewHolder(itemView: View) : ).first() } - parentChatMessage!!.activeUser = message.activeUser + parentChatMessage.activeUser = message.activeUser parentChatMessage.imageUrl?.let { binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE binding.messageQuote.quotedMessageImage.load(it) { @@ -207,7 +222,6 @@ class OutcomingTextMessageViewHolder(itemView: View) : viewThemeUtils.talk.colorOutgoingQuoteBackground(binding.messageQuote.quoteColoredView) binding.messageQuote.quotedChatMessageView.setOnClickListener { - val chatActivity = commonMessageInterface as ChatActivity chatActivity.jumpToQuotedMessage(parentChatMessage) } } catch (e: Exception) { diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/TalkMessagesListAdapter.java b/app/src/main/java/com/nextcloud/talk/adapters/messages/TalkMessagesListAdapter.java index 6eda1ff98..3d7609133 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/TalkMessagesListAdapter.java +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/TalkMessagesListAdapter.java @@ -68,9 +68,6 @@ public class TalkMessagesListAdapter extends MessagesListAda } else if (holder instanceof SystemMessageViewHolder holderInstance) { holderInstance.assignSystemMessageInterface(chatActivity); - } else if (holder instanceof TemporaryMessageViewHolder holderInstance) { - holderInstance.assignTemporaryMessageInterface(chatActivity); - } else if (holder instanceof IncomingDeckCardViewHolder holderInstance) { holderInstance.assignCommonMessageInterface(chatActivity); } else if (holder instanceof OutcomingDeckCardViewHolder holderInstance) { diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/TemporaryMessageInterface.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/TemporaryMessageInterface.kt deleted file mode 100644 index 44dab23af..000000000 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/TemporaryMessageInterface.kt +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2024 Julius Linus - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package com.nextcloud.talk.adapters.messages - -interface TemporaryMessageInterface { - fun editTemporaryMessage(id: Int, newMessage: String) - fun deleteTemporaryMessage(id: Int) -} diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/TemporaryMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/TemporaryMessageViewHolder.kt deleted file mode 100644 index 73552c8c7..000000000 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/TemporaryMessageViewHolder.kt +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2024 Julius Linus - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package com.nextcloud.talk.adapters.messages - -import android.content.Context -import android.util.Log -import android.view.View -import androidx.core.content.res.ResourcesCompat -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import autodagger.AutoInjector -import coil.load -import com.nextcloud.android.common.ui.theme.utils.ColorRole -import com.nextcloud.talk.R -import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.chat.ChatActivity -import com.nextcloud.talk.chat.data.model.ChatMessage -import com.nextcloud.talk.databinding.ItemTemporaryMessageBinding -import com.nextcloud.talk.ui.theme.ViewThemeUtils -import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.DisplayUtils -import com.nextcloud.talk.utils.message.MessageUtils -import com.stfalcon.chatkit.messages.MessagesListAdapter -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import javax.inject.Inject - -@AutoInjector(NextcloudTalkApplication::class) -class TemporaryMessageViewHolder(outgoingView: View, payload: Any) : - MessagesListAdapter.OutcomingMessageViewHolder(outgoingView) { - - private val binding: ItemTemporaryMessageBinding = ItemTemporaryMessageBinding.bind(outgoingView) - - @Inject - lateinit var viewThemeUtils: ViewThemeUtils - - @Inject - lateinit var context: Context - - @Inject - lateinit var messageUtils: MessageUtils - - lateinit var temporaryMessageInterface: TemporaryMessageInterface - var isEditing = false - - override fun onBind(message: ChatMessage) { - super.onBind(message) - NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - - viewThemeUtils.platform.colorImageView(binding.tempMsgEdit, ColorRole.PRIMARY) - viewThemeUtils.platform.colorImageView(binding.tempMsgDelete, ColorRole.PRIMARY) - - binding.tempMsgEdit.setOnClickListener { - isEditing = !isEditing - if (isEditing) { - binding.tempMsgEdit.setImageDrawable( - ResourcesCompat.getDrawable( - context.resources, - R.drawable.ic_check, - null - ) - ) - binding.messageEdit.visibility = View.VISIBLE - binding.messageEdit.requestFocus() - ViewCompat.getWindowInsetsController(binding.root)?.show(WindowInsetsCompat.Type.ime()) - binding.messageEdit.setText(binding.messageText.text) - binding.messageText.visibility = View.GONE - } else { - binding.tempMsgEdit.setImageDrawable( - ResourcesCompat.getDrawable( - context.resources, - R.drawable.ic_edit, - null - ) - ) - binding.messageEdit.visibility = View.GONE - binding.messageText.visibility = View.VISIBLE - val newMessage = binding.messageEdit.text.toString() - message.message = newMessage - temporaryMessageInterface.editTemporaryMessage(message.tempMessageId, newMessage) - } - } - - binding.tempMsgDelete.setOnClickListener { - temporaryMessageInterface.deleteTemporaryMessage(message.tempMessageId) - } - - // parent message handling - if (message.parentMessageId != null && message.parentMessageId!! > 0) { - processParentMessage(message) - binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE - } else { - binding.messageQuote.quotedChatMessageView.visibility = View.GONE - } - - val bgBubbleColor = bubble.resources.getColor(R.color.bg_message_list_incoming_bubble, null) - val layout = R.drawable.shape_outcoming_message - val bubbleDrawable = DisplayUtils.getMessageSelector( - bgBubbleColor, - ResourcesCompat.getColor(bubble.resources, R.color.transparent, null), - bgBubbleColor, - layout - ) - ViewCompat.setBackground(bubble, bubbleDrawable) - } - - @Suppress("Detekt.TooGenericExceptionCaught") - private fun processParentMessage(message: ChatMessage) { - if (message.parentMessageId != null && !message.isDeleted) { - CoroutineScope(Dispatchers.Main).launch { - try { - val chatActivity = temporaryMessageInterface as ChatActivity - val urlForChatting = ApiUtils.getUrlForChat( - chatActivity.chatApiVersion, - chatActivity.conversationUser?.baseUrl, - chatActivity.roomToken - ) - - val parentChatMessage = withContext(Dispatchers.IO) { - chatActivity.chatViewModel.getMessageById( - urlForChatting, - chatActivity.currentConversation!!, - message.parentMessageId!! - ).first() - } - - parentChatMessage.activeUser = message.activeUser - parentChatMessage.imageUrl?.let { - binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE - val placeholder = ResourcesCompat.getDrawable( - context.resources, - R.drawable.ic_mimetype_image, - null - ) - binding.messageQuote.quotedMessageImage.setImageDrawable(placeholder) - binding.messageQuote.quotedMessageImage.load(it) { - addHeader( - "Authorization", - ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!! - ) - } - } ?: run { - binding.messageQuote.quotedMessageImage.visibility = View.GONE - } - binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName - ?: context.getText(R.string.nc_nick_guest) - binding.messageQuote.quotedMessage.text = messageUtils - .enrichChatReplyMessageText( - binding.messageQuote.quotedMessage.context, - parentChatMessage, - false, - viewThemeUtils - ) - - viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage) - viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor) - viewThemeUtils.talk.colorOutgoingQuoteBackground(binding.messageQuote.quoteColoredView) - - binding.messageQuote.quotedChatMessageView.setOnClickListener { - val chatActivity = temporaryMessageInterface as ChatActivity - chatActivity.jumpToQuotedMessage(parentChatMessage) - } - } catch (e: Exception) { - Log.d(TAG, "Error when processing parent message in view holder", e) - } - } - } - } - - fun assignTemporaryMessageInterface(temporaryMessageInterface: TemporaryMessageInterface) { - this.temporaryMessageInterface = temporaryMessageInterface - } - - override fun viewDetached() { - // unused atm - } - - override fun viewAttached() { - // unused atm - } - - override fun viewRecycled() { - // unused atm - } - - companion object { - private val TAG = TemporaryMessageViewHolder::class.java.simpleName - } -} 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 a404d973a..ca5d7afe5 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApi.java +++ b/app/src/main/java/com/nextcloud/talk/api/NcApi.java @@ -344,18 +344,14 @@ public interface NcApi { @FormUrlEncoded @POST - Observable sendChatMessage(@Header("Authorization") String authorization, + Observable sendChatMessage(@Header("Authorization") String authorization, @Url String url, @Field("message") CharSequence message, @Field("actorDisplayName") String actorDisplayName, @Field("replyTo") Integer replyTo, - @Field("silent") Boolean sendWithoutNotification); - - @FormUrlEncoded - @PUT - Observable editChatMessage(@Header("Authorization") String authorization, - @Url String url, - @Field("message") String message); + @Field("silent") Boolean sendWithoutNotification, + @Field("referenceId") String referenceId + ); @GET Observable> getSharedItems( diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt index 26e67f261..af291d4cf 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt +++ b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt @@ -8,6 +8,7 @@ package com.nextcloud.talk.api import com.nextcloud.talk.models.json.autocomplete.AutocompleteOverall +import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.participants.AddParticipantOverall @@ -30,6 +31,7 @@ import retrofit2.http.Query import retrofit2.http.QueryMap import retrofit2.http.Url +@Suppress("TooManyFunctions") interface NcApiCoroutines { @GET @JvmSuppressWildcards @@ -122,6 +124,27 @@ interface NcApiCoroutines { @DELETE suspend fun unarchiveConversation(@Header("Authorization") authorization: String, @Url url: String): GenericOverall + @Suppress("LongParameterList") + @FormUrlEncoded + @POST + suspend fun sendChatMessage( + @Header("Authorization") authorization: String, + @Url url: String, + @Field("message") message: String, + @Field("actorDisplayName") actorDisplayName: String, + @Field("replyTo") replyTo: Int, + @Field("silent") sendWithoutNotification: Boolean, + @Field("referenceId") referenceId: String + ): ChatOverallSingleMessage + + @FormUrlEncoded + @PUT + suspend fun editChatMessage( + @Header("Authorization") authorization: String, + @Url url: String, + @Field("message") message: String + ): ChatOverallSingleMessage + @FormUrlEncoded @POST suspend fun banActor( 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 5432675aa..1f04945bd 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -111,8 +111,6 @@ import com.nextcloud.talk.adapters.messages.PreviewMessageViewHolder import com.nextcloud.talk.adapters.messages.SystemMessageInterface import com.nextcloud.talk.adapters.messages.SystemMessageViewHolder import com.nextcloud.talk.adapters.messages.TalkMessagesListAdapter -import com.nextcloud.talk.adapters.messages.TemporaryMessageInterface -import com.nextcloud.talk.adapters.messages.TemporaryMessageViewHolder import com.nextcloud.talk.adapters.messages.UnreadNoticeMessageViewHolder import com.nextcloud.talk.adapters.messages.VoiceMessageInterface import com.nextcloud.talk.api.NcApi @@ -154,6 +152,7 @@ import com.nextcloud.talk.ui.dialog.FileAttachmentPreviewFragment import com.nextcloud.talk.ui.dialog.MessageActionsDialog import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment import com.nextcloud.talk.ui.dialog.ShowReactionsDialog +import com.nextcloud.talk.ui.dialog.TempMessageActionsDialog import com.nextcloud.talk.ui.recyclerview.MessageSwipeActions import com.nextcloud.talk.ui.recyclerview.MessageSwipeCallback import com.nextcloud.talk.utils.ApiUtils @@ -208,7 +207,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode -import retrofit2.HttpException import java.io.File import java.io.IOException import java.net.HttpURLConnection @@ -231,8 +229,7 @@ class ChatActivity : CommonMessageInterface, PreviewMessageInterface, SystemMessageInterface, - CallStartedMessageInterface, - TemporaryMessageInterface { + CallStartedMessageInterface { var active = false @@ -319,7 +316,6 @@ class ChatActivity : var startCallFromNotification: Boolean = false var startCallFromRoomSwitch: Boolean = false - // lateinit var roomId: String var voiceOnly: Boolean = true private lateinit var path: String @@ -452,6 +448,7 @@ class ChatActivity : chatViewModel = ViewModelProvider(this, viewModelFactory)[ChatViewModel::class.java] messageInputViewModel = ViewModelProvider(this, viewModelFactory)[MessageInputViewModel::class.java] + messageInputViewModel.setData(chatViewModel.getChatRepository()) this.lifecycleScope.launch { delay(DELAY_TO_SHOW_PROGRESS_BAR) @@ -524,7 +521,6 @@ class ChatActivity : private fun handleIntent(intent: Intent) { val extras: Bundle? = intent.extras - // roomId = extras?.getString(KEY_ROOM_ID).orEmpty() roomToken = extras?.getString(KEY_ROOM_TOKEN).orEmpty() sharedText = extras?.getString(BundleKeys.KEY_SHARED_TEXT).orEmpty() @@ -583,35 +579,6 @@ class ChatActivity : private fun initObservers() { Log.d(TAG, "initObservers Called") - messageInputViewModel.messageQueueFlow.observe(this) { list -> - list.forEachIndexed { _, qMsg -> - val temporaryChatMessage = ChatMessage() - temporaryChatMessage.jsonMessageId = TEMPORARY_MESSAGE_ID_INT - temporaryChatMessage.actorId = "-3" - temporaryChatMessage.timestamp = System.currentTimeMillis() / ONE_SECOND_IN_MILLIS - temporaryChatMessage.message = qMsg.message.toString() - temporaryChatMessage.tempMessageId = qMsg.id - temporaryChatMessage.isTempMessage = true - temporaryChatMessage.parentMessageId = qMsg.replyTo!!.toLong() - val pos = adapter?.getMessagePositionById(qMsg.replyTo.toString()) - adapter?.addToStart(temporaryChatMessage, true) - adapter?.notifyDataSetChanged() - } - } - - messageInputViewModel.messageQueueSizeFlow.observe(this) { size -> - if (size == 0) { - var i = 0 - var pos = adapter?.getMessagePositionById(TEMPORARY_MESSAGE_ID_STRING) - while (pos != null && pos > -1) { - adapter?.items?.removeAt(pos) - i++ - pos = adapter?.getMessagePositionById(TEMPORARY_MESSAGE_ID_STRING) - } - adapter?.notifyDataSetChanged() - } - } - this.lifecycleScope.launch { chatViewModel.getConversationFlow .onEach { conversationModel -> @@ -719,7 +686,6 @@ class ChatActivity : withCredentials = credentials!!, withUrl = urlForChatting ) - messageInputViewModel.getTempMessagesFromMessageQueue(currentConversation!!.internalId) } } else { Log.w( @@ -744,7 +710,6 @@ class ChatActivity : sessionIdAfterRoomJoined = currentConversation!!.sessionId ApplicationWideCurrentRoomHolder.getInstance().session = currentConversation!!.sessionId - // ApplicationWideCurrentRoomHolder.getInstance().currentRoomId = currentConversation!!.roomId ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken = currentConversation!!.token ApplicationWideCurrentRoomHolder.getInstance().userInRoom = conversationUser @@ -813,18 +778,7 @@ class ChatActivity : } is MessageInputViewModel.SendChatMessageErrorState -> { - if (state.e is HttpException) { - val code = state.e.code() - if (code.toString().startsWith("2")) { - myFirstMessage = state.message - - if (binding.unreadMessagesPopup.isShown) { - binding.unreadMessagesPopup.visibility = View.GONE - } - - binding.messagesListView.smoothScrollToPosition(0) - } - } + binding.messagesListView.smoothScrollToPosition(0) } else -> {} @@ -861,7 +815,6 @@ class ChatActivity : is ChatViewModel.CreateRoomSuccessState -> { val bundle = Bundle() bundle.putString(KEY_ROOM_TOKEN, state.roomOverall.ocs!!.data!!.token) - // bundle.putString(KEY_ROOM_ID, state.roomOverall.ocs!!.data!!.roomId) leaveRoom { val chatIntent = Intent(context, ChatActivity::class.java) @@ -937,6 +890,14 @@ class ChatActivity : .collect() } + this.lifecycleScope.launch { + chatViewModel.getRemoveMessageFlow + .onEach { + removeMessageById(it.id) + } + .collect() + } + this.lifecycleScope.launch { chatViewModel.getUpdateMessageFlow .onEach { @@ -1081,8 +1042,10 @@ class ChatActivity : is ChatViewModel.OutOfOfficeUIState.Error -> { Log.e(TAG, "Error fetching/ no user absence data", uiState.exception) } + ChatViewModel.OutOfOfficeUIState.None -> { } + is ChatViewModel.OutOfOfficeUIState.Success -> { binding.outOfOfficeContainer.visibility = View.VISIBLE @@ -1171,9 +1134,25 @@ class ChatActivity : } private fun removeUnreadMessagesMarker() { - val index = adapter?.getMessagePositionById(UNREAD_MESSAGES_MARKER_ID.toString()) - if (index != null && index != -1) { - adapter?.items?.removeAt(index) + removeMessageById(UNREAD_MESSAGES_MARKER_ID.toString()) + } + + // do not use adapter.deleteById() as it seems to contain a bug! Use this method instead! + @Suppress("MagicNumber") + private fun removeMessageById(idToDelete: String) { + val indexToDelete = adapter?.getMessagePositionById(idToDelete) + if (indexToDelete != null && indexToDelete != UNREAD_MESSAGES_MARKER_ID) { + // If user sent a message as a first message in todays chat, the temp message will be deleted when + // messages are retrieved from server, but also the date has to be deleted as it will be added again + // when the chat messages are added from server. Otherwise date "Today" would be shown twice. + if (indexToDelete == 0 && (adapter?.items?.get(1))?.item is Date) { + adapter?.items?.removeAt(0) + adapter?.items?.removeAt(0) + adapter?.notifyItemRangeRemoved(indexToDelete, 1) + } else { + adapter?.items?.removeAt(indexToDelete) + adapter?.notifyItemRemoved(indexToDelete) + } } } @@ -1190,7 +1169,7 @@ class ChatActivity : cancelNotificationsForCurrentConversation() - chatViewModel.getRoom(conversationUser!!, roomToken) + chatViewModel.getRoom(roomToken) actionBar?.show() @@ -1238,18 +1217,18 @@ class ChatActivity : viewThemeUtils.material.colorToolbarOverflowIcon(binding.chatToolbar) } - private fun getLastAdapterId(): Int { - var lastId = 0 - if (adapter?.items?.size != 0) { - val item = adapter?.items?.get(0)?.item - if (item != null) { - lastId = (item as ChatMessage).jsonMessageId - } else { - lastId = 0 - } - } - return lastId - } + // private fun getLastAdapterId(): Int { + // var lastId = 0 + // if (adapter?.items?.size != 0) { + // val item = adapter?.items?.get(0)?.item + // if (item != null) { + // lastId = (item as ChatMessage).jsonMessageId + // } else { + // lastId = 0 + // } + // } + // return lastId + // } private fun setupActionBar() { setSupportActionBar(binding.chatToolbar) @@ -1369,17 +1348,6 @@ class ChatActivity : R.layout.item_custom_outcoming_preview_message ) - messageHolders.registerContentType( - CONTENT_TYPE_TEMP, - TemporaryMessageViewHolder::class.java, - payload, - R.layout.item_temporary_message, - TemporaryMessageViewHolder::class.java, - payload, - R.layout.item_temporary_message, - this - ) - messageHolders.registerContentType( CONTENT_TYPE_SYSTEM_MESSAGE, SystemMessageViewHolder::class.java, @@ -1658,7 +1626,7 @@ class ChatActivity : } getRoomInfoTimerHandler?.postDelayed( { - chatViewModel.getRoom(conversationUser!!, roomToken) + chatViewModel.getRoom(roomToken) }, delayForRecursiveCall ) @@ -2727,7 +2695,6 @@ class ChatActivity : ) { sessionIdAfterRoomJoined = ApplicationWideCurrentRoomHolder.getInstance().session - // ApplicationWideCurrentRoomHolder.getInstance().currentRoomId = roomId ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken = roomToken ApplicationWideCurrentRoomHolder.getInstance().userInRoom = conversationUser } @@ -2918,7 +2885,6 @@ class ChatActivity : ) { if (message.item is ChatMessage) { val chatMessage = message.item as ChatMessage - if (chatMessage.jsonMessageId <= xChatLastCommonRead) { chatMessage.readStatus = ReadStatus.READ } else { @@ -2968,7 +2934,19 @@ class ChatActivity : } } - private fun isScrolledToBottom() = layoutManager?.findFirstVisibleItemPosition() == 0 + private fun isScrolledToBottom(): Boolean { + val position = layoutManager?.findFirstVisibleItemPosition() + if (position == -1) { + Log.w( + TAG, + "FirstVisibleItemPosition was -1 but true is returned for isScrolledToBottom(). This can " + + "happen when the UI is not yet ready" + ) + return true + } + + return layoutManager?.findFirstVisibleItemPosition() == 0 + } private fun setUnreadMessageMarker(chatMessageList: List) { if (chatMessageList.isNotEmpty()) { @@ -3354,7 +3332,6 @@ class ChatActivity : currentConversation?.let { val bundle = Bundle() bundle.putString(KEY_ROOM_TOKEN, roomToken) - // bundle.putString(KEY_ROOM_ID, roomId) bundle.putString(BundleKeys.KEY_CONVERSATION_PASSWORD, roomPassword) bundle.putString(BundleKeys.KEY_MODIFIED_BASE_URL, conversationUser?.baseUrl!!) bundle.putString(KEY_CONVERSATION_NAME, it.displayName) @@ -3423,9 +3400,14 @@ class ChatActivity : private fun openMessageActionsDialog(iMessage: IMessage?) { val message = iMessage as ChatMessage - if (hasVisibleItems(message) && - !isSystemMessage(message) && - message.id != "-3" + + if (message.isTemporary) { + TempMessageActionsDialog( + this, + message + ).show() + } else if (hasVisibleItems(message) && + !isSystemMessage(message) ) { MessageActionsDialog( this, @@ -3849,7 +3831,6 @@ class ChatActivity : CONTENT_TYPE_SYSTEM_MESSAGE -> !TextUtils.isEmpty(message.systemMessage) CONTENT_TYPE_UNREAD_NOTICE_MESSAGE -> message.id == UNREAD_MESSAGES_MARKER_ID.toString() CONTENT_TYPE_CALL_STARTED -> message.id == "-2" - CONTENT_TYPE_TEMP -> message.id == "-3" CONTENT_TYPE_DECK_CARD -> message.isDeckCard() else -> false @@ -3996,30 +3977,6 @@ class ChatActivity : startACall(false, false) } - override fun editTemporaryMessage(id: Int, newMessage: String) { - messageInputViewModel.editQueuedMessage(currentConversation!!.internalId, id, newMessage) - adapter?.notifyDataSetChanged() // TODO optimize this - } - - override fun deleteTemporaryMessage(id: Int) { - messageInputViewModel.removeFromQueue(currentConversation!!.internalId, id) - var i = 0 - val max = messageInputViewModel.messageQueueSizeFlow.value?.plus(1) - for (item in adapter?.items!!) { - if (i > max!! && max < 1) break - if (item.item is ChatMessage && - (item.item as ChatMessage).isTempMessage && - (item.item as ChatMessage).tempMessageId == id - ) { - val index = adapter?.items!!.indexOf(item) - adapter?.items!!.removeAt(index) - adapter?.notifyItemRemoved(index) - break - } - i++ - } - } - private fun logConversationInfos(methodName: String) { Log.d(TAG, " |-----------------------------------------------") Log.d(TAG, " | method: $methodName") @@ -4068,9 +4025,7 @@ class ChatActivity : private const val CONTENT_TYPE_POLL: Byte = 6 private const val CONTENT_TYPE_LINK_PREVIEW: Byte = 7 private const val CONTENT_TYPE_DECK_CARD: Byte = 8 - private const val CONTENT_TYPE_TEMP: Byte = 9 private const val UNREAD_MESSAGES_MARKER_ID = -1 - private const val CALL_STARTED_ID = -2 private const val GET_ROOM_INFO_DELAY_NORMAL: Long = 30000 private const val GET_ROOM_INFO_DELAY_LOBBY: Long = 5000 private const val AGE_THRESHOLD_FOR_DELETE_MESSAGE: Int = 21600000 // (6 hours in millis = 6 * 3600 * 1000) @@ -4108,8 +4063,6 @@ class ChatActivity : private const val RESUME_AUDIO_TAG = "RESUME_AUDIO_TAG" private const val DELAY_TO_SHOW_PROGRESS_BAR = 1000L private const val FIVE_MINUTES_IN_SECONDS: Long = 300 - private const val TEMPORARY_MESSAGE_ID_INT: Int = -3 - private const val TEMPORARY_MESSAGE_ID_STRING: String = "-3" private const val ROOM_TYPE_ONE_TO_ONE = "1" private const val ACTOR_TYPE = "users" const val CONVERSATION_INTERNAL_ID = "CONVERSATION_INTERNAL_ID" diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt index b97ca345b..14607a210 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -158,7 +158,6 @@ class MessageInputFragment : Fragment() { override fun onResume() { super.onResume() - chatActivity.messageInputViewModel.restoreMessageQueue(conversationInternalId) } override fun onDestroyView() { @@ -199,19 +198,20 @@ class MessageInputFragment : Fragment() { wasOnline = !binding.fragmentConnectionLost.isShown val connectionGained = (!wasOnline && isOnline) Log.d(TAG, "isOnline: $isOnline\nwasOnline: $wasOnline\nconnectionGained: $connectionGained") - handleMessageQueue(isOnline) + if (connectionGained) { + chatActivity.messageInputViewModel.sendTempMessages( + chatActivity.conversationUser!!.getCredentials(), + ApiUtils.getUrlForChat( + chatActivity.chatApiVersion, + chatActivity.conversationUser!!.baseUrl!!, + chatActivity.roomToken + ) + ) + } handleUI(isOnline, connectionGained) }.collect() } - chatActivity.messageInputViewModel.messageQueueSizeFlow.observe(viewLifecycleOwner) { size -> - if (size > 0) { - binding.fragmentConnectionLost.text = getString(R.string.connection_lost_queued, size) - } else { - binding.fragmentConnectionLost.text = getString(R.string.connection_lost_sent_messages_are_queued) - } - } - chatActivity.messageInputViewModel.callStartedFlow.observe(viewLifecycleOwner) { val (message, show) = it if (show) { @@ -292,23 +292,6 @@ class MessageInputFragment : Fragment() { } } - private fun handleMessageQueue(isOnline: Boolean) { - if (isOnline) { - chatActivity.messageInputViewModel.switchToMessageQueue(false) - chatActivity.messageInputViewModel.sendAndEmptyMessageQueue( - conversationInternalId, - chatActivity.conversationUser!!.getCredentials(), - ApiUtils.getUrlForChat( - chatActivity.chatApiVersion, - chatActivity.conversationUser!!.baseUrl!!, - chatActivity.roomToken - ) - ) - } else { - chatActivity.messageInputViewModel.switchToMessageQueue(true) - } - } - private fun restoreState() { if (binding.fragmentMessageInputView.inputEditText.text.isEmpty()) { requireContext().getSharedPreferences(chatActivity.localClassName, AppCompatActivity.MODE_PRIVATE).apply { @@ -868,7 +851,7 @@ class MessageInputFragment : Fragment() { .findViewById(R.id.quotedChatMessageView)?.tag as Int? ?: 0 sendMessage( - editable, + editable.toString(), replyMessageId, sendWithoutNotification ) @@ -876,9 +859,8 @@ class MessageInputFragment : Fragment() { } } - private fun sendMessage(message: CharSequence, replyTo: Int?, sendWithoutNotification: Boolean) { + private fun sendMessage(message: String, replyTo: Int?, sendWithoutNotification: Boolean) { chatActivity.messageInputViewModel.sendChatMessage( - conversationInternalId, chatActivity.conversationUser!!.getCredentials(), ApiUtils.getUrlForChat( chatActivity.chatApiVersion, @@ -917,16 +899,23 @@ class MessageInputFragment : Fragment() { // FIXME Fix API checking with guests? val apiVersion: Int = ApiUtils.getChatApiVersion(chatActivity.spreedCapabilities, intArrayOf(1)) - chatActivity.messageInputViewModel.editChatMessage( - chatActivity.credentials!!, - ApiUtils.getUrlForChatMessage( - apiVersion, - chatActivity.conversationUser!!.baseUrl!!, - chatActivity.roomToken, - message.id - ), - editedMessageText - ) + if (message.isTemporary) { + chatActivity.messageInputViewModel.editTempChatMessage( + message, + editedMessageText + ) + } else { + chatActivity.messageInputViewModel.editChatMessage( + chatActivity.credentials!!, + ApiUtils.getUrlForChatMessage( + apiVersion, + chatActivity.conversationUser!!.baseUrl!!, + chatActivity.roomToken, + message.id + ), + editedMessageText + ) + } } private fun setEditUI(message: ChatMessage) { 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 5cbf39efe..d42c3bff3 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 @@ -11,6 +11,7 @@ import android.os.Bundle import com.nextcloud.talk.chat.data.io.LifecycleAwareManager import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -41,6 +42,8 @@ interface ChatMessageRepository : LifecycleAwareManager { */ val generalUIFlow: Flow + val removeMessageFlow: Flow + fun setData(conversationModel: ConversationModel, credentials: String, urlForChatting: String) fun loadInitialMessages(withNetworkParams: Bundle): Job @@ -75,4 +78,42 @@ interface ChatMessageRepository : LifecycleAwareManager { * Destroys unused resources. */ fun handleChatOnBackPress() + + @Suppress("LongParameterList") + suspend fun sendChatMessage( + credentials: String, + url: String, + message: String, + displayName: String, + replyTo: Int, + sendWithoutNotification: Boolean, + referenceId: String + ): Flow> + + @Suppress("LongParameterList") + suspend fun resendChatMessage( + credentials: String, + url: String, + message: String, + displayName: String, + replyTo: Int, + sendWithoutNotification: Boolean, + referenceId: String + ): Flow> + + suspend fun addTemporaryMessage( + message: CharSequence, + displayName: String, + replyTo: Int, + sendWithoutNotification: Boolean, + referenceId: String + ): Flow> + + suspend fun editChatMessage(credentials: String, url: String, text: String): Flow> + + suspend fun editTempChatMessage(message: ChatMessage, editedMessageText: String): Flow + + suspend fun sendTempChatMessages(credentials: String, url: String) + + suspend fun deleteTempMessage(chatMessage: ChatMessage) } diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt b/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt index c2f14ffc3..f80886fa4 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/model/ChatMessage.kt @@ -115,11 +115,16 @@ data class ChatMessage( var openWhenDownloaded: Boolean = true, - var isTempMessage: Boolean = false, + var isTemporary: Boolean = false, - var tempMessageId: Int = -1 + var referenceId: String? = null, -) : MessageContentType, MessageContentType.Image { + var sendingFailed: Boolean = true, + + var silent: Boolean = false + +) : MessageContentType, + MessageContentType.Image { var extractedUrlToPreview: String? = null @@ -240,8 +245,8 @@ data class ChatMessage( } } - fun getCalculateMessageType(): MessageType { - return if (!TextUtils.isEmpty(systemMessage)) { + fun getCalculateMessageType(): MessageType = + if (!TextUtils.isEmpty(systemMessage)) { MessageType.SYSTEM_MESSAGE } else if (isVoiceMessage) { MessageType.VOICE_MESSAGE @@ -256,19 +261,15 @@ data class ChatMessage( } else { MessageType.REGULAR_TEXT_MESSAGE } - } - override fun getId(): String { - return jsonMessageId.toString() - } + override fun getId(): String = jsonMessageId.toString() - override fun getText(): String { - return if (message != null) { + override fun getText(): String = + if (message != null) { getParsedMessage(message, messageParameters)!! } else { "" } - } fun getNullsafeActorDisplayName() = if (!TextUtils.isEmpty(actorDisplayName)) { @@ -277,22 +278,19 @@ data class ChatMessage( sharedApplication!!.getString(R.string.nc_guest) } - override fun getUser(): IUser { - return object : IUser { - override fun getId(): String { - return "$actorType/$actorId" - } + override fun getUser(): IUser = + object : IUser { + override fun getId(): String = "$actorType/$actorId" - override fun getName(): String { - return if (!TextUtils.isEmpty(actorDisplayName)) { + override fun getName(): String = + if (!TextUtils.isEmpty(actorDisplayName)) { actorDisplayName!! } else { sharedApplication!!.getString(R.string.nc_guest) } - } - override fun getAvatar(): String? { - return when { + override fun getAvatar(): String? = + when { activeUser == null -> { null } @@ -317,21 +315,14 @@ data class ChatMessage( ApiUtils.getUrlForGuestAvatar(activeUser!!.baseUrl!!, apiId, true) } } - } } - } - override fun getCreatedAt(): Date { - return Date(timestamp * MILLIES) - } + override fun getCreatedAt(): Date = Date(timestamp * MILLIES) - override fun getSystemMessage(): String { - return EnumSystemMessageTypeConverter().convertToString(systemMessageType) - } + override fun getSystemMessage(): String = EnumSystemMessageTypeConverter().convertToString(systemMessageType) - private fun isHashMapEntryEqualTo(map: HashMap, key: String, searchTerm: String): Boolean { - return map != null && MessageDigest.isEqual(map[key]!!.toByteArray(), searchTerm.toByteArray()) - } + private fun isHashMapEntryEqualTo(map: HashMap, key: String, searchTerm: String): Boolean = + map != null && MessageDigest.isEqual(map[key]!!.toByteArray(), searchTerm.toByteArray()) // needed a equals and hashcode function to fix detekt errors override fun equals(other: Any?): Boolean { @@ -340,9 +331,7 @@ data class ChatMessage( return false } - override fun hashCode(): Int { - return 0 - } + override fun hashCode(): Int = 0 val isVoiceMessage: Boolean get() = "voice-message" == messageType diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt index 81a6ec6c8..d808ac9a8 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatNetworkDataSource.kt @@ -38,7 +38,7 @@ interface ChatNetworkDataSource { url: String, message: String, displayName: String - ): Observable // last two fields are false + ): Observable fun checkForNoteToSelf(credentials: String, url: String, includeStatus: Boolean): Observable fun shareLocationToNotes( @@ -50,19 +50,20 @@ interface ChatNetworkDataSource { ): Observable fun leaveRoom(credentials: String, url: String): Observable - fun sendChatMessage( + suspend fun sendChatMessage( credentials: String, url: String, - message: CharSequence, + message: String, displayName: String, replyTo: Int, - sendWithoutNotification: Boolean - ): Observable + sendWithoutNotification: Boolean, + referenceId: String + ): ChatOverallSingleMessage fun pullChatMessages(credentials: String, url: String, fieldMap: HashMap): Observable> fun deleteChatMessage(credentials: String, url: String): Observable fun createRoom(credentials: String, url: String, map: Map): Observable fun setChatReadMarker(credentials: String, url: String, previousMessageId: Int): Observable - fun editChatMessage(credentials: String, url: String, text: String): Observable + suspend fun editChatMessage(credentials: String, url: String, text: String): ChatOverallSingleMessage suspend fun getOutOfOfficeStatusForUser(credentials: String, baseUrl: String, userId: String): UserAbsenceOverall } 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 9a50d7c68..08cf1b832 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 @@ -21,12 +21,16 @@ import com.nextcloud.talk.data.database.model.ChatBlockEntity import com.nextcloud.talk.data.database.model.ChatMessageEntity import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.extensions.toIntOrZero import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.chat.ChatMessageJson import com.nextcloud.talk.models.json.chat.ChatOverall +import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage +import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter +import com.nextcloud.talk.models.json.participants.Participant import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew -import com.nextcloud.talk.utils.preferences.AppPreferences +import com.nextcloud.talk.utils.message.SendMessageUtils import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.CoroutineScope @@ -36,18 +40,22 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.launch +import java.io.IOException import javax.inject.Inject +@Suppress("LargeClass", "TooManyFunctions") class OfflineFirstChatRepository @Inject constructor( private val chatDao: ChatMessagesDao, private val chatBlocksDao: ChatBlocksDao, private val network: ChatNetworkDataSource, - private val datastore: AppPreferences, private val monitor: NetworkMonitor, - private val userProvider: CurrentUserProviderNew + userProvider: CurrentUserProviderNew ) : ChatMessageRepository { val currentUser: User = userProvider.currentUser.blockingGet() @@ -71,8 +79,7 @@ class OfflineFirstChatRepository @Inject constructor( > > = MutableSharedFlow() - override val updateMessageFlow: - Flow + override val updateMessageFlow: Flow get() = _updateMessageFlow private val _updateMessageFlow: @@ -85,8 +92,7 @@ class OfflineFirstChatRepository @Inject constructor( private val _lastCommonReadFlow: MutableSharedFlow = MutableSharedFlow() - override val lastReadMessageFlow: - Flow + override val lastReadMessageFlow: Flow get() = _lastReadMessageFlow private val _lastReadMessageFlow: @@ -97,6 +103,12 @@ class OfflineFirstChatRepository @Inject constructor( private val _generalUIFlow: MutableSharedFlow = MutableSharedFlow() + override val removeMessageFlow: Flow + get() = _removeMessageFlow + + private val _removeMessageFlow: + MutableSharedFlow = MutableSharedFlow() + private var newXChatLastCommonRead: Int? = null private var itIsPaused = false private val scope = CoroutineScope(Dispatchers.IO) @@ -169,26 +181,39 @@ class OfflineFirstChatRepository @Inject constructor( Log.d(TAG, "newestMessageIdFromDb after sync: $newestMessageIdFromDb") } - if (newestMessageIdFromDb.toInt() != 0) { - val limit = getCappedMessagesAmountOfChatBlock(newestMessageIdFromDb) - - showMessagesBeforeAndEqual( - internalConversationId, - newestMessageIdFromDb, - limit - ) - - // delay is a dirty workaround to make sure messages are added to adapter on initial load before dealing - // with them (otherwise there is a race condition). - delay(DELAY_TO_ENSURE_MESSAGES_ARE_ADDED) - - updateUiForLastCommonRead() - updateUiForLastReadMessage(newestMessageIdFromDb) - } + handleMessagesFromDb(newestMessageIdFromDb) initMessagePolling(newestMessageIdFromDb) } + private suspend fun handleMessagesFromDb(newestMessageIdFromDb: Long) { + if (newestMessageIdFromDb.toInt() != 0) { + val limit = getCappedMessagesAmountOfChatBlock(newestMessageIdFromDb) + + val list = getMessagesBeforeAndEqual( + newestMessageIdFromDb, + internalConversationId, + limit + ) + if (list.isNotEmpty()) { + handleNewAndTempMessages( + receivedChatMessages = list, + lookIntoFuture = false, + showUnreadMessagesMarker = false + ) + } + + sendTempChatMessages(credentials, urlForChatting) + + // delay is a dirty workaround to make sure messages are added to adapter on initial load before dealing + // with them (otherwise there is a race condition). + delay(DELAY_TO_ENSURE_MESSAGES_ARE_ADDED) + + updateUiForLastCommonRead() + updateUiForLastReadMessage(newestMessageIdFromDb) + } + } + private suspend fun getCappedMessagesAmountOfChatBlock(messageId: Long): Int { val chatBlock = getBlockOfMessage(messageId.toInt()) @@ -293,8 +318,11 @@ class OfflineFirstChatRepository @Inject constructor( val weHaveMessagesFromOurself = chatMessages.any { it.actorId == currentUser.userId } showUnreadMessagesMarker = showUnreadMessagesMarker && !weHaveMessagesFromOurself - val triple = Triple(true, showUnreadMessagesMarker, chatMessages) - _messageFlow.emit(triple) + handleNewAndTempMessages( + receivedChatMessages = chatMessages, + lookIntoFuture = true, + showUnreadMessagesMarker = showUnreadMessagesMarker + ) } else { Log.d(TAG, "resultsFromSync are null or empty") } @@ -317,6 +345,39 @@ class OfflineFirstChatRepository @Inject constructor( } } + private suspend fun handleNewAndTempMessages( + receivedChatMessages: List, + lookIntoFuture: Boolean, + showUnreadMessagesMarker: Boolean + ) { + // remove all temp messages from UI + val oldTempMessages = chatDao.getTempMessagesForConversation(internalConversationId) + .first() + .map(ChatMessageEntity::asModel) + oldTempMessages.forEach { _removeMessageFlow.emit(it) } + + // add new messages to UI + val tripleChatMessages = Triple(lookIntoFuture, showUnreadMessagesMarker, receivedChatMessages) + _messageFlow.emit(tripleChatMessages) + + // remove temp messages from DB that are now found in the new messages + val chatMessagesReferenceIds = receivedChatMessages.mapTo(HashSet(receivedChatMessages.size)) { it.referenceId } + val tempChatMessagesThatCanBeReplaced = oldTempMessages.filter { it.referenceId in chatMessagesReferenceIds } + chatDao.deleteTempChatMessages( + internalConversationId, + tempChatMessagesThatCanBeReplaced.map { it.referenceId!! } + ) + + // add the remaining temp messages to UI again + val remainingTempMessages = chatDao.getTempMessagesForConversation(internalConversationId) + .first() + .sortedBy { it.internalId } + .map(ChatMessageEntity::asModel) + + val triple = Triple(true, false, remainingTempMessages) + _messageFlow.emit(triple) + } + private suspend fun hasToLoadPreviousMessagesFromServer(beforeMessageId: Long): Boolean { val loadFromServer: Boolean @@ -684,31 +745,18 @@ class OfflineFirstChatRepository @Inject constructor( } } - private suspend fun showMessagesBeforeAndEqual(internalConversationId: String, messageId: Long, limit: Int) { - suspend fun getMessagesBeforeAndEqual( - messageId: Long, - internalConversationId: String, - messageLimit: Int - ): List = - chatDao.getMessagesForConversationBeforeAndEqual( - internalConversationId, - messageId, - messageLimit - ).map { - it.map(ChatMessageEntity::asModel) - }.first() - - val list = getMessagesBeforeAndEqual( - messageId, + suspend fun getMessagesBeforeAndEqual( + messageId: Long, + internalConversationId: String, + messageLimit: Int + ): List = + chatDao.getMessagesForConversationBeforeAndEqual( internalConversationId, - limit - ) - - if (list.isNotEmpty()) { - val triple = Triple(false, false, list) - _messageFlow.emit(triple) - } - } + messageId, + messageLimit + ).map { + it.map(ChatMessageEntity::asModel) + }.first() private suspend fun showMessagesBefore(internalConversationId: String, messageId: Long, limit: Int) { suspend fun getMessagesBefore( @@ -752,6 +800,227 @@ class OfflineFirstChatRepository @Inject constructor( scope.cancel() } + @Suppress("LongParameterList") + override suspend fun sendChatMessage( + credentials: String, + url: String, + message: String, + displayName: String, + replyTo: Int, + sendWithoutNotification: Boolean, + referenceId: String + ): Flow> { + if (!monitor.isOnline.first()) { + return flow { + emit(Result.failure(IOException("Skipped to send message as device is offline"))) + } + } + + return flow { + val response = network.sendChatMessage( + credentials, + url, + message, + displayName, + replyTo, + sendWithoutNotification, + referenceId + ) + + val chatMessageModel = response.ocs?.data?.asModel() + + emit(Result.success(chatMessageModel)) + } + .retryWhen { cause, attempt -> + if (cause is IOException && attempt < SEND_MESSAGE_RETRY_ATTEMPTS) { + delay(SEND_MESSAGE_RETRY_DELAY) + return@retryWhen true + } else { + return@retryWhen false + } + } + .catch { e -> + Log.e(TAG, "Error when sending message", e) + + val failedMessage = chatDao.getTempMessageForConversation(internalConversationId, referenceId).first() + failedMessage.sendingFailed = true + chatDao.updateChatMessage(failedMessage) + + val failedMessageModel = failedMessage.asModel() + _updateMessageFlow.emit(failedMessageModel) + + emit(Result.failure(e)) + } + } + + @Suppress("LongParameterList") + override suspend fun resendChatMessage( + credentials: String, + url: String, + message: String, + displayName: String, + replyTo: Int, + sendWithoutNotification: Boolean, + referenceId: String + ): Flow> { + val messageToResend = chatDao.getTempMessageForConversation(internalConversationId, referenceId).first() + messageToResend.sendingFailed = false + chatDao.updateChatMessage(messageToResend) + + val messageToResendModel = messageToResend.asModel() + _updateMessageFlow.emit(messageToResendModel) + + return sendChatMessage( + credentials, + url, + message, + displayName, + replyTo, + sendWithoutNotification, + referenceId + ) + } + + @Suppress("Detekt.TooGenericExceptionCaught") + override suspend fun editChatMessage( + credentials: String, + url: String, + text: String + ): Flow> = + flow { + try { + val response = network.editChatMessage( + credentials, + url, + text + ) + emit(Result.success(response)) + } catch (e: Exception) { + emit(Result.failure(e)) + } + } + + @Suppress("Detekt.TooGenericExceptionCaught") + override suspend fun editTempChatMessage(message: ChatMessage, editedMessageText: String): Flow = + flow { + try { + val messageToEdit = chatDao.getChatMessageForConversation( + internalConversationId, + message.jsonMessageId + .toLong() + ).first() + messageToEdit.message = editedMessageText + chatDao.upsertChatMessage(messageToEdit) + + val editedMessageModel = messageToEdit.asModel() + _updateMessageFlow.emit(editedMessageModel) + emit(true) + } catch (e: Exception) { + emit(false) + } + } + + override suspend fun sendTempChatMessages(credentials: String, url: String) { + val tempMessages = chatDao.getTempMessagesForConversation(internalConversationId).first() + tempMessages.sortedBy { it.internalId }.onEach { + sendChatMessage( + credentials, + url, + it.message, + it.actorDisplayName, + it.parentMessageId?.toIntOrZero() ?: 0, + it.silent, + it.referenceId.orEmpty() + ).collect { result -> + if (result.isSuccess) { + Log.d(TAG, "Sent temp message") + } else { + Log.e(TAG, "Failed to send temp message") + } + } + } + } + + override suspend fun deleteTempMessage(chatMessage: ChatMessage) { + chatDao.deleteTempChatMessages(internalConversationId, listOf(chatMessage.referenceId.orEmpty())) + _removeMessageFlow.emit(chatMessage) + } + + @Suppress("Detekt.TooGenericExceptionCaught") + override suspend fun addTemporaryMessage( + message: CharSequence, + displayName: String, + replyTo: Int, + sendWithoutNotification: Boolean, + referenceId: String + ): Flow> = + flow { + try { + val tempChatMessageEntity = createChatMessageEntity( + internalConversationId, + message.toString(), + replyTo, + sendWithoutNotification, + referenceId + ) + + chatDao.upsertChatMessage(tempChatMessageEntity) + + val tempChatMessageModel = tempChatMessageEntity.asModel() + + emit(Result.success(tempChatMessageModel)) + + val triple = Triple(true, false, listOf(tempChatMessageModel)) + _messageFlow.emit(triple) + } catch (e: Exception) { + Log.e(TAG, "Something went wrong when adding temporary message", e) + emit(Result.failure(e)) + } + } + + private fun createChatMessageEntity( + internalConversationId: String, + message: String, + replyTo: Int, + sendWithoutNotification: Boolean, + referenceId: String + ): ChatMessageEntity { + val currentTimeMillies = System.currentTimeMillis() + + val currentTimeWithoutYear = SendMessageUtils().removeYearFromTimestamp(currentTimeMillies) + + val parentMessageId = if (replyTo != 0) { + replyTo.toLong() + } else { + null + } + + val entity = ChatMessageEntity( + internalId = "$internalConversationId@_temp_$currentTimeMillies", + internalConversationId = internalConversationId, + id = currentTimeWithoutYear.toLong(), + message = message, + deleted = false, + token = conversationModel.token, + actorId = currentUser.userId!!, + actorType = EnumActorTypeConverter().convertToString(Participant.ActorType.USERS), + accountId = currentUser.id!!, + messageParameters = null, + messageType = "comment", + parentMessageId = parentMessageId, + systemMessageType = ChatMessage.SystemMessageType.DUMMY, + replyable = false, + timestamp = currentTimeMillies / MILLIES, + expirationTimestamp = 0, + actorDisplayName = currentUser.displayName!!, + referenceId = referenceId, + isTemporary = true, + sendingFailed = false, + silent = sendWithoutNotification + ) + return entity + } + companion object { val TAG = OfflineFirstChatRepository::class.simpleName private const val HTTP_CODE_OK: Int = 200 @@ -760,5 +1029,8 @@ class OfflineFirstChatRepository @Inject constructor( private const val HALF_SECOND = 500L private const val DELAY_TO_ENSURE_MESSAGES_ARE_ADDED: Long = 100 private const val DEFAULT_MESSAGES_LIMIT = 100 + private const val MILLIES = 1000 + private const val SEND_MESSAGE_RETRY_ATTEMPTS = 3 + private const val SEND_MESSAGE_RETRY_DELAY: Long = 2000 } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt index eafab6484..6f857d254 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/RetrofitChatNetwork.kt @@ -18,6 +18,7 @@ import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.reminder.Reminder import com.nextcloud.talk.models.json.userAbsence.UserAbsenceOverall import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.message.SendMessageUtils import io.reactivex.Observable import retrofit2.Response @@ -108,26 +109,24 @@ class RetrofitChatNetwork( url: String, message: String, displayName: String - ): Observable { - return ncApi.sendChatMessage( + ): Observable = + ncApi.sendChatMessage( credentials, url, message, displayName, null, - false + false, + SendMessageUtils().generateReferenceId() ).map { it } - } override fun checkForNoteToSelf( credentials: String, url: String, includeStatus: Boolean - ): Observable { - return ncApi.getRooms(credentials, url, includeStatus).map { it } - } + ): Observable = ncApi.getRooms(credentials, url, includeStatus).map { it } override fun shareLocationToNotes( credentials: String, @@ -135,54 +134,56 @@ class RetrofitChatNetwork( objectType: String, objectId: String, metadata: String - ): Observable { - return ncApi.sendLocation(credentials, url, objectType, objectId, metadata).map { it } - } + ): Observable = ncApi.sendLocation(credentials, url, objectType, objectId, metadata).map { it } - override fun leaveRoom(credentials: String, url: String): Observable { - return ncApi.leaveRoom(credentials, url).map { it } - } - - override fun sendChatMessage( - credentials: String, - url: String, - message: CharSequence, - displayName: String, - replyTo: Int, - sendWithoutNotification: Boolean - ): Observable { - return ncApi.sendChatMessage(credentials, url, message, displayName, replyTo, sendWithoutNotification).map { + override fun leaveRoom(credentials: String, url: String): Observable = + ncApi.leaveRoom(credentials, url).map { it } - } + + override suspend fun sendChatMessage( + credentials: String, + url: String, + message: String, + displayName: String, + replyTo: Int, + sendWithoutNotification: Boolean, + referenceId: String + ): ChatOverallSingleMessage = + ncApiCoroutines.sendChatMessage( + credentials, + url, + message, + displayName, + replyTo, + sendWithoutNotification, + referenceId + ) override fun pullChatMessages( credentials: String, url: String, fieldMap: HashMap - ): Observable> { - return ncApi.pullChatMessages(credentials, url, fieldMap).map { it } - } + ): Observable> = ncApi.pullChatMessages(credentials, url, fieldMap).map { it } - override fun deleteChatMessage(credentials: String, url: String): Observable { - return ncApi.deleteChatMessage(credentials, url).map { it } - } + override fun deleteChatMessage(credentials: String, url: String): Observable = + ncApi.deleteChatMessage(credentials, url).map { + it + } - override fun createRoom(credentials: String, url: String, map: Map): Observable { - return ncApi.createRoom(credentials, url, map).map { it } - } + override fun createRoom(credentials: String, url: String, map: Map): Observable = + ncApi.createRoom(credentials, url, map).map { + it + } override fun setChatReadMarker( credentials: String, url: String, previousMessageId: Int - ): Observable { - return ncApi.setChatReadMarker(credentials, url, previousMessageId).map { it } - } + ): Observable = ncApi.setChatReadMarker(credentials, url, previousMessageId).map { it } - override fun editChatMessage(credentials: String, url: String, text: String): Observable { - return ncApi.editChatMessage(credentials, url, text).map { it } - } + override suspend fun editChatMessage(credentials: String, url: String, text: String): ChatOverallSingleMessage = + ncApiCoroutines.editChatMessage(credentials, url, text) override suspend fun getOutOfOfficeStatusForUser( credentials: String, 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 64b029ae6..bb2abef36 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 @@ -24,6 +24,7 @@ import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.extensions.toIntOrZero import com.nextcloud.talk.jobs.UploadAndShareFilesWorker import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.domain.ReactionAddedModel @@ -63,7 +64,8 @@ class ChatViewModel @Inject constructor( private val mediaRecorderManager: MediaRecorderManager, private val audioFocusRequestManager: AudioFocusRequestManager, private val userProvider: CurrentUserProviderNew -) : ViewModel(), DefaultLifecycleObserver { +) : ViewModel(), + DefaultLifecycleObserver { enum class LifeCycleFlag { PAUSED, @@ -74,6 +76,10 @@ class ChatViewModel @Inject constructor( lateinit var currentLifeCycleFlag: LifeCycleFlag val disposableSet = mutableSetOf() + fun getChatRepository(): ChatMessageRepository { + return chatRepository + } + override fun onResume(owner: LifecycleOwner) { super.onResume(owner) currentLifeCycleFlag = LifeCycleFlag.RESUMED @@ -131,6 +137,8 @@ class ChatViewModel @Inject constructor( _chatMessageViewState.value = ChatMessageErrorState } + val getRemoveMessageFlow = chatRepository.removeMessageFlow + val getUpdateMessageFlow = chatRepository.updateMessageFlow val getLastCommonReadFlow = chatRepository.lastCommonReadFlow @@ -239,14 +247,9 @@ class ChatViewModel @Inject constructor( chatRepository.setData(conversationModel, credentials, urlForChatting) } - fun getRoom(user: User, token: String) { + fun getRoom(token: String) { _getRoomViewState.value = GetRoomStartState conversationRepository.getRoom(token) - - // chatNetworkDataSource.getRoom(user, token) - // .subscribeOn(Schedulers.io()) - // ?.observeOn(AndroidSchedulers.mainThread()) - // ?.subscribe(GetRoomObserver()) } fun getCapabilities(user: User, token: String, conversationModel: ConversationModel) { @@ -478,12 +481,12 @@ class ChatViewModel @Inject constructor( chatNetworkDataSource.shareToNotes(credentials, url, message, displayName) .subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(object : Observer { + ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { disposableSet.add(d) } - override fun onNext(genericOverall: GenericOverall) { + override fun onNext(genericOverall: ChatOverallSingleMessage) { // unused atm } @@ -609,9 +612,7 @@ class ChatViewModel @Inject constructor( cachedFile.delete() } - fun getCurrentVoiceRecordFile(): String { - return mediaRecorderManager.currentVoiceRecordFile - } + fun getCurrentVoiceRecordFile(): String = mediaRecorderManager.currentVoiceRecordFile fun uploadFile(fileUri: String, room: String, displayName: String, metaData: String) { try { @@ -650,7 +651,7 @@ class ChatViewModel @Inject constructor( chatRepository.handleChatOnBackPress() } - suspend fun getMessageById(url: String, conversationModel: ConversationModel, messageId: Long): Flow = + fun getMessageById(url: String, conversationModel: ConversationModel, messageId: Long): Flow = flow { val bundle = Bundle() bundle.putString(BundleKeys.KEY_CHAT_URL, url) @@ -671,25 +672,6 @@ class ChatViewModel @Inject constructor( fun getPlaybackSpeedPreference(message: ChatMessage) = _voiceMessagePlaybackSpeedPreferences.value?.get(message.user.id) ?: PlaybackSpeed.NORMAL -// inner class GetRoomObserver : Observer { -// override fun onSubscribe(d: Disposable) { -// // unused atm -// } -// -// override fun onNext(conversationModel: ConversationModel) { -// _getRoomViewState.value = GetRoomSuccessState(conversationModel) -// } -// -// override fun onError(e: Throwable) { -// Log.e(TAG, "Error when fetching room") -// _getRoomViewState.value = GetRoomErrorState -// } -// -// override fun onComplete() { -// // unused atm -// } -// } - inner class JoinRoomObserver : Observer { override fun onSubscribe(d: Disposable) { disposableSet.add(d) @@ -788,6 +770,32 @@ class ChatViewModel @Inject constructor( } } + fun deleteTempMessage(chatMessage: ChatMessage) { + viewModelScope.launch { + chatRepository.deleteTempMessage(chatMessage) + } + } + + fun resendMessage(credentials: String, urlForChat: String, message: ChatMessage) { + viewModelScope.launch { + chatRepository.resendChatMessage( + credentials, + urlForChat, + message.message.orEmpty(), + message.actorDisplayName.orEmpty(), + message.parentMessageId?.toIntOrZero() ?: 0, + false, + message.referenceId.orEmpty() + ).collect { result -> + if (result.isSuccess) { + Log.d(TAG, "resend successful") + } else { + Log.e(TAG, "resend failed") + } + } + } + } + companion object { private val TAG = ChatViewModel::class.simpleName const val JOIN_ROOM_RETRY_COUNT: Long = 3 diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt index 54c73869d..02299d309 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt @@ -14,50 +14,40 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import com.nextcloud.talk.chat.data.ChatMessageRepository import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager import com.nextcloud.talk.chat.data.io.AudioRecorderManager import com.nextcloud.talk.chat.data.io.MediaPlayerManager import com.nextcloud.talk.chat.data.model.ChatMessage -import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage -import com.nextcloud.talk.models.json.generic.GenericOverall -import com.nextcloud.talk.utils.preferences.AppPreferences +import com.nextcloud.talk.utils.message.SendMessageUtils import com.stfalcon.chatkit.commons.models.IMessage -import io.reactivex.Observer -import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import java.lang.Thread.sleep +import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("Detekt.TooManyFunctions") class MessageInputViewModel @Inject constructor( - private val chatNetworkDataSource: ChatNetworkDataSource, private val audioRecorderManager: AudioRecorderManager, private val mediaPlayerManager: MediaPlayerManager, - private val audioFocusRequestManager: AudioFocusRequestManager, - private val appPreferences: AppPreferences -) : ViewModel(), DefaultLifecycleObserver { + private val audioFocusRequestManager: AudioFocusRequestManager +) : ViewModel(), + DefaultLifecycleObserver { + enum class LifeCycleFlag { PAUSED, RESUMED, STOPPED } + + lateinit var chatRepository: ChatMessageRepository lateinit var currentLifeCycleFlag: LifeCycleFlag val disposableSet = mutableSetOf() - data class QueuedMessage( - val id: Int, - var message: CharSequence? = null, - val displayName: String? = null, - val replyTo: Int? = null, - val sendWithoutNotification: Boolean? = null - ) - - private var isQueueing: Boolean = false - private var messageQueue: MutableList = mutableListOf() + fun setData(chatMessageRepository: ChatMessageRepository) { + chatRepository = chatMessageRepository + } override fun onResume(owner: LifecycleOwner) { super.onResume(owner) @@ -106,11 +96,12 @@ class MessageInputViewModel @Inject constructor( sealed interface ViewState object SendChatMessageStartState : ViewState class SendChatMessageSuccessState(val message: CharSequence) : ViewState - class SendChatMessageErrorState(val e: Throwable, val message: CharSequence) : ViewState + class SendChatMessageErrorState(val message: CharSequence) : ViewState + private val _sendChatMessageViewState: MutableLiveData = MutableLiveData(SendChatMessageStartState) val sendChatMessageViewState: LiveData get() = _sendChatMessageViewState - object EditMessageStartState : ViewState + object EditMessageErrorState : ViewState class EditMessageSuccessState(val messageEdited: ChatOverallSingleMessage) : ViewState @@ -122,89 +113,93 @@ class MessageInputViewModel @Inject constructor( val isVoicePreviewPlaying: LiveData get() = _isVoicePreviewPlaying - private val _messageQueueSizeFlow = MutableStateFlow(messageQueue.size) - val messageQueueSizeFlow: LiveData - get() = _messageQueueSizeFlow.asLiveData() - - private val _messageQueueFlow: MutableLiveData> = MutableLiveData() - val messageQueueFlow: LiveData> - get() = _messageQueueFlow - private val _callStartedFlow: MutableLiveData> = MutableLiveData() val callStartedFlow: LiveData> get() = _callStartedFlow @Suppress("LongParameterList") fun sendChatMessage( - internalId: String, credentials: String, url: String, - message: CharSequence, + message: String, displayName: String, replyTo: Int, sendWithoutNotification: Boolean ) { - if (isQueueing) { - val tempID = System.currentTimeMillis().toInt() - val qMsg = QueuedMessage(tempID, message, displayName, replyTo, sendWithoutNotification) - messageQueue = appPreferences.getMessageQueue(internalId) - messageQueue.add(qMsg) - appPreferences.saveMessageQueue(internalId, messageQueue) - _messageQueueSizeFlow.update { messageQueue.size } - _messageQueueFlow.postValue(listOf(qMsg)) - return + val referenceId = SendMessageUtils().generateReferenceId() + Log.d(TAG, "Random SHA-256 Hash: $referenceId") + + viewModelScope.launch { + chatRepository.addTemporaryMessage( + message, + displayName, + replyTo, + sendWithoutNotification, + referenceId + ).collect { result -> + if (result.isSuccess) { + Log.d(TAG, "temp message ref id: " + (result.getOrNull()?.referenceId ?: "none")) + + _sendChatMessageViewState.value = SendChatMessageSuccessState(message) + } else { + _sendChatMessageViewState.value = SendChatMessageErrorState(message) + } + } } - chatNetworkDataSource.sendChatMessage( - credentials, - url, - message, - displayName, - replyTo, - sendWithoutNotification - ).subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - disposableSet.add(d) - } + viewModelScope.launch { + chatRepository.sendChatMessage( + credentials, + url, + message, + displayName, + replyTo, + sendWithoutNotification, + referenceId + ).collect { result -> + if (result.isSuccess) { + Log.d(TAG, "received ref id: " + (result.getOrNull()?.referenceId ?: "none")) - override fun onError(e: Throwable) { - _sendChatMessageViewState.value = SendChatMessageErrorState(e, message) - } - - override fun onComplete() { - // unused atm - } - - override fun onNext(t: GenericOverall) { _sendChatMessageViewState.value = SendChatMessageSuccessState(message) + } else { + _sendChatMessageViewState.value = SendChatMessageErrorState(message) } - }) + } + } + } + + fun sendTempMessages(credentials: String, url: String) { + viewModelScope.launch { + chatRepository.sendTempChatMessages( + credentials, + url + ) + } } fun editChatMessage(credentials: String, url: String, text: String) { - chatNetworkDataSource.editChatMessage(credentials, url, text) - .subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - disposableSet.add(d) - } - - override fun onError(e: Throwable) { - Log.e(TAG, "failed to edit message", e) + viewModelScope.launch { + chatRepository.editChatMessage( + credentials, + url, + text + ).collect { result -> + if (result.isSuccess) { + _editMessageViewState.value = EditMessageSuccessState(result.getOrNull()!!) + } else { _editMessageViewState.value = EditMessageErrorState } + } + } + } - override fun onComplete() { - // unused atm - } - - override fun onNext(messageEdited: ChatOverallSingleMessage) { - _editMessageViewState.value = EditMessageSuccessState(messageEdited) - } - }) + fun editTempChatMessage(message: ChatMessage, editedMessageText: String) { + viewModelScope.launch { + chatRepository.editTempChatMessage( + message, + editedMessageText + ).collect {} + } } fun reply(message: IMessage?) { @@ -256,75 +251,11 @@ class MessageInputViewModel @Inject constructor( _getRecordingTime.postValue(time) } - fun sendAndEmptyMessageQueue(internalId: String, credentials: String, url: String) { - if (isQueueing) return - messageQueue.clear() - - val queue = appPreferences.getMessageQueue(internalId) - appPreferences.saveMessageQueue(internalId, null) // empties the queue - while (queue.size > 0) { - val msg = queue.removeAt(0) - sendChatMessage( - internalId, - credentials, - url, - msg.message!!, - msg.displayName!!, - msg.replyTo!!, - msg.sendWithoutNotification!! - ) - sleep(DELAY_BETWEEN_QUEUED_MESSAGES) - } - _messageQueueSizeFlow.tryEmit(0) - } - - fun getTempMessagesFromMessageQueue(internalId: String) { - val queue = appPreferences.getMessageQueue(internalId) - val list = mutableListOf() - for (msg in queue) { - list.add(msg) - } - _messageQueueFlow.postValue(list) - } - - fun switchToMessageQueue(shouldQueue: Boolean) { - isQueueing = shouldQueue - } - - fun restoreMessageQueue(internalId: String) { - messageQueue = appPreferences.getMessageQueue(internalId) - _messageQueueSizeFlow.tryEmit(messageQueue.size) - } - - fun removeFromQueue(internalId: String, id: Int) { - val queue = appPreferences.getMessageQueue(internalId) - for (qMsg in queue) { - if (qMsg.id == id) { - queue.remove(qMsg) - break - } - } - appPreferences.saveMessageQueue(internalId, queue) - _messageQueueSizeFlow.tryEmit(queue.size) - } - - fun editQueuedMessage(internalId: String, id: Int, newMessage: String) { - val queue = appPreferences.getMessageQueue(internalId) - for (qMsg in queue) { - if (qMsg.id == id) { - qMsg.message = newMessage - break - } - } - appPreferences.saveMessageQueue(internalId, queue) - } - fun showCallStartedIndicator(recent: ChatMessage, show: Boolean) { _callStartedFlow.postValue(Pair(recent, show)) } companion object { private val TAG = MessageInputViewModel::class.java.simpleName - private const val DELAY_BETWEEN_QUEUED_MESSAGES: Long = 1000 } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt index f2d30a83b..8a68e366a 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt @@ -10,7 +10,6 @@ package com.nextcloud.talk.conversationlist.data.network import android.util.Log import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource -import com.nextcloud.talk.chat.data.network.OfflineFirstChatRepository import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository import com.nextcloud.talk.data.database.dao.ConversationsDao import com.nextcloud.talk.data.database.mappers.asEntity @@ -107,7 +106,7 @@ class OfflineFirstConversationsRepository @Inject constructor( var conversationsFromSync: List? = null if (!monitor.isOnline.first()) { - Log.d(OfflineFirstChatRepository.TAG, "Device is offline, can't load conversations from server") + Log.d(TAG, "Device is offline, can't load conversations from server") return null } diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt index 0efa202ce..b79b4d573 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt @@ -61,7 +61,6 @@ import com.nextcloud.talk.translate.repositories.TranslateRepositoryImpl import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew -import com.nextcloud.talk.utils.preferences.AppPreferences import dagger.Module import dagger.Provides import okhttp3.OkHttpClient @@ -74,87 +73,66 @@ class RepositoryModule { ncApi: NcApi, ncApiCoroutines: NcApiCoroutines, userProvider: CurrentUserProviderNew - ): ConversationsRepository { - return ConversationsRepositoryImpl(ncApi, ncApiCoroutines, userProvider) - } + ): ConversationsRepository = ConversationsRepositoryImpl(ncApi, ncApiCoroutines, userProvider) @Provides - fun provideSharedItemsRepository(ncApi: NcApi, dateUtils: DateUtils): SharedItemsRepository { - return SharedItemsRepositoryImpl(ncApi, dateUtils) - } + fun provideSharedItemsRepository(ncApi: NcApi, dateUtils: DateUtils): SharedItemsRepository = + SharedItemsRepositoryImpl(ncApi, dateUtils) @Provides - fun provideUnifiedSearchRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): UnifiedSearchRepository { - return UnifiedSearchRepositoryImpl(ncApi, userProvider) - } + fun provideUnifiedSearchRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): UnifiedSearchRepository = + UnifiedSearchRepositoryImpl(ncApi, userProvider) @Provides - fun provideDialogPollRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): PollRepository { - return PollRepositoryImpl(ncApi, userProvider) - } + fun provideDialogPollRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): PollRepository = + PollRepositoryImpl(ncApi, userProvider) @Provides fun provideRemoteFileBrowserItemsRepository( okHttpClient: OkHttpClient, userProvider: CurrentUserProviderNew - ): RemoteFileBrowserItemsRepository { - return RemoteFileBrowserItemsRepositoryImpl(okHttpClient, userProvider) - } + ): RemoteFileBrowserItemsRepository = RemoteFileBrowserItemsRepositoryImpl(okHttpClient, userProvider) @Provides - fun provideUsersRepository(database: TalkDatabase): UsersRepository { - return UsersRepositoryImpl(database.usersDao()) - } + fun provideUsersRepository(database: TalkDatabase): UsersRepository = UsersRepositoryImpl(database.usersDao()) @Provides - fun provideArbitraryStoragesRepository(database: TalkDatabase): ArbitraryStoragesRepository { - return ArbitraryStoragesRepositoryImpl(database.arbitraryStoragesDao()) - } + fun provideArbitraryStoragesRepository(database: TalkDatabase): ArbitraryStoragesRepository = + ArbitraryStoragesRepositoryImpl(database.arbitraryStoragesDao()) @Provides fun provideReactionsRepository( ncApi: NcApi, userProvider: CurrentUserProviderNew, dao: ChatMessagesDao - ): ReactionsRepository { - return ReactionsRepositoryImpl(ncApi, userProvider, dao) - } + ): ReactionsRepository = ReactionsRepositoryImpl(ncApi, userProvider, dao) @Provides - fun provideCallRecordingRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): CallRecordingRepository { - return CallRecordingRepositoryImpl(ncApi, userProvider) - } + fun provideCallRecordingRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): CallRecordingRepository = + CallRecordingRepositoryImpl(ncApi, userProvider) @Provides fun provideRequestAssistanceRepository( ncApi: NcApi, userProvider: CurrentUserProviderNew - ): RequestAssistanceRepository { - return RequestAssistanceRepositoryImpl(ncApi, userProvider) - } + ): RequestAssistanceRepository = RequestAssistanceRepositoryImpl(ncApi, userProvider) @Provides fun provideOpenConversationsRepository( ncApi: NcApi, userProvider: CurrentUserProviderNew - ): OpenConversationsRepository { - return OpenConversationsRepositoryImpl(ncApi, userProvider) - } + ): OpenConversationsRepository = OpenConversationsRepositoryImpl(ncApi, userProvider) @Provides - fun translateRepository(ncApi: NcApi): TranslateRepository { - return TranslateRepositoryImpl(ncApi) - } + fun translateRepository(ncApi: NcApi): TranslateRepository = TranslateRepositoryImpl(ncApi) @Provides - fun provideChatNetworkDataSource(ncApi: NcApi, ncApiCoroutines: NcApiCoroutines): ChatNetworkDataSource { - return RetrofitChatNetwork(ncApi, ncApiCoroutines) - } + fun provideChatNetworkDataSource(ncApi: NcApi, ncApiCoroutines: NcApiCoroutines): ChatNetworkDataSource = + RetrofitChatNetwork(ncApi, ncApiCoroutines) @Provides - fun provideConversationsNetworkDataSource(ncApi: NcApi): ConversationsNetworkDataSource { - return RetrofitConversationsNetwork(ncApi) - } + fun provideConversationsNetworkDataSource(ncApi: NcApi): ConversationsNetworkDataSource = + RetrofitConversationsNetwork(ncApi) @Provides fun provideConversationInfoEditRepository( @@ -166,33 +144,27 @@ class RepositoryModule { } @Provides - fun provideConversationRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): ConversationRepository { - return ConversationRepositoryImpl(ncApi, userProvider) - } + fun provideConversationRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): ConversationRepository = + ConversationRepositoryImpl(ncApi, userProvider) @Provides - fun provideInvitationsRepository(ncApi: NcApi): InvitationsRepository { - return InvitationsRepositoryImpl(ncApi) - } + fun provideInvitationsRepository(ncApi: NcApi): InvitationsRepository = InvitationsRepositoryImpl(ncApi) @Provides fun provideOfflineFirstChatRepository( chatMessagesDao: ChatMessagesDao, chatBlocksDao: ChatBlocksDao, dataSource: ChatNetworkDataSource, - appPreferences: AppPreferences, networkMonitor: NetworkMonitor, userProvider: CurrentUserProviderNew - ): ChatMessageRepository { - return OfflineFirstChatRepository( + ): ChatMessageRepository = + OfflineFirstChatRepository( chatMessagesDao, chatBlocksDao, dataSource, - appPreferences, networkMonitor, userProvider ) - } @Provides fun provideOfflineFirstConversationsRepository( @@ -201,26 +173,22 @@ class RepositoryModule { chatNetworkDataSource: ChatNetworkDataSource, networkMonitor: NetworkMonitor, currentUserProviderNew: CurrentUserProviderNew - ): OfflineConversationsRepository { - return OfflineFirstConversationsRepository( + ): OfflineConversationsRepository = + OfflineFirstConversationsRepository( dao, dataSource, chatNetworkDataSource, networkMonitor, currentUserProviderNew ) - } @Provides - fun provideContactsRepository(ncApiCoroutines: NcApiCoroutines, userManager: UserManager): ContactsRepository { - return ContactsRepositoryImpl(ncApiCoroutines, userManager) - } + fun provideContactsRepository(ncApiCoroutines: NcApiCoroutines, userManager: UserManager): ContactsRepository = + ContactsRepositoryImpl(ncApiCoroutines, userManager) @Provides fun provideConversationCreationRepository( ncApiCoroutines: NcApiCoroutines, userManager: UserManager - ): ConversationCreationRepository { - return ConversationCreationRepositoryImpl(ncApiCoroutines, userManager) - } + ): ConversationCreationRepository = ConversationCreationRepositoryImpl(ncApiCoroutines, userManager) } diff --git a/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatMessagesDao.kt b/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatMessagesDao.kt index 6fbf61ca1..1008ce853 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatMessagesDao.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatMessagesDao.kt @@ -16,12 +16,14 @@ import com.nextcloud.talk.data.database.model.ChatMessageEntity import kotlinx.coroutines.flow.Flow @Dao +@Suppress("Detekt.TooManyFunctions") interface ChatMessagesDao { @Query( """ SELECT MAX(id) as max_items FROM ChatMessages WHERE internalConversationId = :internalConversationId + AND isTemporary = 0 """ ) fun getNewestMessageId(internalConversationId: String): Long @@ -31,11 +33,35 @@ interface ChatMessagesDao { SELECT * FROM ChatMessages WHERE internalConversationId = :internalConversationId + AND isTemporary = 0 ORDER BY timestamp DESC, id DESC """ ) fun getMessagesForConversation(internalConversationId: String): Flow> + @Query( + """ + SELECT * + FROM ChatMessages + WHERE internalConversationId = :internalConversationId + AND isTemporary = 1 + ORDER BY timestamp DESC, id DESC + """ + ) + fun getTempMessagesForConversation(internalConversationId: String): Flow> + + @Query( + """ + SELECT * + FROM ChatMessages + WHERE internalConversationId = :internalConversationId + AND referenceId = :referenceId + AND isTemporary = 1 + ORDER BY timestamp DESC, id DESC + """ + ) + fun getTempMessageForConversation(internalConversationId: String, referenceId: String): Flow + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertChatMessages(chatMessages: List) @@ -54,10 +80,20 @@ interface ChatMessagesDao { @Query( value = """ DELETE FROM ChatMessages - WHERE id in (:messageIds) + WHERE internalId in (:internalIds) """ ) - fun deleteChatMessages(messageIds: List) + fun deleteChatMessages(internalIds: List) + + @Query( + value = """ + DELETE FROM ChatMessages + WHERE internalConversationId = :internalConversationId + AND referenceId in (:referenceIds) + AND isTemporary = 1 + """ + ) + fun deleteTempChatMessages(internalConversationId: String, referenceIds: List) @Update fun updateChatMessage(message: ChatMessageEntity) @@ -77,6 +113,7 @@ interface ChatMessagesDao { SELECT * FROM ChatMessages WHERE internalConversationId = :internalConversationId AND id >= :messageId + AND isTemporary = 0 ORDER BY timestamp ASC, id ASC """ ) @@ -87,6 +124,7 @@ interface ChatMessagesDao { SELECT * FROM ChatMessages WHERE internalConversationId = :internalConversationId + AND isTemporary = 0 AND id < :messageId ORDER BY timestamp DESC, id DESC LIMIT :limit @@ -103,6 +141,7 @@ interface ChatMessagesDao { SELECT * FROM ChatMessages WHERE internalConversationId = :internalConversationId + AND isTemporary = 0 AND id <= :messageId ORDER BY timestamp DESC, id DESC LIMIT :limit @@ -119,6 +158,7 @@ interface ChatMessagesDao { SELECT COUNT(*) FROM ChatMessages WHERE internalConversationId = :internalConversationId + AND isTemporary = 0 AND id BETWEEN :newestMessageId AND :oldestMessageId """ ) diff --git a/app/src/main/java/com/nextcloud/talk/data/database/mappers/ChatMessageMapUtils.kt b/app/src/main/java/com/nextcloud/talk/data/database/mappers/ChatMessageMapUtils.kt index 30b856a6b..7aa236369 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/mappers/ChatMessageMapUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/mappers/ChatMessageMapUtils.kt @@ -10,6 +10,7 @@ package com.nextcloud.talk.data.database.mappers import com.nextcloud.talk.models.json.chat.ChatMessageJson import com.nextcloud.talk.data.database.model.ChatMessageEntity import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.models.json.chat.ReadStatus fun ChatMessageJson.asEntity(accountId: Long) = ChatMessageEntity( @@ -37,7 +38,9 @@ fun ChatMessageJson.asEntity(accountId: Long) = lastEditActorId = lastEditActorId, lastEditActorType = lastEditActorType, lastEditTimestamp = lastEditTimestamp, - deleted = deleted + deleted = deleted, + referenceId = referenceId, + silent = silent ) fun ChatMessageEntity.asModel() = @@ -62,7 +65,12 @@ fun ChatMessageEntity.asModel() = lastEditActorId = lastEditActorId, lastEditActorType = lastEditActorType, lastEditTimestamp = lastEditTimestamp, - isDeleted = deleted + isDeleted = deleted, + referenceId = referenceId, + isTemporary = isTemporary, + sendingFailed = sendingFailed, + readStatus = ReadStatus.NONE, + silent = silent ) fun ChatMessageJson.asModel() = @@ -87,5 +95,7 @@ fun ChatMessageJson.asModel() = lastEditActorId = lastEditActorId, lastEditActorType = lastEditActorType, lastEditTimestamp = lastEditTimestamp, - isDeleted = deleted + isDeleted = deleted, + referenceId = referenceId, + silent = silent ) diff --git a/app/src/main/java/com/nextcloud/talk/data/database/model/ChatMessageEntity.kt b/app/src/main/java/com/nextcloud/talk/data/database/model/ChatMessageEntity.kt index dbf1cce92..c00b2c2e7 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/model/ChatMessageEntity.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/model/ChatMessageEntity.kt @@ -52,6 +52,7 @@ data class ChatMessageEntity( @ColumnInfo(name = "deleted") var deleted: Boolean = false, @ColumnInfo(name = "expirationTimestamp") var expirationTimestamp: Int = 0, @ColumnInfo(name = "isReplyable") var replyable: Boolean = false, + @ColumnInfo(name = "isTemporary") var isTemporary: Boolean = false, @ColumnInfo(name = "lastEditActorDisplayName") var lastEditActorDisplayName: String? = null, @ColumnInfo(name = "lastEditActorId") var lastEditActorId: String? = null, @ColumnInfo(name = "lastEditActorType") var lastEditActorType: String? = null, @@ -62,8 +63,9 @@ data class ChatMessageEntity( @ColumnInfo(name = "parent") var parentMessageId: Long? = null, @ColumnInfo(name = "reactions") var reactions: LinkedHashMap? = null, @ColumnInfo(name = "reactionsSelf") var reactionsSelf: ArrayList? = null, + @ColumnInfo(name = "referenceId") var referenceId: String? = null, + @ColumnInfo(name = "sendingFailed") var sendingFailed: Boolean = false, + @ColumnInfo(name = "silent") var silent: Boolean = false, @ColumnInfo(name = "systemMessage") var systemMessageType: ChatMessage.SystemMessageType, @ColumnInfo(name = "timestamp") var timestamp: Long = 0 - // missing/not needed: referenceId - // missing/not needed: silent ) diff --git a/app/src/main/java/com/nextcloud/talk/data/source/local/Migrations.kt b/app/src/main/java/com/nextcloud/talk/data/source/local/Migrations.kt index 3e61f699d..85c3239be 100644 --- a/app/src/main/java/com/nextcloud/talk/data/source/local/Migrations.kt +++ b/app/src/main/java/com/nextcloud/talk/data/source/local/Migrations.kt @@ -48,6 +48,13 @@ object Migrations { } } + val MIGRATION_12_13 = object : Migration(12, 13) { + override fun migrate(db: SupportSQLiteDatabase) { + Log.i("Migrations", "Migrating 12 to 13") + addTempMessagesSupport(db) + } + } + fun migrateToRoom(db: SupportSQLiteDatabase) { db.execSQL( "CREATE TABLE User_new (" + @@ -257,4 +264,42 @@ object Migrations { Log.i("Migrations", "hasArchived already exists") } } + + fun addTempMessagesSupport(db: SupportSQLiteDatabase) { + try { + db.execSQL( + "ALTER TABLE ChatMessages " + + "ADD COLUMN referenceId TEXT;" + ) + } catch (e: SQLException) { + Log.i("Migrations", "Something went wrong when adding column referenceId to table ChatMessages") + } + + try { + db.execSQL( + "ALTER TABLE ChatMessages " + + "ADD COLUMN isTemporary INTEGER NOT NULL DEFAULT 0;" + ) + } catch (e: SQLException) { + Log.i("Migrations", "Something went wrong when adding column isTemporary to table ChatMessages") + } + + try { + db.execSQL( + "ALTER TABLE ChatMessages " + + "ADD COLUMN sendingFailed INTEGER NOT NULL DEFAULT 0;" + ) + } catch (e: SQLException) { + Log.i("Migrations", "Something went wrong when adding column sendingFailed to table ChatMessages") + } + + try { + db.execSQL( + "ALTER TABLE ChatMessages " + + "ADD COLUMN silent INTEGER NOT NULL DEFAULT 0;" + ) + } catch (e: SQLException) { + Log.i("Migrations", "Something went wrong when adding column silent to table ChatMessages") + } + } } diff --git a/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt b/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt index 053ad4766..5c3656c76 100644 --- a/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt +++ b/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt @@ -49,7 +49,7 @@ import java.util.Locale ChatMessageEntity::class, ChatBlockEntity::class ], - version = 12, + version = 13, autoMigrations = [ AutoMigration(from = 9, to = 11) ], @@ -114,7 +114,8 @@ abstract class TalkDatabase : RoomDatabase() { Migrations.MIGRATION_7_8, Migrations.MIGRATION_8_9, Migrations.MIGRATION_10_11, - Migrations.MIGRATION_11_12 + Migrations.MIGRATION_11_12, + Migrations.MIGRATION_12_13 ) .allowMainThreadQueries() .addCallback( @@ -128,8 +129,8 @@ abstract class TalkDatabase : RoomDatabase() { .build() } - private fun getCipherMigrationHook(): SQLiteDatabaseHook { - return object : SQLiteDatabaseHook { + private fun getCipherMigrationHook(): SQLiteDatabaseHook = + object : SQLiteDatabaseHook { override fun preKey(database: SQLiteDatabase) { // unused atm } @@ -140,6 +141,5 @@ abstract class TalkDatabase : RoomDatabase() { Log.i(TAG, "DB cipher_migrate END") } } - } } } diff --git a/app/src/main/java/com/nextcloud/talk/extensions/LongFormatExtension.kt b/app/src/main/java/com/nextcloud/talk/extensions/LongFormatExtension.kt new file mode 100644 index 000000000..02cbe262f --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/extensions/LongFormatExtension.kt @@ -0,0 +1,15 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Marcel Hibbe + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.extensions + +fun Long.toIntOrZero(): Int { + return if (this >= Int.MIN_VALUE && this <= Int.MAX_VALUE) { + toInt() + } else { + 0 + } +} diff --git a/app/src/main/java/com/nextcloud/talk/jobs/AccountRemovalWorker.java b/app/src/main/java/com/nextcloud/talk/jobs/AccountRemovalWorker.java index 6f273ab32..8679f6504 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/AccountRemovalWorker.java +++ b/app/src/main/java/com/nextcloud/talk/jobs/AccountRemovalWorker.java @@ -192,7 +192,6 @@ public class AccountRemovalWorker extends Worker { if (user.getId() != null) { String username = user.getUsername(); try { - appPreferences.deleteAllMessageQueuesFor(user.getUserId()); userManager.deleteUser(user.getId()); Log.d(TAG, "deleted user: " + username); } catch (Throwable e) { diff --git a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessageJson.kt b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessageJson.kt index 024e13fe6..727ac8847 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessageJson.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessageJson.kt @@ -42,5 +42,7 @@ data class ChatMessageJson( @JsonField(name = ["lastEditActorId"]) var lastEditActorId: String? = null, @JsonField(name = ["lastEditActorType"]) var lastEditActorType: String? = null, @JsonField(name = ["lastEditTimestamp"]) var lastEditTimestamp: Long? = 0, - @JsonField(name = ["deleted"]) var deleted: Boolean = false + @JsonField(name = ["deleted"]) var deleted: Boolean = false, + @JsonField(name = ["referenceId"]) var referenceId: String? = null, + @JsonField(name = ["silent"]) var silent: Boolean = false ) : Parcelable diff --git a/app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt b/app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt index 09984c0c6..223fed968 100644 --- a/app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt +++ b/app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt @@ -23,13 +23,14 @@ import com.nextcloud.talk.R import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.models.json.generic.GenericOverall +import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.NotificationUtils 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.bundle.BundleKeys.KEY_SYSTEM_NOTIFICATION_ID +import com.nextcloud.talk.utils.message.SendMessageUtils import io.reactivex.Observer import io.reactivex.Single import io.reactivex.android.schedulers.AndroidSchedulers @@ -71,24 +72,31 @@ class DirectReplyReceiver : BroadcastReceiver() { sendDirectReply() } - private fun getMessageText(intent: Intent): CharSequence? { - return RemoteInput.getResultsFromIntent(intent)?.getCharSequence(NotificationUtils.KEY_DIRECT_REPLY) - } + private fun getMessageText(intent: Intent): CharSequence? = + RemoteInput.getResultsFromIntent(intent)?.getCharSequence(NotificationUtils.KEY_DIRECT_REPLY) private fun sendDirectReply() { val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) val apiVersion = ApiUtils.getChatApiVersion(currentUser.capabilities!!.spreedCapability!!, intArrayOf(1)) val url = ApiUtils.getUrlForChat(apiVersion, currentUser.baseUrl!!, roomToken!!) - ncApi.sendChatMessage(credentials, url, replyMessage, currentUser.displayName, null, false) + ncApi.sendChatMessage( + credentials, + url, + replyMessage, + currentUser.displayName, + null, + false, + SendMessageUtils().generateReferenceId() + ) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(object : Observer { + ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { // unused atm } - override fun onNext(genericOverall: GenericOverall) { + override fun onNext(message: ChatOverallSingleMessage) { confirmReplySent() } diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/TempMessageActionsDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/TempMessageActionsDialog.kt new file mode 100644 index 000000000..a75592532 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/TempMessageActionsDialog.kt @@ -0,0 +1,133 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2022 Marcel Hibbe + * SPDX-FileCopyrightText: 2022 Andy Scherzinger + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.ui.dialog + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope +import autodagger.AutoInjector +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.nextcloud.talk.R +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.chat.ChatActivity +import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.data.network.NetworkMonitor +import com.nextcloud.talk.databinding.DialogTempMessageActionsBinding +import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.DateUtils +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AutoInjector(NextcloudTalkApplication::class) +class TempMessageActionsDialog( + private val chatActivity: ChatActivity, + private val message: ChatMessage +) : BottomSheetDialog(chatActivity) { + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + @Inject + lateinit var dateUtils: DateUtils + + @Inject + lateinit var networkMonitor: NetworkMonitor + + private lateinit var binding: DialogTempMessageActionsBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + NextcloudTalkApplication.sharedApplication?.componentApplication?.inject(this) + + binding = DialogTempMessageActionsBinding.inflate(layoutInflater) + setContentView(binding.root) + window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + + viewThemeUtils.material.colorBottomSheetBackground(binding.root) + viewThemeUtils.material.colorBottomSheetDragHandle(binding.bottomSheetDragHandle) + initMenuItems() + } + + private fun initMenuItems() { + this.lifecycleScope.launch { + val isOnline = networkMonitor.isOnline.first() + initResendMessage(message.sendingFailed && isOnline) + initMenuEditMessage(message.sendingFailed || !isOnline) + initMenuDeleteMessage(message.sendingFailed || !isOnline) + initMenuItemCopy() + } + } + + override fun onStart() { + super.onStart() + val bottomSheet = findViewById(R.id.design_bottom_sheet) + val behavior = BottomSheetBehavior.from(bottomSheet as View) + behavior.state = BottomSheetBehavior.STATE_COLLAPSED + } + + private fun initResendMessage(visible: Boolean) { + if (visible) { + binding.menuResendMessage.setOnClickListener { + chatActivity.chatViewModel.resendMessage( + chatActivity.conversationUser!!.getCredentials(), + ApiUtils.getUrlForChat( + chatActivity.chatApiVersion, + chatActivity.conversationUser!!.baseUrl!!, + chatActivity.roomToken + ), + message + ) + dismiss() + } + } + binding.menuResendMessage.visibility = getVisibility(visible) + } + + private fun initMenuDeleteMessage(visible: Boolean) { + if (visible) { + binding.menuDeleteMessage.setOnClickListener { + chatActivity.chatViewModel.deleteTempMessage(message) + dismiss() + } + } + binding.menuDeleteMessage.visibility = getVisibility(visible) + } + + private fun initMenuEditMessage(visible: Boolean) { + if (visible) { + binding.menuEditMessage.setOnClickListener { + chatActivity.messageInputViewModel.edit(message) + dismiss() + } + } + binding.menuEditMessage.visibility = getVisibility(visible) + } + + private fun initMenuItemCopy() { + binding.menuCopyMessage.setOnClickListener { + chatActivity.copyMessage(message) + dismiss() + } + } + + private fun getVisibility(visible: Boolean): Int { + return if (visible) { + View.VISIBLE + } else { + View.GONE + } + } + + companion object { + private val TAG = TempMessageActionsDialog::class.java.simpleName + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/message/SendMessageUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/message/SendMessageUtils.kt new file mode 100644 index 000000000..547cc6c58 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/message/SendMessageUtils.kt @@ -0,0 +1,32 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Marcel Hibbe + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils.message + +import java.security.MessageDigest +import java.util.Calendar +import java.util.UUID + +class SendMessageUtils { + fun generateReferenceId(): String { + val randomString = UUID.randomUUID().toString() + val digest = MessageDigest.getInstance("SHA-256") + val hashBytes = digest.digest(randomString.toByteArray(Charsets.UTF_8)) + return hashBytes.joinToString("") { "%02x".format(it) } + } + + @Suppress("MagicNumber") + fun removeYearFromTimestamp(timestampMillis: Long): Int { + val calendar = Calendar.getInstance().apply { timeInMillis = timestampMillis } + + val month = calendar.get(Calendar.MONTH) + val day = calendar.get(Calendar.DAY_OF_MONTH) + val hour = calendar.get(Calendar.HOUR_OF_DAY) + val minute = calendar.get(Calendar.MINUTE) + val second = calendar.get(Calendar.SECOND) + return (month * 1000000) + (day * 10000) + (hour * 100) + (minute * 10) + second + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java index a9265ae8e..beb152a69 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java @@ -175,12 +175,6 @@ public interface AppPreferences { int getLastKnownId(String internalConversationId, int defaultValue); - void saveMessageQueue(String internalConversationId, List queue); - - List getMessageQueue(String internalConversationId); - - void deleteAllMessageQueuesFor(String userId); - void saveVoiceMessagePlaybackSpeedPreferences(Map speeds); Map readVoiceMessagePlaybackSpeedPreferences(); diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt index c1e611b9a..d0867b6f8 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt @@ -17,7 +17,6 @@ import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.nextcloud.talk.R -import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel import com.nextcloud.talk.ui.PlaybackSpeed import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async @@ -501,76 +500,6 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { return if (lastReadId.isNotEmpty()) lastReadId.toInt() else defaultValue } - override fun saveMessageQueue( - internalConversationId: String, - queue: MutableList? - ) { - runBlocking { - async { - var queueStr = "" - queue?.let { - for (msg in queue) { - val msgStr = "${msg.id},${msg.message},${msg.replyTo},${msg.displayName},${ - msg - .sendWithoutNotification - }^" - queueStr += msgStr - } - } - writeString(internalConversationId + MESSAGE_QUEUE, queueStr) - } - } - } - - @Suppress("Detekt.TooGenericExceptionCaught") - override fun getMessageQueue(internalConversationId: String): MutableList { - val queueStr = - runBlocking { async { readString(internalConversationId + MESSAGE_QUEUE).first() } }.getCompleted() - - val queue: MutableList = mutableListOf() - if (queueStr.isEmpty()) return queue - - for (msgStr in queueStr.split("^")) { - try { - if (msgStr.isNotEmpty()) { - val msgArray = msgStr.split(",") - val id = msgArray[ID].toInt() - val message = msgArray[MESSAGE_INDEX] - val replyTo = msgArray[REPLY_TO_INDEX].toInt() - val displayName = msgArray[DISPLAY_NAME_INDEX] - val silent = msgArray[SILENT_INDEX].toBoolean() - - val qMsg = MessageInputViewModel.QueuedMessage(id, message, displayName, replyTo, silent) - queue.add(qMsg) - } - } catch (e: IndexOutOfBoundsException) { - Log.e(TAG, "Message string: $msgStr\n Queue String: $queueStr \n$e") - } - } - - return queue - } - - override fun deleteAllMessageQueuesFor(userId: String) { - runBlocking { - async { - val keyList = mutableListOf>() - val preferencesMap = context.dataStore.data.first().asMap() - for (preference in preferencesMap) { - if (preference.key.name.contains("$userId@")) { - keyList.add(preference.key) - } - } - - for (key in keyList) { - context.dataStore.edit { - it.remove(key) - } - } - } - } - } - override fun saveVoiceMessagePlaybackSpeedPreferences(speeds: Map) { Json.encodeToString(speeds).let { runBlocking { async { writeString(VOICE_MESSAGE_PLAYBACK_SPEEDS, it) } } @@ -655,13 +584,7 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { @Suppress("UnusedPrivateProperty") private val TAG = AppPreferencesImpl::class.simpleName private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") - private const val ID: Int = 0 - private const val MESSAGE_INDEX: Int = 1 - private const val REPLY_TO_INDEX: Int = 2 - private const val DISPLAY_NAME_INDEX: Int = 3 - private const val SILENT_INDEX: Int = 4 const val PROXY_TYPE = "proxy_type" - const val PROXY_SERVER = "proxy_server" const val PROXY_HOST = "proxy_host" const val PROXY_PORT = "proxy_port" const val PROXY_CRED = "proxy_credentials" @@ -686,7 +609,6 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { const val DB_ROOM_MIGRATED = "db_room_migrated" const val PHONE_BOOK_INTEGRATION_LAST_RUN = "phone_book_integration_last_run" const val TYPING_STATUS = "typing_status" - const val MESSAGE_QUEUE = "@message_queue" const val VOICE_MESSAGE_PLAYBACK_SPEEDS = "voice_message_playback_speeds" const val SHOW_REGULAR_NOTIFICATION_WARNING = "show_regular_notification_warning" const val LAST_NOTIFICATION_WARNING = "last_notification_warning" diff --git a/app/src/main/res/drawable/baseline_error_outline_24.xml b/app/src/main/res/drawable/baseline_error_outline_24.xml new file mode 100644 index 000000000..b040255b5 --- /dev/null +++ b/app/src/main/res/drawable/baseline_error_outline_24.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/baseline_report_problem_24.xml b/app/src/main/res/drawable/baseline_report_problem_24.xml new file mode 100644 index 000000000..697250acc --- /dev/null +++ b/app/src/main/res/drawable/baseline_report_problem_24.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/baseline_schedule_24.xml b/app/src/main/res/drawable/baseline_schedule_24.xml new file mode 100644 index 000000000..c5334702b --- /dev/null +++ b/app/src/main/res/drawable/baseline_schedule_24.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/app/src/main/res/layout/dialog_temp_message_actions.xml b/app/src/main/res/layout/dialog_temp_message_actions.xml new file mode 100644 index 000000000..98d51ba81 --- /dev/null +++ b/app/src/main/res/layout/dialog_temp_message_actions.xml @@ -0,0 +1,165 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_custom_outcoming_text_message.xml b/app/src/main/res/layout/item_custom_outcoming_text_message.xml index 4d0d2d452..711367f4b 100644 --- a/app/src/main/res/layout/item_custom_outcoming_text_message.xml +++ b/app/src/main/res/layout/item_custom_outcoming_text_message.xml @@ -43,7 +43,6 @@ android:textIsSelectable="false" tools:text="Talk to you later!" /> - + + + app:tint="@color/high_emphasis_text" + tools:src="@drawable/ic_warning_white"/> + + - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4a20c4251..088b58c22 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -433,6 +433,9 @@ How to translate with transifex: You: %1$s Message read Message sent + Offline + Failed + Sending Failed to send message: Remote audio off Add attachment @@ -820,7 +823,6 @@ How to translate with transifex: Show banned participants Bans list Connection lost - Sent messages are queued - Connection lost - %1$d are queued Connection established Message deleted by you Unban @@ -843,4 +845,5 @@ How to translate with transifex: %1$s is out of office and might not respond %1$s is out of office today Replacement: + Resend diff --git a/scripts/analysis/lint-results.txt b/scripts/analysis/lint-results.txt index bc7f6c076..7db741857 100644 --- a/scripts/analysis/lint-results.txt +++ b/scripts/analysis/lint-results.txt @@ -1,2 +1,2 @@ DO NOT TOUCH; GENERATED BY DRONE - Lint Report: 36 errors and 106 warnings + Lint Report: 36 errors and 104 warnings