Skip to content

Commit 396c9e9

Browse files
VelikovPetarclaudecursoragent
authored
Fix local only message deletion (#6157)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 227ec07 commit 396c9e9

6 files changed

Lines changed: 445 additions & 58 deletions

File tree

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
package io.getstream.chat.android.client.utils.message
2121

22+
import io.getstream.chat.android.client.errors.cause.MessageModerationDeletedException
2223
import io.getstream.chat.android.client.extensions.getCreatedAtOrNull
2324
import io.getstream.chat.android.core.internal.InternalStreamChatApi
2425
import io.getstream.chat.android.core.utils.date.after
@@ -30,6 +31,8 @@ import io.getstream.chat.android.models.MessageType
3031
import io.getstream.chat.android.models.ModerationAction
3132
import io.getstream.chat.android.models.SyncStatus
3233
import io.getstream.chat.android.models.User
34+
import io.getstream.result.Error
35+
import io.getstream.result.Result
3336
import java.util.UUID
3437

3538
private const val ITEM_COUNT_OF_TWO: Int = 2
@@ -205,6 +208,41 @@ public fun Message.isModerationFlag(): Boolean =
205208
public fun Message.isModerationError(currentUserId: String?): Boolean = isMine(currentUserId) &&
206209
(isError() && isModerationBounce())
207210

211+
/**
212+
* Checks whether we should attempt to delete the message remotely.
213+
*
214+
* @param currentUserId The ID of the currently logged in user.
215+
* @return [Result.Success] if remote delete should be attempted, [Result.Failure] if the message should be deleted only
216+
* locally.
217+
*/
218+
@Suppress("ComplexCondition")
219+
@InternalStreamChatApi
220+
public fun Message.shouldDeleteRemote(currentUserId: String?): Result<Unit> {
221+
// 1. Moderation action = 'bounce' - not persisted on server, delete only locally
222+
// Note: handled separately from pt. 2/3 for backwards-compatibility
223+
if (isModerationError(currentUserId)) {
224+
val error = Error.ThrowableError(
225+
message = "Message with failed moderation has been deleted locally: $id",
226+
cause = MessageModerationDeletedException(
227+
"Message with failed moderation has been deleted locally: $id",
228+
),
229+
)
230+
return Result.Failure(error)
231+
}
232+
// 2. type = 'error'/'ephemeral' - not persisted on server, delete only locally
233+
// 3. syncStatus = 'IN_PROGRESS'/`SYNC_NEEDED`/`FAILED_PERMANENTLY` - not persisted on server, delete only locally
234+
if (isError() || isEphemeral() ||
235+
syncStatus == SyncStatus.IN_PROGRESS ||
236+
syncStatus == SyncStatus.SYNC_NEEDED ||
237+
syncStatus == SyncStatus.FAILED_PERMANENTLY
238+
) {
239+
val error = Error.GenericError("Message is local-only, don't call DeleteMessage API")
240+
return Result.Failure(error)
241+
}
242+
// 4. Any other case, attempt to delete the message remotely
243+
return Result.Success(Unit)
244+
}
245+
208246
/**
209247
* Ensures the message has an id.
210248
* If the message doesn't have an id, a unique message id is generated.

stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/message/MessageUtilsTest.kt

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,13 @@ import io.getstream.chat.android.randomModeration
3131
import io.getstream.chat.android.randomPoll
3232
import io.getstream.chat.android.randomString
3333
import io.getstream.chat.android.randomUser
34+
import io.getstream.result.Result
3435
import kotlinx.coroutines.ExperimentalCoroutinesApi
3536
import kotlinx.coroutines.test.advanceTimeBy
3637
import kotlinx.coroutines.test.currentTime
3738
import kotlinx.coroutines.test.runTest
3839
import org.amshove.kluent.shouldBeEqualTo
40+
import org.junit.jupiter.api.Assertions.assertTrue
3941
import org.junit.jupiter.api.Test
4042
import java.util.Date
4143

@@ -583,4 +585,86 @@ internal class MessageUtilsTest {
583585
val result = draftMessage.ensureId(user)
584586
result.id.startsWith(userId) shouldBeEqualTo true
585587
}
588+
589+
@Test
590+
fun `shouldDeleteRemote should return Failure for moderation bounce message`() {
591+
val currentUserId = randomString()
592+
val message = randomMessage(
593+
user = randomUser(id = currentUserId),
594+
type = MessageType.ERROR,
595+
moderation = randomModeration(action = ModerationAction.bounce),
596+
)
597+
val result = message.shouldDeleteRemote(currentUserId)
598+
assertTrue(result is Result.Failure)
599+
}
600+
601+
@Test
602+
fun `shouldDeleteRemote should return Failure for error message type`() {
603+
val message = randomMessage(
604+
type = MessageType.ERROR,
605+
syncStatus = SyncStatus.COMPLETED,
606+
)
607+
val result = message.shouldDeleteRemote(randomString())
608+
assertTrue(result is Result.Failure)
609+
}
610+
611+
@Test
612+
fun `shouldDeleteRemote should return Failure for ephemeral message type`() {
613+
val message = randomMessage(
614+
type = MessageType.EPHEMERAL,
615+
syncStatus = SyncStatus.COMPLETED,
616+
)
617+
val result = message.shouldDeleteRemote(randomString())
618+
assertTrue(result is Result.Failure)
619+
}
620+
621+
@Test
622+
fun `shouldDeleteRemote should return Failure for IN_PROGRESS sync status`() {
623+
val message = randomMessage(
624+
type = MessageType.REGULAR,
625+
syncStatus = SyncStatus.IN_PROGRESS,
626+
)
627+
val result = message.shouldDeleteRemote(randomString())
628+
assertTrue(result is Result.Failure)
629+
}
630+
631+
@Test
632+
fun `shouldDeleteRemote should return Failure for SYNC_NEEDED sync status`() {
633+
val message = randomMessage(
634+
type = MessageType.REGULAR,
635+
syncStatus = SyncStatus.SYNC_NEEDED,
636+
)
637+
val result = message.shouldDeleteRemote(randomString())
638+
assertTrue(result is Result.Failure)
639+
}
640+
641+
@Test
642+
fun `shouldDeleteRemote should return Failure for FAILED_PERMANENTLY sync status`() {
643+
val message = randomMessage(
644+
type = MessageType.REGULAR,
645+
syncStatus = SyncStatus.FAILED_PERMANENTLY,
646+
)
647+
val result = message.shouldDeleteRemote(randomString())
648+
assertTrue(result is Result.Failure)
649+
}
650+
651+
@Test
652+
fun `shouldDeleteRemote should return Success for COMPLETED regular message`() {
653+
val message = randomMessage(
654+
type = MessageType.REGULAR,
655+
syncStatus = SyncStatus.COMPLETED,
656+
)
657+
val result = message.shouldDeleteRemote(randomString())
658+
assertTrue(result is Result.Success)
659+
}
660+
661+
@Test
662+
fun `shouldDeleteRemote should return Success for AWAITING_ATTACHMENTS regular message`() {
663+
val message = randomMessage(
664+
type = MessageType.REGULAR,
665+
syncStatus = SyncStatus.AWAITING_ATTACHMENTS,
666+
)
667+
val result = message.shouldDeleteRemote(randomString())
668+
assertTrue(result is Result.Success)
669+
}
586670
}

stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/plugin/listener/internal/DeleteMessageListenerDatabase.kt

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,14 @@
1616

1717
package io.getstream.chat.android.offline.plugin.listener.internal
1818

19-
import io.getstream.chat.android.client.errors.cause.MessageModerationDeletedException
2019
import io.getstream.chat.android.client.extensions.internal.users
2120
import io.getstream.chat.android.client.persistance.repository.MessageRepository
2221
import io.getstream.chat.android.client.persistance.repository.UserRepository
2322
import io.getstream.chat.android.client.plugin.listeners.DeleteMessageListener
2423
import io.getstream.chat.android.client.setup.state.ClientState
25-
import io.getstream.chat.android.client.utils.message.isModerationError
24+
import io.getstream.chat.android.client.utils.message.shouldDeleteRemote
2625
import io.getstream.chat.android.models.Message
2726
import io.getstream.chat.android.models.SyncStatus
28-
import io.getstream.result.Error
2927
import io.getstream.result.Result
3028
import java.util.Date
3129

@@ -45,24 +43,19 @@ internal class DeleteMessageListenerDatabase(
4543
* @param messageId The message id to be deleted.
4644
*/
4745
override suspend fun onMessageDeletePrecondition(messageId: String): Result<Unit> {
48-
return messageRepository.selectMessage(messageId)?.let { message ->
49-
val currentUserId = clientState.user.value?.id
50-
val isModerationFailed = message.isModerationError(currentUserId)
51-
52-
if (isModerationFailed) {
53-
messageRepository.deleteChannelMessage(message)
54-
Result.Failure(
55-
Error.ThrowableError(
56-
message = "Message with failed moderation has been deleted locally: $messageId",
57-
cause = MessageModerationDeletedException(
58-
"Message with failed moderation has been deleted locally: $messageId",
59-
),
60-
),
61-
)
62-
} else {
63-
Result.Success(Unit)
64-
}
65-
} ?: Result.Success(Unit)
46+
val localMessage = messageRepository.selectMessage(messageId)
47+
val currentUserId = clientState.user.value?.id
48+
// We don't have the message locally, we must attempt to delete the message remotely
49+
if (localMessage == null) {
50+
return Result.Success(Unit)
51+
}
52+
// Check if the message is local-only (if attempting remote delete should be skipped
53+
val shouldDeleteRemote = localMessage.shouldDeleteRemote(currentUserId)
54+
if (shouldDeleteRemote is Result.Failure) {
55+
// Delete the message ONLY locally
56+
messageRepository.deleteChannelMessage(localMessage)
57+
}
58+
return shouldDeleteRemote
6659
}
6760

6861
/**

stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/plugin/listener/internal/DeleteMessageListenerDatabaseTest.kt

Lines changed: 146 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,19 @@ package io.getstream.chat.android.offline.plugin.listener.internal
1919
import io.getstream.chat.android.client.persistance.repository.MessageRepository
2020
import io.getstream.chat.android.client.persistance.repository.UserRepository
2121
import io.getstream.chat.android.client.setup.state.ClientState
22+
import io.getstream.chat.android.models.MessageType
23+
import io.getstream.chat.android.models.ModerationAction
2224
import io.getstream.chat.android.models.SyncStatus
2325
import io.getstream.chat.android.randomCID
2426
import io.getstream.chat.android.randomMessage
27+
import io.getstream.chat.android.randomModeration
28+
import io.getstream.chat.android.randomUser
2529
import io.getstream.result.Error
2630
import io.getstream.result.Result
31+
import kotlinx.coroutines.flow.MutableStateFlow
2732
import kotlinx.coroutines.test.runTest
33+
import org.junit.jupiter.api.Assertions.assertTrue
34+
import org.junit.jupiter.api.BeforeEach
2835
import org.junit.jupiter.api.Test
2936
import org.mockito.kotlin.any
3037
import org.mockito.kotlin.argThat
@@ -37,11 +44,16 @@ internal class DeleteMessageListenerDatabaseTest {
3744

3845
private val clientState: ClientState = mock()
3946

40-
private val messageRepository: MessageRepository = mock()
47+
private lateinit var messageRepository: MessageRepository
4148
private val userRepository: UserRepository = mock()
4249

43-
private val deleteMessageListenerState: DeleteMessageListenerDatabase =
44-
DeleteMessageListenerDatabase(clientState, messageRepository, userRepository)
50+
private lateinit var deleteMessageListenerState: DeleteMessageListenerDatabase
51+
52+
@BeforeEach
53+
fun setUp() {
54+
messageRepository = mock()
55+
deleteMessageListenerState = DeleteMessageListenerDatabase(clientState, messageRepository, userRepository)
56+
}
4557

4658
@Test
4759
fun `when internet is available, the message should be updated as in progress before the request`() = runTest {
@@ -122,4 +134,135 @@ internal class DeleteMessageListenerDatabaseTest {
122134
},
123135
)
124136
}
137+
138+
@Test
139+
fun `onMessageDeletePrecondition when message not found locally should return Success`() = runTest {
140+
val currentUser = randomUser()
141+
142+
whenever(clientState.user) doReturn MutableStateFlow(currentUser)
143+
whenever(messageRepository.selectMessage(any())) doReturn null
144+
145+
val result = deleteMessageListenerState.onMessageDeletePrecondition("unknown-message-id")
146+
147+
assertTrue(result is Result.Success)
148+
}
149+
150+
@Test
151+
fun `onMessageDeletePrecondition when message has moderation bounce should return Failure and delete from repo`() =
152+
runTest {
153+
val currentUser = randomUser()
154+
val testMessage = randomMessage(
155+
id = "msg-1",
156+
cid = randomCID(),
157+
user = currentUser,
158+
type = MessageType.ERROR,
159+
moderation = randomModeration(action = ModerationAction.bounce),
160+
)
161+
162+
whenever(clientState.user) doReturn MutableStateFlow(currentUser)
163+
whenever(messageRepository.selectMessage(any())) doReturn testMessage
164+
165+
val result = deleteMessageListenerState.onMessageDeletePrecondition(testMessage.id)
166+
167+
assertTrue(result is Result.Failure)
168+
verify(messageRepository).deleteChannelMessage(argThat { id == testMessage.id })
169+
}
170+
171+
@Test
172+
fun `onMessageDeletePrecondition when message is error type should return Failure and delete from repo`() =
173+
runTest {
174+
val currentUser = randomUser()
175+
val testMessage = randomMessage(
176+
id = "msg-1",
177+
cid = randomCID(),
178+
type = MessageType.ERROR,
179+
syncStatus = SyncStatus.COMPLETED,
180+
)
181+
182+
whenever(clientState.user) doReturn MutableStateFlow(currentUser)
183+
whenever(messageRepository.selectMessage(any())) doReturn testMessage
184+
185+
val result = deleteMessageListenerState.onMessageDeletePrecondition(testMessage.id)
186+
187+
assertTrue(result is Result.Failure)
188+
verify(messageRepository).deleteChannelMessage(argThat { id == testMessage.id })
189+
}
190+
191+
@Test
192+
fun `onMessageDeletePrecondition when message is ephemeral type should return Failure and delete from repo`() =
193+
runTest {
194+
val currentUser = randomUser()
195+
val testMessage = randomMessage(
196+
id = "msg-1",
197+
cid = randomCID(),
198+
type = MessageType.EPHEMERAL,
199+
syncStatus = SyncStatus.COMPLETED,
200+
)
201+
202+
whenever(clientState.user) doReturn MutableStateFlow(currentUser)
203+
whenever(messageRepository.selectMessage(any())) doReturn testMessage
204+
205+
val result = deleteMessageListenerState.onMessageDeletePrecondition(testMessage.id)
206+
207+
assertTrue(result is Result.Failure)
208+
verify(messageRepository).deleteChannelMessage(argThat { id == testMessage.id })
209+
}
210+
211+
@Test
212+
fun `onMessageDeletePrecondition when message has SYNC_NEEDED should return Failure and delete from repo`() =
213+
runTest {
214+
val currentUser = randomUser()
215+
val testMessage = randomMessage(
216+
id = "msg-1",
217+
cid = randomCID(),
218+
type = MessageType.REGULAR,
219+
syncStatus = SyncStatus.SYNC_NEEDED,
220+
)
221+
222+
whenever(clientState.user) doReturn MutableStateFlow(currentUser)
223+
whenever(messageRepository.selectMessage(any())) doReturn testMessage
224+
225+
val result = deleteMessageListenerState.onMessageDeletePrecondition(testMessage.id)
226+
227+
assertTrue(result is Result.Failure)
228+
verify(messageRepository).deleteChannelMessage(argThat { id == testMessage.id })
229+
}
230+
231+
@Test
232+
fun `onMessageDeletePrecondition when message has FAILED_PERMANENTLY should return Failure and delete from repo`() =
233+
runTest {
234+
val currentUser = randomUser()
235+
val testMessage = randomMessage(
236+
id = "msg-1",
237+
cid = randomCID(),
238+
type = MessageType.REGULAR,
239+
syncStatus = SyncStatus.FAILED_PERMANENTLY,
240+
)
241+
242+
whenever(clientState.user) doReturn MutableStateFlow(currentUser)
243+
whenever(messageRepository.selectMessage(any())) doReturn testMessage
244+
245+
val result = deleteMessageListenerState.onMessageDeletePrecondition(testMessage.id)
246+
247+
assertTrue(result is Result.Failure)
248+
verify(messageRepository).deleteChannelMessage(argThat { id == testMessage.id })
249+
}
250+
251+
@Test
252+
fun `onMessageDeletePrecondition when message is COMPLETED regular should return Success`() = runTest {
253+
val currentUser = randomUser()
254+
val testMessage = randomMessage(
255+
id = "msg-1",
256+
cid = randomCID(),
257+
type = MessageType.REGULAR,
258+
syncStatus = SyncStatus.COMPLETED,
259+
)
260+
261+
whenever(clientState.user) doReturn MutableStateFlow(currentUser)
262+
whenever(messageRepository.selectMessage(any())) doReturn testMessage
263+
264+
val result = deleteMessageListenerState.onMessageDeletePrecondition(testMessage.id)
265+
266+
assertTrue(result is Result.Success)
267+
}
125268
}

0 commit comments

Comments
 (0)