diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt index a06b89f2e13..63444442408 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt @@ -1195,9 +1195,10 @@ internal class DomainMapping( originalText = originalText, textHarms = textHarms.orEmpty(), imageHarms = imageHarms.orEmpty(), - blocklistMatched = blocklistMatched, + blocklistMatched = blocklistMatched ?: blocklistsMatched?.firstOrNull(), semanticFilterMatched = semanticFilterMatched, platformCircumvented = platformCircumvented ?: false, + blocklistsMatched = blocklistsMatched.orEmpty(), ) /** diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/ModerationEntity.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/ModerationEntity.kt index a96de611774..b83c6ff5636 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/ModerationEntity.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/ModerationEntity.kt @@ -30,4 +30,5 @@ internal data class ModerationEntity( val blocklistMatched: String?, val semanticFilterMatched: String?, val platformCircumvented: Boolean, + val blocklistsMatched: List = emptyList(), ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/ModerationMapper.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/ModerationMapper.kt index 7f95391949b..a73deb3cb47 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/ModerationMapper.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/ModerationMapper.kt @@ -22,6 +22,7 @@ import io.getstream.chat.android.models.ModerationAction /** * Maps the domain model [Moderation] to the database model [ModerationEntity]. */ +@Suppress("DEPRECATION") internal fun Moderation.toEntity() = ModerationEntity( action = action.value, originalText = originalText, @@ -30,6 +31,7 @@ internal fun Moderation.toEntity() = ModerationEntity( blocklistMatched = blocklistMatched, semanticFilterMatched = semanticFilterMatched, platformCircumvented = platformCircumvented, + blocklistsMatched = blocklistsMatched, ) /** @@ -43,4 +45,5 @@ internal fun ModerationEntity.toDomain() = Moderation( blocklistMatched = blocklistMatched, semanticFilterMatched = semanticFilterMatched, platformCircumvented = platformCircumvented, + blocklistsMatched = blocklistsMatched, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/ModerationAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/ModerationAdapter.kt index da3186b3938..2001bb6da55 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/ModerationAdapter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/ModerationAdapter.kt @@ -32,6 +32,7 @@ internal class ModerationAdapter : JsonAdapter() { var textHarms: List? = null var imageHarms: List? = null var blocklistMatched: String? = null + var blocklistsMatched: List? = null var semanticFilterMatched: String? = null var platformCircumvented: Boolean? = null @@ -42,6 +43,7 @@ internal class ModerationAdapter : JsonAdapter() { "text_harms" -> textHarms = JsonParsingUtils.parseStringList(reader) "image_harms" -> imageHarms = JsonParsingUtils.parseStringList(reader) "blocklist_matched" -> blocklistMatched = JsonParsingUtils.readNullableString(reader) + "blocklists_matched" -> blocklistsMatched = JsonParsingUtils.parseStringList(reader) "semantic_filter_matched" -> semanticFilterMatched = JsonParsingUtils.readNullableString(reader) "platform_circumvented" -> platformCircumvented = JsonParsingUtils.readNullableBoolean(reader) else -> reader.skipValue() @@ -57,9 +59,10 @@ internal class ModerationAdapter : JsonAdapter() { originalText = originalText, textHarms = textHarms.orEmpty(), imageHarms = imageHarms.orEmpty(), - blocklistMatched = blocklistMatched, + blocklistMatched = blocklistMatched ?: blocklistsMatched?.firstOrNull(), semanticFilterMatched = semanticFilterMatched, platformCircumvented = platformCircumvented ?: false, + blocklistsMatched = blocklistsMatched.orEmpty(), ) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt index 5d550feed93..6cd0618f779 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt @@ -925,6 +925,7 @@ internal object Mother { blocklistMatched: String = randomString(), semanticFilterMatched: String = randomString(), platformCircumvented: Boolean = randomBoolean(), + blocklistsMatched: List = listOf(randomString()), ): ModerationV2Response = ModerationV2Response( action = action, originalText = originalText, @@ -933,6 +934,7 @@ internal object Mother { blocklistMatched = blocklistMatched, semanticFilterMatched = semanticFilterMatched, platformCircumvented = platformCircumvented, + blocklistsMatched = blocklistsMatched, ) fun randomQueryUsersRequest( diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt index 8a2ddb624cd..a4309bd4d89 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt @@ -1648,10 +1648,25 @@ internal class DomainMappingTest { blocklistMatched = moderationResponse.blocklistMatched, semanticFilterMatched = moderationResponse.semanticFilterMatched, platformCircumvented = moderationResponse.platformCircumvented ?: false, + blocklistsMatched = moderationResponse.blocklistsMatched.orEmpty(), ) assertEquals(expected, moderation) } + @Test + fun `ModerationV2Response without blocklist_matched falls back to the first blocklists_matched entry`() { + val blocklists = listOf(randomString(), randomString()) + val moderationResponse = randomModerationV2Response().copy( + blocklistMatched = null, + blocklistsMatched = blocklists, + ) + val sut = Fixture().get() + val moderation = with(sut) { moderationResponse.toDomain() } + @Suppress("DEPRECATION") + assertEquals(blocklists.first(), moderation.blocklistMatched) + assertEquals(blocklists, moderation.blocklistsMatched) + } + @Test fun `SearchWarning is correctly mapped to the domain model`() { val response = randomSearchWarningResponse() diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/ModerationMapperTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/ModerationMapperTest.kt new file mode 100644 index 00000000000..d1c73fc5149 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/ModerationMapperTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.internal.offline.repository.domain.message.internal + +import io.getstream.chat.android.client.internal.offline.repository.database.converter.internal.ModerationConverter +import io.getstream.chat.android.randomModeration +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +internal class ModerationMapperTest { + + private val converter = ModerationConverter() + + @Test + fun `Moderation survives a round trip through the database`() { + val moderation = randomModeration(blocklistsMatched = listOf("custom_blocklist", "other_blocklist")) + + val stored = converter.moderationToString(moderation.toEntity()) + val result = converter.stringToModeration(stored)?.toDomain() + + assertEquals(moderation, result) + } + + @Test + fun `Moderation stored without blocklistsMatched reads back as an empty list`() { + val stored = """{"action":"bounce","originalText":"Some text","textHarms":[],"imageHarms":[],""" + + """"blocklistMatched":"custom_blocklist","platformCircumvented":false}""" + + val result = converter.stringToModeration(stored) + + assertEquals(emptyList(), result?.blocklistsMatched) + assertEquals("custom_blocklist", result?.blocklistMatched) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/ModerationParsingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/ModerationParsingTest.kt index cbefb7e3231..98c59b03ad5 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/ModerationParsingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/ModerationParsingTest.kt @@ -57,6 +57,13 @@ internal class ModerationParsingTest { assertEquals(ModerationTestData.expectedOptionalFieldsMissing, domain) } + @Test + fun `Generated path - falls back to the first blocklists_matched entry`() { + val dto = parser.fromJson(ModerationTestData.jsonOnlyBlocklistsMatched, ModerationV2Response::class.java) + val domain = with(domainMapping) { dto.toDomain() } + assertEquals(ModerationTestData.expectedOnlyBlocklistsMatched, domain) + } + // endregion // region Direct path (JSON → Moderation via ModerationAdapter) @@ -73,6 +80,12 @@ internal class ModerationParsingTest { assertEquals(ModerationTestData.expectedOptionalFieldsMissing, domain) } + @Test + fun `Direct path - falls back to the first blocklists_matched entry`() { + val domain = adapter.fromJson(ModerationTestData.jsonOnlyBlocklistsMatched) + assertEquals(ModerationTestData.expectedOnlyBlocklistsMatched, domain) + } + // endregion // region Explicit null values diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ModerationTestData.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ModerationTestData.kt index 7fcae5b8c6e..2d292e04a2f 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ModerationTestData.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ModerationTestData.kt @@ -24,7 +24,7 @@ internal object ModerationTestData { @Language("JSON") val jsonAllFields = - """{"action":"bounce","original_text":"This is offensive","text_harms":["profanity","harassment"],"image_harms":["nudity"],"blocklist_matched":"custom_blocklist","semantic_filter_matched":"hate_speech","platform_circumvented":true}""" + """{"action":"bounce","original_text":"This is offensive","text_harms":["profanity","harassment"],"image_harms":["nudity"],"blocklist_matched":"custom_blocklist","blocklists_matched":["custom_blocklist","other_blocklist"],"semantic_filter_matched":"hate_speech","platform_circumvented":true}""" @Language("JSON") val jsonOptionalFieldsMissing = @@ -46,6 +46,22 @@ internal object ModerationTestData { blocklistMatched = "custom_blocklist", semanticFilterMatched = "hate_speech", platformCircumvented = true, + blocklistsMatched = listOf("custom_blocklist", "other_blocklist"), + ) + + @Language("JSON") + val jsonOnlyBlocklistsMatched = + """{"action":"bounce","original_text":"Some text","blocklists_matched":["custom_blocklist","other_blocklist"]}""" + + val expectedOnlyBlocklistsMatched = Moderation( + action = ModerationAction.bounce, + originalText = "Some text", + textHarms = emptyList(), + imageHarms = emptyList(), + blocklistMatched = "custom_blocklist", + semanticFilterMatched = null, + platformCircumvented = false, + blocklistsMatched = listOf("custom_blocklist", "other_blocklist"), ) val expectedOptionalFieldsMissing = Moderation( @@ -60,7 +76,7 @@ internal object ModerationTestData { @Language("JSON") val jsonWithExplicitNulls = - """{"action":"flag","original_text":"Some text","text_harms":null,"image_harms":null,"blocklist_matched":null,"semantic_filter_matched":null,"platform_circumvented":null}""" + """{"action":"flag","original_text":"Some text","text_harms":null,"image_harms":null,"blocklist_matched":null,"blocklists_matched":null,"semantic_filter_matched":null,"platform_circumvented":null}""" val expectedWithExplicitNulls = Moderation( action = ModerationAction.flag, diff --git a/stream-chat-android-core/api/stream-chat-android-core.api b/stream-chat-android-core/api/stream-chat-android-core.api index 8c89cbd16d9..ea83892e4be 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -1708,7 +1708,8 @@ public final class io/getstream/chat/android/models/MessagesState$Result : io/ge } public final class io/getstream/chat/android/models/Moderation { - public fun (Lio/getstream/chat/android/models/ModerationAction;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Z)V + public fun (Lio/getstream/chat/android/models/ModerationAction;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;ZLjava/util/List;)V + public synthetic fun (Lio/getstream/chat/android/models/ModerationAction;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;ZLjava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/models/ModerationAction; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/util/List; @@ -1716,11 +1717,13 @@ public final class io/getstream/chat/android/models/Moderation { public final fun component5 ()Ljava/lang/String; public final fun component6 ()Ljava/lang/String; public final fun component7 ()Z - public final fun copy (Lio/getstream/chat/android/models/ModerationAction;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Z)Lio/getstream/chat/android/models/Moderation; - public static synthetic fun copy$default (Lio/getstream/chat/android/models/Moderation;Lio/getstream/chat/android/models/ModerationAction;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)Lio/getstream/chat/android/models/Moderation; + public final fun component8 ()Ljava/util/List; + public final fun copy (Lio/getstream/chat/android/models/ModerationAction;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;ZLjava/util/List;)Lio/getstream/chat/android/models/Moderation; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/Moderation;Lio/getstream/chat/android/models/ModerationAction;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;ZLjava/util/List;ILjava/lang/Object;)Lio/getstream/chat/android/models/Moderation; public fun equals (Ljava/lang/Object;)Z public final fun getAction ()Lio/getstream/chat/android/models/ModerationAction; public final fun getBlocklistMatched ()Ljava/lang/String; + public final fun getBlocklistsMatched ()Ljava/util/List; public final fun getImageHarms ()Ljava/util/List; public final fun getOriginalText ()Ljava/lang/String; public final fun getPlatformCircumvented ()Z diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Moderation.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Moderation.kt index 77a5440d705..5512ca44240 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Moderation.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Moderation.kt @@ -23,18 +23,21 @@ package io.getstream.chat.android.models * @property originalText The original text of the message. * @property textHarms The list of harmful text detected in the message. * @property imageHarms The list of harmful images detected in the message. - * @property blocklistMatched The blocklist matched by the message. + * @property blocklistMatched The first blocklist matched by the message. * @property semanticFilterMatched The semantic filter matched by the message. * @property platformCircumvented true/false if the message triggered the platform circumvention model. + * @property blocklistsMatched The blocklists matched by the message. */ public data class Moderation( val action: ModerationAction, val originalText: String, val textHarms: List, val imageHarms: List, + @Deprecated("Use blocklistsMatched instead.") val blocklistMatched: String?, val semanticFilterMatched: String?, val platformCircumvented: Boolean, + val blocklistsMatched: List = emptyList(), ) /** diff --git a/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt b/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt index 5f93e90f24b..bbd3e34240c 100644 --- a/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt +++ b/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt @@ -1201,6 +1201,7 @@ public fun randomModeration( blocklistMatched: String = randomString(), semanticFilterMatched: String = randomString(), platformCircumvented: Boolean = randomBoolean(), + blocklistsMatched: List = listOf(randomString()), ): Moderation = Moderation( action = action, originalText = originalText, @@ -1209,6 +1210,7 @@ public fun randomModeration( blocklistMatched = blocklistMatched, semanticFilterMatched = semanticFilterMatched, platformCircumvented = platformCircumvented, + blocklistsMatched = blocklistsMatched, ) public fun randomMessageReminder(