Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ data class CardItem(
val descriptionText: String,
val placeholder: Placeholder,
val primaryAction: Action,
val matchingRules: List<Int>,
val exclusionRules: List<Int>,
)

enum class CardItemType(val jsonValue: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.duckduckgo.remote.messaging.impl

import com.duckduckgo.remote.messaging.api.AttributeMatcherPlugin
import com.duckduckgo.remote.messaging.api.Content
import com.duckduckgo.remote.messaging.api.MatchingAttribute
import com.duckduckgo.remote.messaging.api.RemoteMessage
import com.duckduckgo.remote.messaging.api.RemoteMessagingRepository
Expand All @@ -40,17 +41,63 @@ class RemoteMessagingConfigMatcher(
val dismissedMessages = remoteMessagingRepository.dismissedMessages()

remoteConfig.messages.filter { !dismissedMessages.contains(it.id) }.forEach { message ->
val matchingRules = if (message.matchingRules.isEmpty() && message.exclusionRules.isEmpty()) return message else message.matchingRules
val matchingRules = if (message.matchingRules.isEmpty() && message.exclusionRules.isEmpty()) {
val processed = filterCardsListMessage(message, rules)
if (processed != null) return processed
return@forEach
} else {
message.matchingRules
}

val matchingResult = matchingRules.evaluateMatchingRules(message.id, rules)
val excludeResult = message.exclusionRules.evaluateExclusionRules(message.id, rules)

if (matchingResult == EvaluationResult.Match && excludeResult == EvaluationResult.Fail) return message
if (matchingResult == EvaluationResult.Match && excludeResult == EvaluationResult.Fail) {
val processed = filterCardsListMessage(message, rules)
if (processed != null) return processed
return@forEach
}
}

return null
}

private suspend fun filterCardsListMessage(
message: RemoteMessage,
rules: List<Rule>,
): RemoteMessage? {
val cardsList = message.content as? Content.CardsList ?: return message

val filteredItems = cardsList.listItems.filter { cardItem ->
if (cardItem.matchingRules.isEmpty() && cardItem.exclusionRules.isEmpty()) {
true
} else {
// Evaluate CardItem rules following the same logic as for remote message
val itemMatching = if (cardItem.matchingRules.isEmpty()) {
EvaluationResult.Match
} else {
cardItem.matchingRules.evaluateMatchingRules(message.id, rules)
}

val itemExclusion = if (cardItem.exclusionRules.isEmpty()) {
EvaluationResult.Fail
} else {
cardItem.exclusionRules.evaluateExclusionRules(message.id, rules)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Card item percentile rules use parent message ID

When evaluating card item rules in filterCardsListMessage, message.id is passed to evaluateMatchingRules and evaluateExclusionRules instead of cardItem.id. These functions use this ID for percentile cohort lookups via remoteMessagingCohortStore.getPercentile(messageId). This means all card items within a message share the same percentile cohort value, so if rules with targetPercentile are used on card items, they cannot have independent percentage-based rollouts - they'll all pass or fail together based on the message's percentile. If independent per-card percentile targeting is intended, the card item's own id would need to be passed instead.


Please tell me if this was useful or not with a 👍 or 👎.

Fix in Cursor Fix in Web

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is probably correct. If there's a rule used by an item, we should pass the cardItem.id. We use that id to store the percentile and keep the logic deterministic.
The message will have its own percentile to decide % for the message. The item percentile should belong to them.

}

itemMatching == EvaluationResult.Match && itemExclusion == EvaluationResult.Fail
}
}

if (filteredItems.isEmpty()) {
logcat(INFO) { "RMF: All CardItems filtered out for message ${message.id}. Nothing to display." }
return null
}

logcat(INFO) { "RMF: Filtered ${cardsList.listItems.size - filteredItems.size} CardItems for message ${message.id}." }
return message.copy(content = cardsList.copy(listItems = filteredItems))
}

private suspend fun Iterable<Int>.evaluateMatchingRules(
messageId: String,
rules: List<Rule>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ private fun List<JsonListItem>?.toListItems(actionMappers: Set<MessageActionMapp
placeholder = jsonItem.placeholder.asPlaceholder(),
primaryAction = jsonItem.primaryAction?.toAction(actionMappers)
?: throw IllegalStateException("CardItem primaryAction cannot be null"),
matchingRules = jsonItem.matchingRules.orEmpty(),
exclusionRules = jsonItem.exclusionRules.orEmpty(),
)
} ?: emptyList()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ data class JsonListItem(
val descriptionText: String,
val placeholder: String = "",
val primaryAction: JsonMessageAction?,
val matchingRules: List<Int> = emptyList(),
val exclusionRules: List<Int> = emptyList(),
)

@Suppress("ktlint:standard:class-naming")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ object RemoteMessageOM {
descriptionText = "Item Description 1",
placeholder = IMAGE_AI,
primaryAction = urlAction(),
matchingRules = emptyList(),
exclusionRules = emptyList(),
),
CardItem(
id = "item2",
Expand All @@ -120,6 +122,8 @@ object RemoteMessageOM {
descriptionText = "Item Description 2",
placeholder = RADAR,
primaryAction = urlAction(),
matchingRules = emptyList(),
exclusionRules = emptyList(),
),
),
) = Content.CardsList(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,4 +325,85 @@ class CardsListMessageMapperTest {
assertEquals("Share this!", shareAction.value)
assertEquals("Share Title", shareAction.title)
}

@Test
fun whenCardsListMessageWithMatchingAndExclusionRulesThenMapCorrectly() {
val listItemsWithRules = listOf(
JsonListItem(
id = "item1",
type = "two_line_list_item",
titleText = "Feature One",
descriptionText = "First feature",
placeholder = "ImageAI",
primaryAction = JsonMessageAction(type = "url", value = "https://example.com/1", additionalParameters = null),
matchingRules = listOf(1, 2, 3),
exclusionRules = listOf(4, 5),
),
JsonListItem(
id = "item2",
type = "two_line_list_item",
titleText = "Feature Two",
descriptionText = "Second feature",
placeholder = "Radar",
primaryAction = JsonMessageAction(type = "url", value = "https://example.com/2", additionalParameters = null),
matchingRules = listOf(6),
exclusionRules = emptyList(),
),
)

val jsonMessages = listOf(
aJsonMessage(
id = "cards10",
content = cardsListJsonContent(listItems = listItemsWithRules),
),
)

val remoteMessages = jsonMessages.mapToRemoteMessage(Locale.US, messageActionPlugins)

assertEquals(1, remoteMessages.size)
val content = remoteMessages.first().content as Content.CardsList
assertEquals(2, content.listItems.size)

val item1 = content.listItems[0]
assertEquals("item1", item1.id)
assertEquals(listOf(1, 2, 3), item1.matchingRules)
assertEquals(listOf(4, 5), item1.exclusionRules)

val item2 = content.listItems[1]
assertEquals("item2", item2.id)
assertEquals(listOf(6), item2.matchingRules)
assertEquals(emptyList<Int>(), item2.exclusionRules)
}

@Test
fun whenCardsListMessageWithoutRulesThenDefaultToEmptyLists() {
val listItemsWithoutRules = listOf(
JsonListItem(
id = "item1",
type = "two_line_list_item",
titleText = "Feature",
descriptionText = "Description",
placeholder = "ImageAI",
primaryAction = JsonMessageAction(type = "url", value = "https://example.com", additionalParameters = null),
),
)

val jsonMessages = listOf(
aJsonMessage(
id = "cards11",
content = cardsListJsonContent(listItems = listItemsWithoutRules),
),
)

val remoteMessages = jsonMessages.mapToRemoteMessage(Locale.US, messageActionPlugins)

assertEquals(1, remoteMessages.size)
val content = remoteMessages.first().content as Content.CardsList
assertEquals(1, content.listItems.size)

val item = content.listItems[0]
assertEquals("item1", item.id)
assertEquals(emptyList<Int>(), item.matchingRules)
assertEquals(emptyList<Int>(), item.exclusionRules)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,8 @@ class RemoteMessagingConfigJsonMapperTest {
primaryAction = Action.UrlInContext(
value = "https://duckduckgo.com/duckduckgo-help-pages/results/how-to-filter-out-ai-images-in-duckduckgo-search-results",
),
matchingRules = emptyList(),
exclusionRules = emptyList(),
),
CardItem(
id = "enhanced_scam_blocker",
Expand All @@ -299,6 +301,8 @@ class RemoteMessagingConfigJsonMapperTest {
primaryAction = Action.UrlInContext(
value = "https://spreadprivacy.com/scam-blocker/",
),
matchingRules = emptyList(),
exclusionRules = emptyList(),
),
CardItem(
id = "import_passwords",
Expand All @@ -307,6 +311,8 @@ class RemoteMessagingConfigJsonMapperTest {
descriptionText = "Use DuckDuckGo to manage passwords on apps and sites across your whole device.",
placeholder = Content.Placeholder.KEY_IMPORT,
primaryAction = Action.DefaultCredentialProvider,
matchingRules = emptyList(),
exclusionRules = emptyList(),
),
),
),
Expand Down
Loading
Loading