diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index e6b013469a..3b3926652c 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -892,6 +892,7 @@ Delivered Read Not Delivered. Tap to retry + Not Delivered. You can\'t post in this chat Yesterday Message Not Sent Your message could not be delivered. Would you like to try again? diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 0b667255b2..0fed26f032 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -1171,6 +1171,7 @@ internal class ChatViewModel @AssistedInject constructor( when (message.deliveryStatus) { DeliveryStatus.SENDING -> ReceiptStatus.SENDING DeliveryStatus.FAILED -> ReceiptStatus.FAILED + DeliveryStatus.DENIED -> ReceiptStatus.DENIED DeliveryStatus.SENT -> ReceiptStatus.SENT } } else null diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt index a313076253..740ef1663f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt @@ -62,6 +62,7 @@ internal fun ReceiptLabel( ReceiptStatus.SENT -> stringResource(R.string.label_chatReceipt_delivered) ReceiptStatus.READ -> stringResource(R.string.label_chatReceipt_read) ReceiptStatus.FAILED -> stringResource(R.string.label_chatReceipt_notSent) + ReceiptStatus.DENIED -> stringResource(R.string.label_chatReceipt_denied) else -> return@AnimatedContent } @@ -82,7 +83,7 @@ internal fun ReceiptLabel( style = CodeTheme.typography.caption.copy( fontWeight = FontWeight.Bold, ), - color = if (animatedStatus == ReceiptStatus.FAILED) { + color = if (animatedStatus == ReceiptStatus.FAILED || animatedStatus == ReceiptStatus.DENIED) { CodeTheme.colors.errorText } else { CodeTheme.colors.textSecondary diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt index a7ff452d22..1a098a9617 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt @@ -71,7 +71,7 @@ internal fun shouldShowReceiptLabel( // A split message's receipt goes under its last row only; the rows above share its status. if (!item.isLastRow) return false val status = effectiveReceiptStatus(item, otherReadPointer) ?: return false - if (status == ReceiptStatus.FAILED) return true + if (status == ReceiptStatus.FAILED || status == ReceiptStatus.DENIED) return true val confirmed = item.confirmedStatus(otherReadPointer, settlingKey) ?: return false // Walking toward the newest: it keeps its line while nothing newer takes it -- no newer send diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt index ebb492667f..ea9e089f38 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptHandoffTest.kt @@ -75,6 +75,13 @@ class ReceiptHandoffTest { assertEquals(listOf(true, true), shown(rows, settlingKey = send.messageKey)) } + @Test + fun `a denied send always shows its line`() { + val send = mine(0, ReceiptStatus.DENIED, clientId = "c2") + val rows = listOf(send, mine(1, ReceiptStatus.SENT)) + assertEquals(listOf(true, true), shown(rows, settlingKey = send.messageKey)) + } + @Test fun `a first send that is settling shows no line anywhere`() { val send = mine(1, ReceiptStatus.SENT, clientId = "c1") diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index cde94003e6..0d15fffe6d 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -9,7 +9,7 @@ import com.flipcash.shared.chat.ui.UndecryptableHint import com.getcode.opencode.model.core.ID import kotlin.time.Instant -enum class ReceiptStatus { SENDING, SENT, READ, FAILED } +enum class ReceiptStatus { SENDING, SENT, READ, FAILED, DENIED } sealed interface ChatListItem { val itemKey: Any diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt index d0f55a7c03..355056b44c 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/MediaMessageBubble.kt @@ -151,7 +151,10 @@ internal fun MediaMessageBubble( val progress by remember(item.pendingClientIdHex) { derivedStateOf { item.pendingClientIdHex?.let { progressOf()[it] } } } - val failed = item.receiptStatus == ReceiptStatus.FAILED || progress is MediaSendProgress.Failed + val denied = item.receiptStatus == ReceiptStatus.DENIED + val failed = denied || item.receiptStatus == ReceiptStatus.FAILED || progress is MediaSendProgress.Failed + // A denied or terminal failure still renders as failed, but a tap can't fix it. + val retryable = !denied && (progress as? MediaSendProgress.Failed)?.retryable != false var loadError by remember(model) { mutableStateOf(null) } val unavailable = loadError?.let(::isPhotoUnavailable) == true @@ -159,7 +162,7 @@ internal fun MediaMessageBubble( val onClick: (() -> Unit)? = when { !interactive -> null - failed -> ({ actionHandler(ChatAction.RetryMessage(item)) }) + failed -> if (retryable) ({ actionHandler(ChatAction.RetryMessage(item)) }) else null canOpenPhoto(blurhashOnly, failed = false, unavailable = unavailable) && localUri == null -> ({ actionHandler(ChatAction.OpenPhoto(item)) }) else -> null diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MediaSendDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MediaSendDelegate.kt index 95b2d3f9b1..f643f84f7c 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MediaSendDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MediaSendDelegate.kt @@ -333,9 +333,12 @@ class MediaSendDelegate internal constructor( } } .onFailure { cause -> - // A chat that stopped taking ciphertext won't take this blob on a retry either. - val terminal = cause is SendMessageError.EncryptionNotAllowed - fail(chatId, clientMessageId, hex, retryable = !terminal, discard = record.takeIf { terminal }) + // A chat that stopped taking ciphertext won't take this blob on a retry either, and + // a denied sender won't be let in by one. + val denied = cause is SendMessageError.Denied + val terminal = denied || cause is SendMessageError.EncryptionNotAllowed + fail(chatId, clientMessageId, hex, retryable = !terminal, discard = record.takeIf { terminal }, denied = denied) + if (denied) messaging.rereadAfterDenied(chatId) } } @@ -345,8 +348,13 @@ class MediaSendDelegate internal constructor( hex: String, retryable: Boolean, discard: PendingMediaRecord?, + denied: Boolean = false, ) { - messageDataSource.failPending(chatId, clientMessageId) + if (denied) { + messageDataSource.denyPending(chatId, clientMessageId) + } else { + messageDataSource.failPending(chatId, clientMessageId) + } stages.update { it + (hex to Stage.Failed(retryable)) } // A terminal failure has nothing a retry could resend, so the queue entry and the file go. if (discard != null) discard(discard) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 6d906e9ed0..06223cac54 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -2,6 +2,7 @@ package com.flipcash.shared.chat.internal.delegates +import com.flipcash.services.models.SendMessageError import androidx.core.app.NotificationManagerCompat import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager @@ -371,7 +372,7 @@ class MessagingDelegate @Inject constructor( /** * Seals [payload] if the chat encrypts, sends it, and settles the pending row. A failure to * seal fails the row like a failed request, so it can be retried; so does the server refusing - * the ciphertext with `EncryptionNotAllowed`. + * the ciphertext with `EncryptionNotAllowed`. A `Denied` settles the row as denied, with no retry. */ private suspend fun send( chatId: ChatId, @@ -393,8 +394,14 @@ class MessagingDelegate @Inject constructor( metadataDataSource.updateLastMessageId(chatId, serverMessage.messageId) metadataDataSource.updateLastActivity(chatId, serverMessage.timestamp.toEpochMilliseconds()) } - .onFailure { - messageDataSource.failPending(chatId, clientMessageId) + .onFailure { cause -> + // The server won't take this from this user in this chat; a retry gets the same answer. + if (cause is SendMessageError.Denied) { + messageDataSource.denyPending(chatId, clientMessageId) + rereadAfterDenied(chatId) + } else { + messageDataSource.failPending(chatId, clientMessageId) + } } } @@ -652,6 +659,21 @@ class MessagingDelegate @Inject constructor( senderResolver.clear() } + /** + * The server refused a send without saying why. If a speaker rule changed, the stored rules are + * stale and the composer is still up; re-reading the chat lets `speakerBlock` see the new rules. + * A removal from the group is not visible here: GetChat does not return the caller's membership. + */ + internal suspend fun rereadAfterDenied(chatId: ChatId) { + val metadata = chatController.getChat(chatId).getOrElse { + trace(tag = TAG, message = "Couldn't re-read $chatId after a denied send", type = TraceType.Silent) + return + } + // A removal may already have landed from the stream; don't write the caller back in. + metadataDataSource.upsertKeepingMembership(metadata) + memberDataSource.upsert(chatId, metadata.members) + } + // endregion private companion object { diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt index 3c0a859384..4640101996 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt @@ -330,6 +330,62 @@ class MessagingSendEncryptionTest { coVerify { messages.failPending(chatId, clientMessageId) } } + @Test + fun `Denied settles the send without a retry`() = runTest { + storedChat(chat()) + answerSends() + coEvery { controller.sendMessage(chatId, any(), any()) } returns + Result.failure(SendMessageError.Denied()) + coEvery { chatController.getChat(chatId, any()) } returns Result.failure(GetChatError.Other()) + + val result = delegate.sendMessage(chatId, "hello", replyToMessageId = null) + + assertIs(result.exceptionOrNull()) + coVerify { messages.denyPending(chatId, clientMessageId) } + coVerify(exactly = 0) { messages.failPending(any(), any()) } + } + + @Test + fun `Denied re-reads the chat so its rules can hide the composer`() = runTest { + val stored = chat() + storedChat(stored) + answerSends() + coEvery { controller.sendMessage(chatId, any(), any()) } returns Result.failure(SendMessageError.Denied()) + coEvery { chatController.getChat(chatId, any()) } returns Result.success(stored) + + delegate.sendMessage(chatId, "hello", replyToMessageId = null) + + coVerify { metadata.upsertKeepingMembership(stored) } + coVerify(exactly = 0) { metadata.upsert(any()) } + coVerify { members.upsert(chatId, stored.members) } + } + + @Test + fun `a failed re-read leaves the denial in place`() = runTest { + storedChat(chat()) + answerSends() + coEvery { controller.sendMessage(chatId, any(), any()) } returns Result.failure(SendMessageError.Denied()) + coEvery { chatController.getChat(chatId, any()) } returns Result.failure(GetChatError.Other()) + + val result = delegate.sendMessage(chatId, "hello", replyToMessageId = null) + + assertIs(result.exceptionOrNull()) + coVerify { messages.denyPending(chatId, clientMessageId) } + coVerify(exactly = 0) { metadata.upsertKeepingMembership(any()) } + } + + @Test + fun `EncryptionNotAllowed does not re-read the chat`() = runTest { + storedChat(chat()) + answerSends() + coEvery { controller.sendMessage(chatId, any(), any()) } returns + Result.failure(SendMessageError.EncryptionNotAllowed()) + + delegate.sendMessage(chatId, "hello", replyToMessageId = null) + + coVerify(exactly = 0) { metadata.upsertKeepingMembership(any()) } + } + @Test fun `a retry seals the stored plaintext again`() = runTest { storedChat(chat()) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/MediaSendDelegateTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/MediaSendDelegateTest.kt index dcf4951584..6bbe047b66 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/MediaSendDelegateTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/MediaSendDelegateTest.kt @@ -10,6 +10,7 @@ import com.flipcash.app.persistence.sources.PendingMediaRecord import com.flipcash.services.controllers.ChatMessagingController import com.flipcash.services.models.BlobRejectedException import com.flipcash.services.models.ModerationResult +import com.flipcash.services.models.SendMessageError import com.flipcash.services.models.blob.EncryptedConstraints import com.flipcash.services.models.blob.MimeTypeConstraints import com.flipcash.services.models.blob.UploadPolicy @@ -219,6 +220,20 @@ class MediaSendDelegateTest { assertEquals(setOf(ids[1]), records.keys) // kept for retry } + @Test + fun `a denied post settles the row and drops the stored photo`() = runTest { + val (delegate, uploads) = delegate() + sendResult = { Result.failure(SendMessageError.Denied()) } + val ids = delegate.sendMedia(chatId, listOf(stage(uploads, "a")), "", null).getOrThrow() + runCurrent() + + coVerify { messageDataSource.denyPending(chatId, match { hex(it) == ids[0] }) } + coVerify { messaging.rereadAfterDenied(chatId) } + coVerify(exactly = 0) { messageDataSource.failPending(any(), any()) } + assertNull(records[ids[0]]) + assertEquals(MediaSendProgress.Failed(false), delegate.progressOf(ids[0])) + } + @Test fun `a failed post fails the row and keeps the stored photo`() = runTest { val (delegate, uploads) = delegate() diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt index eca8b8f4be..acc5de388d 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt @@ -9,6 +9,11 @@ enum class MessageStatus { SENDING, SENT, FAILED, + /** + * The server refused the send (`Denied`). Final: a retry gets the same answer. Stored by name, + * so no migration; a build that predates it reads the row as SENT. + */ + DENIED, } /** How an end-to-end encrypted row stands on this device. Null on a row sent in plaintext. */ diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt index 9c4d9296e0..24cc4c0578 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt @@ -1,5 +1,6 @@ package com.flipcash.app.persistence.converters +import com.flipcash.app.persistence.entities.MessageStatus import com.flipcash.services.models.VerifiableContactMethod import kotlin.test.Test import kotlin.test.assertEquals @@ -267,4 +268,10 @@ class ChatTypeConvertersTest { } // endregion + + @Test + fun `DENIED round-trips by name`() { + assertEquals("DENIED", converter.fromMessageStatus(MessageStatus.DENIED)) + assertEquals(MessageStatus.DENIED, converter.toMessageStatus("DENIED")) + } } diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt index 2bb803be88..16904b7b09 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt @@ -364,6 +364,37 @@ class ChatMessageDaoTest { assertEquals(MessageStatus.FAILED, dao.getByClientId(CHAT_HEX, CLIENT_HEX)?.status) } + @Test + fun `the sweep leaves a denied send alone`() = runTest { + dao.upsert(pending("refused")) + dao.updatePendingStatus(CHAT_HEX, CLIENT_HEX, MessageStatus.DENIED) + + dao.failInterruptedSends() + + assertEquals(MessageStatus.DENIED, dao.getByClientId(CHAT_HEX, CLIENT_HEX)?.status) + } + + /** A confirm that lands after the denial means the server took it after all. */ + @Test + fun `a late confirm settles a denied send as sent`() = runTest { + dao.upsert(pending("refused")) + dao.updatePendingStatus(CHAT_HEX, CLIENT_HEX, MessageStatus.DENIED) + + dao.confirmPendingMessage(CHAT_HEX, CLIENT_HEX, text(6, "refused")) + + assertEquals(MessageStatus.SENT, dao.getByClientId(CHAT_HEX, CLIENT_HEX)?.status) + } + + @Test + fun `a refresh drops a denied send like a failed one`() = runTest { + dao.upsert(pending("refused")) + dao.updatePendingStatus(CHAT_HEX, CLIENT_HEX, MessageStatus.DENIED) + + dao.deletePendingExcept(CHAT_HEX, CLIENT_HEX, keepMessageId = 99) + + assertNull(dao.getByClientId(CHAT_HEX, CLIENT_HEX)) + } + @Test fun `the sweep leaves a sent message alone`() = runTest { dao.upsert(text(1, "already sent")) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt index f5cd172265..57cac1de08 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -391,6 +391,15 @@ class ChatMessageDataSource @Inject constructor( ) } + /** Settles a send the server refused. Unlike [failPending], the row offers no retry. */ + suspend fun denyPending(chatId: ChatId, clientMessageId: ClientMessageId) { + db?.chatMessageDao()?.updatePendingStatus( + mapper.chatIdHex(chatId), + mapper.clientMessageIdHex(clientMessageId), + MessageStatus.DENIED, + ) + } + /** * Fails the sends a previous process left in flight. See [ChatMessageDao.failInterruptedSends]. */ diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index 8bc485cb35..e6a2bfaa87 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -102,6 +102,16 @@ class ChatMetadataDataSource @Inject constructor( db?.chatMetadataDao()?.upsert(mapper.toEntity(metadata)) } + /** + * Writes a fresh server read of a chat the caller may have left. GetChat doesn't return the + * caller's membership, so the stored value stands instead of [upsert]'s `true`. + */ + suspend fun upsertKeepingMembership(metadata: ChatMetadata) { + val dao = db?.chatMetadataDao() ?: return + val isMember = dao.getById(mapper.chatIdHex(metadata.chatId))?.isMember ?: false + dao.upsert(mapper.toEntity(metadata, isMember = isMember)) + } + suspend fun upsert(metadatas: List) { db?.chatMetadataDao()?.upsert(metadatas.map { mapper.toEntity(it) }) } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index 1053b1d317..a671ba8df9 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -206,6 +206,7 @@ class ChatEntityMapper @Inject constructor() { MessageStatus.SENDING -> DeliveryStatus.SENDING MessageStatus.SENT -> DeliveryStatus.SENT MessageStatus.FAILED -> DeliveryStatus.FAILED + MessageStatus.DENIED -> DeliveryStatus.DENIED }, pendingClientIdHex = entity.pendingClientIdHex, encryption = entity.toEncryption(), diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index 504bfe72c2..c49a4d999d 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -1,5 +1,7 @@ package com.flipcash.app.persistence.sources.mapper.chat +import com.flipcash.services.models.chat.DeliveryStatus +import com.flipcash.app.persistence.entities.MessageStatus import com.flipcash.app.persistence.entities.ChatMemberEntity import com.flipcash.app.persistence.entities.ChatMemberWithProfile import com.flipcash.app.persistence.entities.ChatMetadataEntity @@ -130,6 +132,22 @@ class ChatEntityMapperTest { assertEquals(false, entity(MessageContent.Text("still here")).isDeleted) } + @Test + fun `a denied row maps to a denied delivery`() { + val entity = mapper.toEntity( + CHAT_HEX, + ChatMessage( + messageId = 1, + senderId = listOf(0xAB.toByte()), + content = listOf(MessageContent.Text("refused")), + timestamp = Instant.fromEpochSeconds(1_000), + unreadSeq = 1, + ), + ).copy(status = MessageStatus.DENIED) + + assertEquals(DeliveryStatus.DENIED, mapper.toMessage(entity).deliveryStatus) + } + /** * `MessageContent.Encrypted` is a decode result kept verbatim (not decrypted) so it can be * persisted and later re-encoded faithfully -- see that type's doc. A byte-for-byte round diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index f9687dbc31..701372122e 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -543,7 +543,8 @@ sealed class SendMessageError( override val message: String? = null, override val cause: Throwable? = null ): CodeServerError(message, cause) { - class Denied : SendMessageError("Denied") + // The server refused this user in this chat. The row settles as DENIED; nothing to report. + class Denied : SendMessageError("Denied"), UnreportedError // Sender sent EncryptedContent to a chat that doesn't allow it (non-DM). E2eePolicy only // encrypts in a DM, so this means the chat's type changed under us; the send fails and can // be retried. @@ -559,7 +560,8 @@ sealed class AdvancePointerError( override val message: String? = null, override val cause: Throwable? = null ): CodeServerError(message, cause) { - class Denied : AdvancePointerError("Denied") + // A read pointer the server won't take is dropped; callers ignore the result. + class Denied : AdvancePointerError("Denied"), UnreportedError class MessageNotFound : AdvancePointerError("Message not found") class Unrecognized : AdvancePointerError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : AdvancePointerError(message = cause?.message, cause = cause), NotifiableError @@ -569,7 +571,8 @@ sealed class NotifyIsTypingError( override val message: String? = null, override val cause: Throwable? = null ): CodeServerError(message, cause) { - class Denied : NotifyIsTypingError("Denied") + // A typing notice the server won't take is dropped; callers ignore the result. + class Denied : NotifyIsTypingError("Denied"), UnreportedError class Unrecognized : NotifyIsTypingError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : NotifyIsTypingError(message = cause?.message, cause = cause), NotifiableError } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/DeliveryStatus.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/DeliveryStatus.kt index 809be07ac9..f57e20b070 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/DeliveryStatus.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/DeliveryStatus.kt @@ -1,3 +1,3 @@ package com.flipcash.services.models.chat -enum class DeliveryStatus { SENDING, SENT, FAILED } +enum class DeliveryStatus { SENDING, SENT, FAILED, DENIED } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt index 332734b316..d829d8992b 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/ErrorsTest.kt @@ -153,6 +153,13 @@ class ErrorsTest { assertIs(GetMessagesError.NotFound()) } + @Test + fun `Denied chat errors are not reported`() { + assertIs(SendMessageError.Denied()) + assertIs(NotifyIsTypingError.Denied()) + assertIs(AdvancePointerError.Denied()) + } + // -- SendMessageError -- @Test