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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,7 @@
<string name="label_chatReceipt_delivered">Delivered</string>
<string name="label_chatReceipt_read">Read</string>
<string name="label_chatReceipt_notSent">Not Delivered. Tap to retry</string>
<string name="label_chatReceipt_denied">Not Delivered. You can\'t post in this chat</string>
<string name="label_chatReceipt_yesterday">Yesterday</string>
<string name="title_messageNotSent">Message Not Sent</string>
<string name="description_messageNotSent">Your message could not be delivered. Would you like to try again?</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,15 +151,18 @@ 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<Throwable?>(null) }
val unavailable = loadError?.let(::isPhotoUnavailable) == true
val blurhashOnly = info?.blurhashOnly == true

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}
}
}

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SendMessageError.Denied>(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<ChatMetadata>()) }
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<SendMessageError.Denied>(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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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].
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatMetadata>) {
db?.chatMetadataDao()?.upsert(metadatas.map { mapper.toEntity(it) })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading
Loading