diff --git a/PixelDefinitions/pixels/remote_messaging_framework.json5 b/PixelDefinitions/pixels/remote_messaging_framework.json5 new file mode 100644 index 000000000000..5f29bee2c7d9 --- /dev/null +++ b/PixelDefinitions/pixels/remote_messaging_framework.json5 @@ -0,0 +1,159 @@ +// RMF pixels +{ + "m_remote_message_shown_unique": { + "description": "Triggered when a remote message is displayed to the user for the first time", + "owners": ["cmonfortep"], + "triggers": ["other"], + "suffixes": ["form_factor"], + "parameters": [ + "appVersion", + "atb", + { + "key": "message", + "description": "The identifier for the message, sourced from the remote message configuration", + "type": "string" + } + ] + }, + "m_remote_message_shown": { + "description": "Triggered when a remote message is displayed to the user, regardless of whether it's their first time seeing it", + "owners": ["cmonfortep"], + "triggers": ["other"], + "suffixes": ["form_factor"], + "parameters": [ + "appVersion", + "atb", + { + "key": "message", + "description": "The identifier for the message, sourced from the remote message configuration", + "type": "string" + } + ] + }, + "m_remote_message_dismissed": { + "description": "Triggered when a remote message is dismissed by the user", + "owners": ["cmonfortep", "anikiki"], + "triggers": ["other"], + "suffixes": ["form_factor"], + "parameters": [ + "appVersion", + "atb", + { + "key": "message", + "description": "The identifier for the message, sourced from the remote message configuration", + "type": "string" + }, + { + "key": "dismiss_type", + "description": "The type of dismissal. This value is optional", + "type": "string", + "enum": ["close_button", "back_button_or_gesture"] + } + ] + }, + "m_remote_message_action_clicked": { + "description": "Triggered when the user clicks the action button on the remote message", + "owners": ["cmonfortep"], + "triggers": ["other"], + "suffixes": ["form_factor"], + "parameters": [ + "appVersion", + "atb", + { + "key": "message", + "description": "The identifier for the message, sourced from the remote message configuration", + "type": "string" + } + ] + }, + "m_remote_message_primary_action_clicked": { + "description": "Triggered when the user clicks the primary action button on the remote message", + "owners": ["cmonfortep"], + "triggers": ["other"], + "suffixes": ["form_factor"], + "parameters": [ + "appVersion", + "atb", + { + "key": "message", + "description": "The identifier for the message, sourced from the remote message configuration", + "type": "string" + } + ] + }, + "m_remote_message_secondary_action_clicked": { + "description": "Triggered when the user clicks the secondary action button on the remote message", + "owners": ["cmonfortep"], + "triggers": ["other"], + "suffixes": ["form_factor"], + "parameters": [ + "appVersion", + "atb", + { + "key": "message", + "description": "The identifier for the message, sourced from the remote message configuration", + "type": "string" + } + ] + }, + "m_remote_message_sheet": { + "description": "Triggered after the share action sheet has closed", + "owners": ["cmonfortep"], + "triggers": ["other"], + "suffixes": ["form_factor"], + "parameters": [ + "appVersion", + "atb", + { + "key": "message", + "description": "The identifier for the message, sourced from the remote message configuration", + "type": "string" + }, + { + "key": "success", + "description": "Indicates whether the user utilised one of the actions. `False` means the user closed the share action sheet without taking any action", + "type": "boolean" + } + ] + }, + "m_remote_message_card_shown": { + "description": "Triggered when a card is displayed within a card-style remote message", + "owners": ["anikiki"], + "triggers": ["other"], + "suffixes": ["form_factor"], + "parameters": [ + "appVersion", + "atb", + { + "key": "message", + "description": "The identifier for the message, sourced from the remote message configuration", + "type": "string" + }, + { + "key": "card", + "description": "The identifier for the card, sourced from the remote message configuration", + "type": "string" + } + ] + }, + "m_remote_message_card_clicked": { + "description": "Triggered when a card is clicked within a card-style remote message", + "owners": ["anikiki"], + "triggers": ["other"], + "suffixes": ["form_factor"], + "parameters": [ + "appVersion", + "atb", + { + "key": "message", + "description": "The identifier for the message, sourced from the remote message configuration", + "type": "string" + }, + { + "key": "card", + "description": "The identifier for the card, sourced from the remote message configuration", + "type": "string" + } + ] + } +} diff --git a/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/ui/CardsListRemoteMessagePixelHelper.kt b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/ui/CardsListRemoteMessagePixelHelper.kt new file mode 100644 index 000000000000..b6c357d1c591 --- /dev/null +++ b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/ui/CardsListRemoteMessagePixelHelper.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.duckduckgo.remote.messaging.impl.ui + +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.remote.messaging.api.CardItem +import com.duckduckgo.remote.messaging.api.RemoteMessage +import com.duckduckgo.remote.messaging.api.RemoteMessagingRepository +import com.duckduckgo.remote.messaging.impl.pixels.RemoteMessagingPixelName +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +/** + * Helper class for handling CardsList-specific remote message operations. + * Encapsulates pixel firing and repository interactions specific to CardsList messages. + */ +interface CardsListRemoteMessagePixelHelper { + + /** + * Fires pixel when a card item is shown to the user. + * + * @param remoteMessage The remote message containing the card + * @param cardItem The specific card item that was shown + */ + fun fireCardItemShownPixel(remoteMessage: RemoteMessage, cardItem: CardItem) + + /** + * Fires pixel when a card item is clicked by the user. + * + * @param remoteMessage The remote message containing the card + * @param cardItem The specific card item that was clicked + */ + fun fireCardItemClickedPixel(remoteMessage: RemoteMessage, cardItem: CardItem) + + /** + * Fires pixel and dismisses the CardsList message with custom parameters. + * This should be used instead of the generic RemoteMessageModel.onMessageDismissed() + * for CardsList messages to include CardsList-specific parameters. + * + * @param remoteMessageId The id of the remote message to dismiss + * @param customParams Additional parameters for the dismiss pixel (e.g. dismissType) + */ + suspend fun dismissCardsListMessage(remoteMessageId: String, customParams: Map = emptyMap()) +} + +@ContributesBinding(AppScope::class) +class RealCardsListRemoteMessagePixelHelper @Inject constructor( + private val pixel: Pixel, + private val remoteMessagingRepository: RemoteMessagingRepository, +) : CardsListRemoteMessagePixelHelper { + + override fun fireCardItemShownPixel(remoteMessage: RemoteMessage, cardItem: CardItem) { + val pixelParams = mapOf( + PARAM_NAME_MESSAGE_ID to remoteMessage.id, + PARAM_NAME_CARD_ID to cardItem.id, + ) + pixel.fire( + pixel = CardsListRemoteMessagePixelName.REMOTE_MESSAGE_CARD_SHOWN, + parameters = pixelParams, + ) + } + + override fun fireCardItemClickedPixel(remoteMessage: RemoteMessage, cardItem: CardItem) { + val pixelParams = mapOf( + PARAM_NAME_MESSAGE_ID to remoteMessage.id, + PARAM_NAME_CARD_ID to cardItem.id, + ) + pixel.fire( + pixel = CardsListRemoteMessagePixelName.REMOTE_MESSAGE_CARD_CLICKED, + parameters = pixelParams, + ) + } + + override suspend fun dismissCardsListMessage( + remoteMessageId: String, + customParams: Map, + ) { + val pixelParams = buildMap { + put(PARAM_NAME_MESSAGE_ID, remoteMessageId) + putAll(customParams) + } + pixel.fire( + pixel = RemoteMessagingPixelName.REMOTE_MESSAGE_DISMISSED, + parameters = pixelParams, + ) + remoteMessagingRepository.dismissMessage(remoteMessageId) + } + + companion object { + private const val PARAM_NAME_MESSAGE_ID = "message" + private const val PARAM_NAME_CARD_ID = "card" + internal const val PARAM_NAME_DISMISS_TYPE = "dismissType" + internal const val PARAM_VALUE_CLOSE_BUTTON = "close_button" + internal const val PARAM_VALUE_BACK_BUTTON_OR_GESTURE = "back_button_or_gesture" + } +} + +/** + * Pixel names specific to CardsList remote messages. + */ +enum class CardsListRemoteMessagePixelName(override val pixelName: String) : Pixel.PixelName { + REMOTE_MESSAGE_CARD_SHOWN("m_remote_message_card_shown"), + REMOTE_MESSAGE_CARD_CLICKED("m_remote_message_card_clicked"), +} diff --git a/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/ui/CardsListRemoteMessageView.kt b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/ui/CardsListRemoteMessageView.kt index 1f93dd088c84..a7257dc14595 100644 --- a/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/ui/CardsListRemoteMessageView.kt +++ b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/ui/CardsListRemoteMessageView.kt @@ -142,6 +142,7 @@ class CardsListRemoteMessageView @JvmOverloads constructor( binding.headerImage.setImageResource(it.placeholder.drawable(true)) binding.headerTitle.text = it.titleText binding.actionButton.text = it.primaryActionText + viewModel.onMessageShown() } } diff --git a/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/ui/CardsListRemoteMessageViewModel.kt b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/ui/CardsListRemoteMessageViewModel.kt index dedc451330c6..668c3080d600 100644 --- a/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/ui/CardsListRemoteMessageViewModel.kt +++ b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/ui/CardsListRemoteMessageViewModel.kt @@ -25,7 +25,11 @@ import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ViewScope import com.duckduckgo.remote.messaging.api.CardItem import com.duckduckgo.remote.messaging.api.Content +import com.duckduckgo.remote.messaging.api.RemoteMessage +import com.duckduckgo.remote.messaging.api.RemoteMessageModel import com.duckduckgo.remote.messaging.api.RemoteMessagingRepository +import com.duckduckgo.remote.messaging.impl.ui.RealCardsListRemoteMessagePixelHelper.Companion.PARAM_NAME_DISMISS_TYPE +import com.duckduckgo.remote.messaging.impl.ui.RealCardsListRemoteMessagePixelHelper.Companion.PARAM_VALUE_CLOSE_BUTTON import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -39,8 +43,10 @@ import javax.inject.Inject @ContributesViewModel(ViewScope::class) class CardsListRemoteMessageViewModel @Inject constructor( private val remoteMessagingRepository: RemoteMessagingRepository, + private val remoteMessagingModel: RemoteMessageModel, private val commandActionMapper: CommandActionMapper, private val dispatchers: DispatcherProvider, + private val cardsListPixelHelper: CardsListRemoteMessagePixelHelper, ) : ViewModel(), DefaultLifecycleObserver, ModalSurfaceListener { private val _viewState = MutableStateFlow(null) @@ -49,6 +55,8 @@ class CardsListRemoteMessageViewModel @Inject constructor( val commands: Flow = _command.receiveAsFlow() val viewState: Flow = _viewState.asStateFlow() + private var lastRemoteMessageSeen: RemoteMessage? = null + fun init(messageId: String?) { if (messageId == null) { viewModelScope.launch { @@ -59,6 +67,10 @@ class CardsListRemoteMessageViewModel @Inject constructor( viewModelScope.launch(dispatchers.io()) { val message = remoteMessagingRepository.getMessageById(messageId) + val newMessage = message?.id != lastRemoteMessageSeen?.id + if (newMessage) { + lastRemoteMessageSeen = message + } val cardsList = message?.content as? Content.CardsList if (cardsList != null) { _viewState.value = ViewState(cardsList) @@ -68,27 +80,47 @@ class CardsListRemoteMessageViewModel @Inject constructor( } } + fun onMessageShown() { + val message = lastRemoteMessageSeen ?: return + viewModelScope.launch { + remoteMessagingModel.onMessageShown(message) + val cardsList = message.content as? Content.CardsList + cardsList?.listItems?.forEach { cardItem -> + cardsListPixelHelper.fireCardItemShownPixel(message, cardItem) + } + } + } + fun onCloseButtonClicked() { + val message = lastRemoteMessageSeen ?: return viewModelScope.launch { _command.send(Command.DismissMessage) + val customParams = mapOf( + PARAM_NAME_DISMISS_TYPE to PARAM_VALUE_CLOSE_BUTTON, + ) + cardsListPixelHelper.dismissCardsListMessage(message.id, customParams) } } fun onActionButtonClicked() { + val message = lastRemoteMessageSeen ?: return viewModelScope.launch { val action = _viewState.value?.cardsLists?.primaryAction action?.let { val command = commandActionMapper.asCommand(it) _command.send(command) + remoteMessagingModel.onPrimaryActionClicked(message) } } } override fun onItemClicked(item: CardItem) { viewModelScope.launch { + val message = lastRemoteMessageSeen ?: return@launch val action = item.primaryAction val command = commandActionMapper.asCommand(action) _command.send(command) + cardsListPixelHelper.fireCardItemClickedPixel(message, item) } } @@ -105,10 +137,12 @@ class CardsListRemoteMessageViewModel @Inject constructor( val url: String, val shareTitle: String, ) : Command() + data class LaunchScreen( val screen: String, val payload: String, ) : Command() + data object LaunchDefaultCredentialProvider : Command() } } diff --git a/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/ui/ModalSurfaceActivity.kt b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/ui/ModalSurfaceActivity.kt index bc2389d0d0a2..137db0ce5411 100644 --- a/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/ui/ModalSurfaceActivity.kt +++ b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/ui/ModalSurfaceActivity.kt @@ -17,6 +17,7 @@ package com.duckduckgo.remote.messaging.impl.ui import android.os.Bundle +import androidx.activity.addCallback import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope @@ -45,6 +46,7 @@ class ModalSurfaceActivity : DuckDuckGoActivity(), CardsListRemoteMessageView.Ca initialise() setupObservers() + setupBackNavigationHandler() } override fun onDismiss() { @@ -84,4 +86,10 @@ class ModalSurfaceActivity : DuckDuckGoActivity(), CardsListRemoteMessageView.Ca } } } + + private fun setupBackNavigationHandler() { + onBackPressedDispatcher.addCallback(this) { + viewModel.onBackPressed() + } + } } diff --git a/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/ui/ModalSurfaceViewModel.kt b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/ui/ModalSurfaceViewModel.kt index a4fd818a9bb6..53e97ecf23af 100644 --- a/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/ui/ModalSurfaceViewModel.kt +++ b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/ui/ModalSurfaceViewModel.kt @@ -22,6 +22,8 @@ import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.remote.messaging.api.Content +import com.duckduckgo.remote.messaging.impl.ui.RealCardsListRemoteMessagePixelHelper.Companion.PARAM_NAME_DISMISS_TYPE +import com.duckduckgo.remote.messaging.impl.ui.RealCardsListRemoteMessagePixelHelper.Companion.PARAM_VALUE_BACK_BUTTON_OR_GESTURE import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -34,6 +36,7 @@ import javax.inject.Inject @ContributesViewModel(ActivityScope::class) class ModalSurfaceViewModel @Inject constructor( private val dispatchers: DispatcherProvider, + private val cardsListPixelHelper: CardsListRemoteMessagePixelHelper, ) : ViewModel() { private val _viewState = MutableStateFlow(null) private val _command = Channel(1, BufferOverflow.DROP_OLDEST) @@ -41,11 +44,14 @@ class ModalSurfaceViewModel @Inject constructor( val commands: Flow = _command.receiveAsFlow() val viewState: Flow = _viewState.asStateFlow() + private var lastRemoteMessageIdSeen: String? = null + fun onInitialise(activityParams: ModalSurfaceActivityFromMessageId?) { val messageId = activityParams?.messageId ?: return val messageType = activityParams.messageType if (messageType == Content.MessageType.CARDS_LIST) { + lastRemoteMessageIdSeen = messageId _viewState.value = ViewState(messageId = messageId, showCardsListView = true) } } @@ -56,6 +62,27 @@ class ModalSurfaceViewModel @Inject constructor( } } + fun onBackPressed() { + val currentState = _viewState.value + if (currentState?.showCardsListView != true) { + // If not showing CardsListView, just dismiss without pixel + viewModelScope.launch { + _command.send(Command.DismissMessage) + } + return + } + + viewModelScope.launch { + val customParams = mapOf( + PARAM_NAME_DISMISS_TYPE to PARAM_VALUE_BACK_BUTTON_OR_GESTURE, + ) + lastRemoteMessageIdSeen?.let { + cardsListPixelHelper.dismissCardsListMessage(it, customParams) + } + _command.send(Command.DismissMessage) + } + } + data class ViewState(val messageId: String, val showCardsListView: Boolean) sealed class Command { diff --git a/remote-messaging/remote-messaging-impl/src/test/java/com/duckduckgo/remote/messaging/impl/ui/CardsListRemoteMessageViewModelTest.kt b/remote-messaging/remote-messaging-impl/src/test/java/com/duckduckgo/remote/messaging/impl/ui/CardsListRemoteMessageViewModelTest.kt index 4fe356d39c91..4eb56181cf77 100644 --- a/remote-messaging/remote-messaging-impl/src/test/java/com/duckduckgo/remote/messaging/impl/ui/CardsListRemoteMessageViewModelTest.kt +++ b/remote-messaging/remote-messaging-impl/src/test/java/com/duckduckgo/remote/messaging/impl/ui/CardsListRemoteMessageViewModelTest.kt @@ -23,6 +23,7 @@ import com.duckduckgo.remote.messaging.api.CardItem import com.duckduckgo.remote.messaging.api.CardItemType import com.duckduckgo.remote.messaging.api.Content import com.duckduckgo.remote.messaging.api.RemoteMessage +import com.duckduckgo.remote.messaging.api.RemoteMessageModel import com.duckduckgo.remote.messaging.api.RemoteMessagingRepository import com.duckduckgo.remote.messaging.api.Surface import com.duckduckgo.remote.messaging.impl.ui.CardsListRemoteMessageViewModel.Command @@ -31,6 +32,7 @@ import org.junit.Assert.* import org.junit.Before import org.junit.Rule import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -43,14 +45,18 @@ class CardsListRemoteMessageViewModelTest { private lateinit var viewModel: CardsListRemoteMessageViewModel private val remoteMessagingRepository: RemoteMessagingRepository = mock() + private val remoteMessagingModel: RemoteMessageModel = mock() private val commandActionMapper: CommandActionMapper = mock() + private val cardsListPixelHelper: CardsListRemoteMessagePixelHelper = mock() @Before fun setup() { viewModel = CardsListRemoteMessageViewModel( remoteMessagingRepository = remoteMessagingRepository, + remoteMessagingModel = remoteMessagingModel, commandActionMapper = commandActionMapper, dispatchers = coroutineTestRule.testDispatcherProvider, + cardsListPixelHelper = cardsListPixelHelper, ) } @@ -165,7 +171,91 @@ class CardsListRemoteMessageViewModelTest { } @Test - fun whenOnCloseButtonClickedThenDismissMessageCommandEmitted() = runTest { + fun whenOnMessageShownWithNullMessageThenNoPixelsFired() = runTest { + viewModel.onMessageShown() + + verify(remoteMessagingModel, org.mockito.kotlin.never()).onMessageShown(any()) + verify(cardsListPixelHelper, org.mockito.kotlin.never()).fireCardItemShownPixel(any(), any()) + } + + @Test + fun whenOnMessageShownWithValidMessageThenPixelsFiredForAllItems() = runTest { + val messageId = "message-123" + val cardItem1 = CardItem( + id = "item1", + type = CardItemType.TWO_LINE_LIST_ITEM, + placeholder = Content.Placeholder.DDG_ANNOUNCE, + titleText = "Card 1", + descriptionText = "Description 1", + primaryAction = Action.Dismiss, + ) + val cardItem2 = CardItem( + id = "item2", + type = CardItemType.TWO_LINE_LIST_ITEM, + placeholder = Content.Placeholder.CRITICAL_UPDATE, + titleText = "Card 2", + descriptionText = "Description 2", + primaryAction = Action.Dismiss, + ) + val cardsList = Content.CardsList( + titleText = "Test Cards", + descriptionText = "Description", + placeholder = Content.Placeholder.DDG_ANNOUNCE, + listItems = listOf(cardItem1, cardItem2), + primaryActionText = "Dismiss", + primaryAction = Action.Dismiss, + ) + val message = RemoteMessage( + id = messageId, + content = cardsList, + matchingRules = emptyList(), + exclusionRules = emptyList(), + surfaces = listOf(Surface.MODAL), + ) + whenever(remoteMessagingRepository.getMessageById(eq(messageId))).thenReturn(message) + + viewModel.init(messageId) + + viewModel.onMessageShown() + + verify(remoteMessagingModel).onMessageShown(eq(message)) + verify(cardsListPixelHelper).fireCardItemShownPixel(eq(message), eq(cardItem1)) + verify(cardsListPixelHelper).fireCardItemShownPixel(eq(message), eq(cardItem2)) + } + + @Test + fun whenOnCloseButtonClickedWithNullMessageThenOnlyDismissCommandEmitted() = runTest { + viewModel.commands.test { + viewModel.onCloseButtonClicked() + + expectNoEvents() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenOnCloseButtonClickedWithValidMessageThenDismissMessageCommandEmittedAndPixelFired() = runTest { + val messageId = "message-123" + val cardsList = Content.CardsList( + titleText = "Test Cards", + descriptionText = "Description", + placeholder = Content.Placeholder.DDG_ANNOUNCE, + listItems = emptyList(), + primaryActionText = "Dismiss", + primaryAction = Action.Dismiss, + ) + val message = RemoteMessage( + id = messageId, + content = cardsList, + matchingRules = emptyList(), + exclusionRules = emptyList(), + surfaces = listOf(Surface.MODAL), + ) + whenever(remoteMessagingRepository.getMessageById(eq(messageId))).thenReturn(message) + + viewModel.init(messageId) + viewModel.commands.test { viewModel.onCloseButtonClicked() @@ -174,6 +264,8 @@ class CardsListRemoteMessageViewModelTest { cancelAndIgnoreRemainingEvents() } + + verify(cardsListPixelHelper).dismissCardsListMessage(eq(messageId), any()) } @Test @@ -188,7 +280,7 @@ class CardsListRemoteMessageViewModelTest { } @Test - fun whenOnActionButtonClickedWithValidActionThenCommandEmitted() = runTest { + fun whenOnActionButtonClickedWithValidActionThenCommandEmittedAndModelUpdated() = runTest { val messageId = "message-123" val primaryAction = Action.Url("https://example.com") val cardsList = Content.CardsList( @@ -212,7 +304,6 @@ class CardsListRemoteMessageViewModelTest { // Initialize view state viewModel.init(messageId) - coroutineTestRule.testDispatcher.scheduler.advanceUntilIdle() viewModel.commands.test { viewModel.onActionButtonClicked() @@ -223,10 +314,12 @@ class CardsListRemoteMessageViewModelTest { cancelAndIgnoreRemainingEvents() } + + verify(remoteMessagingModel).onPrimaryActionClicked(eq(message)) } @Test - fun whenOnItemClickedThenCommandEmittedForItemAction() = runTest { + fun whenOnItemClickedWithNullMessageThenCommandEmittedButNoPixel() = runTest { val itemAction = Action.PlayStore("com.example.app") val cardItem = CardItem( id = "id", @@ -239,6 +332,48 @@ class CardsListRemoteMessageViewModelTest { val expectedCommand = Command.LaunchPlayStore("com.example.app") whenever(commandActionMapper.asCommand(eq(itemAction))).thenReturn(expectedCommand) + viewModel.commands.test { + viewModel.onItemClicked(cardItem) + + expectNoEvents() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenOnItemClickedWithValidMessageThenCommandEmittedAndPixelFired() = runTest { + val messageId = "message-123" + val itemAction = Action.PlayStore("com.example.app") + val cardItem = CardItem( + id = "item1", + titleText = "Test Card", + descriptionText = "Description", + primaryAction = itemAction, + placeholder = Content.Placeholder.DDG_ANNOUNCE, + type = CardItemType.TWO_LINE_LIST_ITEM, + ) + val cardsList = Content.CardsList( + titleText = "Test Cards", + descriptionText = "Description", + placeholder = Content.Placeholder.DDG_ANNOUNCE, + listItems = listOf(cardItem), + primaryActionText = "Action", + primaryAction = Action.Dismiss, + ) + val message = RemoteMessage( + id = messageId, + content = cardsList, + matchingRules = emptyList(), + exclusionRules = emptyList(), + surfaces = listOf(Surface.MODAL), + ) + val expectedCommand = Command.LaunchPlayStore("com.example.app") + whenever(remoteMessagingRepository.getMessageById(eq(messageId))).thenReturn(message) + whenever(commandActionMapper.asCommand(eq(itemAction))).thenReturn(expectedCommand) + + viewModel.init(messageId) + viewModel.commands.test { viewModel.onItemClicked(cardItem) @@ -248,10 +383,13 @@ class CardsListRemoteMessageViewModelTest { cancelAndIgnoreRemainingEvents() } + + verify(cardsListPixelHelper).fireCardItemClickedPixel(eq(message), eq(cardItem)) } @Test - fun whenOnItemClickedMultipleTimesThenMultipleCommandsEmitted() = runTest { + fun whenOnItemClickedMultipleTimesThenMultipleCommandsEmittedAndPixelsFired() = runTest { + val messageId = "message-123" val itemAction1 = Action.Url("https://example1.com") val itemAction2 = Action.Url("https://example2.com") val cardItem1 = CardItem( @@ -270,9 +408,27 @@ class CardsListRemoteMessageViewModelTest { type = CardItemType.TWO_LINE_LIST_ITEM, placeholder = Content.Placeholder.DDG_ANNOUNCE, ) + val cardsList = Content.CardsList( + titleText = "Test Cards", + descriptionText = "Description", + placeholder = Content.Placeholder.DDG_ANNOUNCE, + listItems = listOf(cardItem1, cardItem2), + primaryActionText = "Action", + primaryAction = Action.Dismiss, + ) + val message = RemoteMessage( + id = messageId, + content = cardsList, + matchingRules = emptyList(), + exclusionRules = emptyList(), + surfaces = listOf(Surface.MODAL), + ) + whenever(remoteMessagingRepository.getMessageById(eq(messageId))).thenReturn(message) whenever(commandActionMapper.asCommand(eq(itemAction1))).thenReturn(Command.SubmitUrl("https://example1.com")) whenever(commandActionMapper.asCommand(eq(itemAction2))).thenReturn(Command.SubmitUrl("https://example2.com")) + viewModel.init(messageId) + viewModel.commands.test { viewModel.onItemClicked(cardItem1) val command1 = awaitItem() @@ -284,10 +440,14 @@ class CardsListRemoteMessageViewModelTest { cancelAndIgnoreRemainingEvents() } + + verify(cardsListPixelHelper).fireCardItemClickedPixel(eq(message), eq(cardItem1)) + verify(cardsListPixelHelper).fireCardItemClickedPixel(eq(message), eq(cardItem2)) } @Test fun whenCommandActionMapperCalledThenVerifyCorrectActionPassed() = runTest { + val messageId = "message-123" val action = Action.DefaultBrowser val cardItem = CardItem( id = "id", @@ -297,10 +457,27 @@ class CardsListRemoteMessageViewModelTest { type = CardItemType.TWO_LINE_LIST_ITEM, placeholder = Content.Placeholder.DDG_ANNOUNCE, ) + val cardsList = Content.CardsList( + titleText = "Test Cards", + descriptionText = "Description", + placeholder = Content.Placeholder.DDG_ANNOUNCE, + listItems = listOf(cardItem), + primaryActionText = "Action", + primaryAction = Action.Dismiss, + ) + val message = RemoteMessage( + id = messageId, + content = cardsList, + matchingRules = emptyList(), + exclusionRules = emptyList(), + surfaces = listOf(Surface.MODAL), + ) + whenever(remoteMessagingRepository.getMessageById(eq(messageId))).thenReturn(message) whenever(commandActionMapper.asCommand(eq(action))).thenReturn(Command.LaunchDefaultBrowser) + viewModel.init(messageId) + viewModel.onItemClicked(cardItem) - coroutineTestRule.testDispatcher.scheduler.advanceUntilIdle() verify(commandActionMapper).asCommand(eq(action)) } diff --git a/remote-messaging/remote-messaging-impl/src/test/java/com/duckduckgo/remote/messaging/impl/ui/ModalSurfaceViewModelTest.kt b/remote-messaging/remote-messaging-impl/src/test/java/com/duckduckgo/remote/messaging/impl/ui/ModalSurfaceViewModelTest.kt index a0a85ccd3c11..5b73671d3d3c 100644 --- a/remote-messaging/remote-messaging-impl/src/test/java/com/duckduckgo/remote/messaging/impl/ui/ModalSurfaceViewModelTest.kt +++ b/remote-messaging/remote-messaging-impl/src/test/java/com/duckduckgo/remote/messaging/impl/ui/ModalSurfaceViewModelTest.kt @@ -24,18 +24,24 @@ import org.junit.Assert.* import org.junit.Before import org.junit.Rule import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions class ModalSurfaceViewModelTest { @get:Rule val coroutineTestRule = CoroutineTestRule() + private val cardsListPixelHelper: CardsListRemoteMessagePixelHelper = mock() + private lateinit var viewModel: ModalSurfaceViewModel @Before fun setup() { viewModel = ModalSurfaceViewModel( dispatchers = coroutineTestRule.testDispatcherProvider, + cardsListPixelHelper = cardsListPixelHelper, ) } @@ -98,4 +104,65 @@ class ModalSurfaceViewModelTest { cancelAndIgnoreRemainingEvents() } } + + @Test + fun whenOnBackPressedWithCardsListViewShownThenDismissMessageCommandEmittedAndPixelFired() = runTest { + val messageId = "test-message-id" + val params = ModalSurfaceActivityFromMessageId( + messageId = messageId, + messageType = Content.MessageType.CARDS_LIST, + ) + + // Initialize with CARDS_LIST to set showCardsListView = true + viewModel.onInitialise(params) + + viewModel.commands.test { + viewModel.onBackPressed() + + val command = awaitItem() + assertTrue(command is ModalSurfaceViewModel.Command.DismissMessage) + + // Verify pixel helper was called with correct parameters + val expectedParams = mapOf( + RealCardsListRemoteMessagePixelHelper.PARAM_NAME_DISMISS_TYPE to + RealCardsListRemoteMessagePixelHelper.PARAM_VALUE_BACK_BUTTON_OR_GESTURE, + ) + verify(cardsListPixelHelper).dismissCardsListMessage(messageId, expectedParams) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenOnBackPressedWithCardsListViewNotShownThenDismissMessageCommandEmittedWithoutPixel() = runTest { + // Don't initialize or initialize with non-CARDS_LIST type, so showCardsListView = false + viewModel.commands.test { + viewModel.onBackPressed() + + val command = awaitItem() + assertTrue(command is ModalSurfaceViewModel.Command.DismissMessage) + + // Verify pixel helper was NOT called + verifyNoInteractions(cardsListPixelHelper) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenOnBackPressedWithCardsListViewButNoMessageIdThenDismissMessageCommandEmittedWithoutPixel() = runTest { + // This tests an edge case where viewState might be set but lastRemoteMessageIdSeen is null + // In the current implementation, this shouldn't happen, but it's good to verify the null-safe behavior + viewModel.commands.test { + viewModel.onBackPressed() + + val command = awaitItem() + assertTrue(command is ModalSurfaceViewModel.Command.DismissMessage) + + // Verify pixel helper was NOT called since lastRemoteMessageIdSeen is null + verifyNoInteractions(cardsListPixelHelper) + + cancelAndIgnoreRemainingEvents() + } + } } diff --git a/remote-messaging/remote-messaging-impl/src/test/java/com/duckduckgo/remote/messaging/impl/ui/RealCardsListRemoteMessagePixelHelperTest.kt b/remote-messaging/remote-messaging-impl/src/test/java/com/duckduckgo/remote/messaging/impl/ui/RealCardsListRemoteMessagePixelHelperTest.kt new file mode 100644 index 000000000000..332184dd22c9 --- /dev/null +++ b/remote-messaging/remote-messaging-impl/src/test/java/com/duckduckgo/remote/messaging/impl/ui/RealCardsListRemoteMessagePixelHelperTest.kt @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.duckduckgo.remote.messaging.impl.ui + +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.remote.messaging.api.Action +import com.duckduckgo.remote.messaging.api.CardItem +import com.duckduckgo.remote.messaging.api.CardItemType +import com.duckduckgo.remote.messaging.api.Content +import com.duckduckgo.remote.messaging.api.RemoteMessage +import com.duckduckgo.remote.messaging.api.RemoteMessagingRepository +import com.duckduckgo.remote.messaging.api.Surface +import com.duckduckgo.remote.messaging.impl.pixels.RemoteMessagingPixelName +import com.duckduckgo.remote.messaging.impl.ui.RealCardsListRemoteMessagePixelHelper.Companion.PARAM_NAME_DISMISS_TYPE +import com.duckduckgo.remote.messaging.impl.ui.RealCardsListRemoteMessagePixelHelper.Companion.PARAM_VALUE_CLOSE_BUTTON +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +class RealCardsListRemoteMessagePixelHelperTest { + + @get:Rule + val coroutineTestRule = CoroutineTestRule() + + private lateinit var pixelHelper: RealCardsListRemoteMessagePixelHelper + private val pixel: Pixel = mock() + private val remoteMessagingRepository: RemoteMessagingRepository = mock() + + private val testRemoteMessage = RemoteMessage( + id = "test-message-123", + content = Content.CardsList( + titleText = "Test Title", + descriptionText = "Test Description", + placeholder = Content.Placeholder.DDG_ANNOUNCE, + listItems = emptyList(), + primaryActionText = "Action", + primaryAction = Action.Dismiss, + ), + matchingRules = emptyList(), + exclusionRules = emptyList(), + surfaces = listOf(Surface.MODAL), + ) + + private val testCardItem = CardItem( + id = "card-item-456", + titleText = "Card Title", + descriptionText = "Card Description", + primaryAction = Action.Dismiss, + type = CardItemType.TWO_LINE_LIST_ITEM, + placeholder = Content.Placeholder.DDG_ANNOUNCE, + ) + + @Before + fun setup() { + pixelHelper = RealCardsListRemoteMessagePixelHelper( + pixel = pixel, + remoteMessagingRepository = remoteMessagingRepository, + ) + } + + @Test + fun whenFireCardItemShownPixelThenCorrectPixelFiredWithMessageIdAndCardId() { + pixelHelper.fireCardItemShownPixel(testRemoteMessage, testCardItem) + + val expectedParams = mapOf( + "message" to "test-message-123", + "card" to "card-item-456", + ) + + verify(pixel).fire( + pixel = eq(CardsListRemoteMessagePixelName.REMOTE_MESSAGE_CARD_SHOWN), + parameters = eq(expectedParams), + encodedParameters = any(), + type = any(), + ) + } + + @Test + fun whenFireCardItemClickedPixelThenCorrectPixelFiredWithMessageIdAndCardId() { + pixelHelper.fireCardItemClickedPixel(testRemoteMessage, testCardItem) + + val expectedParams = mapOf( + "message" to "test-message-123", + "card" to "card-item-456", + ) + + verify(pixel).fire( + pixel = eq(CardsListRemoteMessagePixelName.REMOTE_MESSAGE_CARD_CLICKED), + parameters = eq(expectedParams), + encodedParameters = any(), + type = any(), + ) + } + + @Test + fun whenDismissCardsListMessageWithNoCustomParamsThenPixelFiredAndMessageDismissed() = runTest { + pixelHelper.dismissCardsListMessage(testRemoteMessage.id) + + val expectedParams = mapOf( + "message" to "test-message-123", + ) + + verify(pixel).fire( + pixel = eq(RemoteMessagingPixelName.REMOTE_MESSAGE_DISMISSED), + parameters = eq(expectedParams), + encodedParameters = any(), + type = any(), + ) + + verify(remoteMessagingRepository).dismissMessage(eq("test-message-123")) + } + + @Test + fun whenDismissCardsListMessageWithCustomParamsThenPixelIncludesCustomParams() = runTest { + val customParams = mapOf( + PARAM_NAME_DISMISS_TYPE to PARAM_VALUE_CLOSE_BUTTON, + ) + + pixelHelper.dismissCardsListMessage(testRemoteMessage.id, customParams) + + val expectedParams = mapOf( + "message" to "test-message-123", + PARAM_NAME_DISMISS_TYPE to PARAM_VALUE_CLOSE_BUTTON, + ) + + verify(pixel).fire( + pixel = eq(RemoteMessagingPixelName.REMOTE_MESSAGE_DISMISSED), + parameters = eq(expectedParams), + encodedParameters = any(), + type = any(), + ) + + verify(remoteMessagingRepository).dismissMessage(eq("test-message-123")) + } + + @Test + fun whenDismissCardsListMessageWithMultipleCustomParamsThenAllParamsIncluded() = runTest { + val customParams = mapOf( + PARAM_NAME_DISMISS_TYPE to PARAM_VALUE_CLOSE_BUTTON, + "extraParam1" to "value1", + "extraParam2" to "value2", + ) + + pixelHelper.dismissCardsListMessage(testRemoteMessage.id, customParams) + + val expectedParams = mapOf( + "message" to "test-message-123", + PARAM_NAME_DISMISS_TYPE to PARAM_VALUE_CLOSE_BUTTON, + "extraParam1" to "value1", + "extraParam2" to "value2", + ) + + verify(pixel).fire( + pixel = eq(RemoteMessagingPixelName.REMOTE_MESSAGE_DISMISSED), + parameters = eq(expectedParams), + encodedParameters = any(), + type = any(), + ) + + verify(remoteMessagingRepository).dismissMessage(eq("test-message-123")) + } +}