Skip to content

Conversation

@anikiki
Copy link
Contributor

@anikiki anikiki commented Dec 1, 2025

Task/Issue URL: https://app.asana.com/1/137249556945/project/1200581511062568/task/1211783737656697?focus=true

Description

Adds support for displaying remote messages in a modal surface. This implementation introduces a new activity that can display card list messages in a full-screen modal format, allowing users to interact with various card items.

The PR includes:

  • New ModalSurfaceActivity for displaying remote messages on a MODAL surface (full screen).
  • Implementation of CardsListRemoteMessageView to render card list based content based in what comes from the server.
  • RemoteMessageModalSurfaceEvaluator for evaluating if there is any remote message that should be displayed. Conditions to be met: feature flag enabled (remoteMessaging and remoteMessaging.remoteMessageModalSurface), onboarding finished, an active remote message with surface=MODAL is present).
  • Remote messages that contain the MODAL surface are never saved in the DB if the feature flag is not enabled (remoteMessaging and remoteMessaging.remoteMessageModalSurface).
  • Support for URL in context actions in remote messaging. The URL will open in a new WebViewActivity.
  • Integration with the app's navigation system

Steps to test this PR

Modal Surface Display and Integration

  • Verify that modal messages appear correctly when triggered
  • Check that card items display properly with title, description, and icons
  • Confirm that tapping on a card item performs the expected action: URLs are opened in a new WebViewActivity and the password action opens the system credentials.
  • Verify that the close button dismisses the modal
  • Test that the primary action button at the bottom works correctly

Various tests with the flags enabled / disabled and messages on other surfaces

UI changes

Cards list light mode Cards list dark mode
cardslist_light_mode cardslist_dark_mode
URL opened Credentials opened
url_opened credentials_screen_opened

Note

Introduces a full-screen modal surface to display remote messages (cards list), wired via new activity/view/viewmodels, feature toggles, repository/mappers updates, and evaluator, with tests and resources.

  • Remote Messaging (Modal Surface):
    • UI & Navigation: Add ModalSurfaceActivity, CardsListRemoteMessageView, ModalSurfaceAdapter, and ModalSurfaceViewModel with layouts (activity_modal_surface, view_cards_list_remote_message, view_remote_message_entry) and colors; register in AndroidManifest.
    • Lifecycle/Evaluation: Add RemoteMessageModalSurfaceEvaluator to launch modal when flags enabled, onboarding complete, and a Surface.MODAL message exists.
    • Feature Toggles: Move remoteMessageModalSurface toggle to remoteMessaging feature (RemoteMessagingFeatureToggles) and remove from AndroidBrowserConfigFeature.
    • Parsing/Mapping:
      • Extend Moshi mapping to support Content.CardsList.
      • Enable UrlInContext action mapper.
      • Update RemoteMessagingConfigJsonMapper to filter messages by surface based on toggles; keep MODAL messages only when enabled.
      • Keep message surfaces from source in AppRemoteMessagingRepository (no longer forced to NEW_TAB_PAGE).
    • New Tab Integration: Handle UrlInContext by submitting URLs like Url in RemoteMessageViewModel.
    • Tests: Add unit tests for config mapping (incl. cards list and toggles), modal view/viewmodel, command mapping; update fixtures and test utilities.
    • Build/Deps: Add UI dependencies (RecyclerView, AppCompat, Material, ConstraintLayout) to remote-messaging-impl.

Written by Cursor Bugbot for commit a2f7db4. This will update automatically on new commits. Configure here.

Copy link
Contributor Author

anikiki commented Dec 1, 2025

@anikiki anikiki changed the title Add Cards List feature to Remote Messaging with UI. [Android] "What’s New" promo message: Add the new UI elements Dec 1, 2025
Base automatically changed from feature/ana/whats_new_promo_message_add_new_action_to_open_password_in_system_settings to develop December 2, 2025 11:20
@anikiki anikiki force-pushed the feature/ana/android_whats_new_promo_message_add_the_new_ui_elements branch 3 times, most recently from a3a769e to 92e3971 Compare December 4, 2025 11:05
<activity
android:name="com.duckduckgo.remote.messaging.impl.ui.ModalSurfaceActivity"
android:exported="false" />

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the new activity that will show the remote message on a "MODAL" surface (full screen).

override fun onResume(owner: LifecycleOwner) {
super.onResume(owner)
appCoroutineScope.launch {
evaluate()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This evaluates if a remote message needs to be shown full screen. This is not the full implementation, more conditions will be added later. So far this is guarded by a FF and a check that onboarding is completed is made.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for the comment here.
Is your plan to keep this class in :app module? because dependencies you need or your original plan?
Something to consider is if we want this to be RMF focused or a generic evaluator for fullscreen prompts that coordinates the priorities. Depending on that, it's ok to keep it here, or I would consider moving it into rmf module.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The main issue why this is still in the app module is detecting that onboarding has completed.

logcat(INFO) { "RMF: messages parsed $messages ${Thread.currentThread().name}" }

val updatedMessages: List<RemoteMessage> =
if (!remoteMessagingFeatureToggles.self().isEnabled() || !remoteMessagingFeatureToggles.remoteMessageModalSurface().isEnabled()) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed the TODO and added the FF. If the FF is not enabled, we keep only the remote messages that should be shown on the new tab page.

is PlayStore -> Command.LaunchPlayStore(this.value)
is Url -> Command.SubmitUrl(this.value)
is UrlInContext -> Command.SubmitUrlInContext(this.value)
is UrlInContext -> Command.SubmitUrl(this.value)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

UrlInContext is handled in the same way as Url for remote messages that are not shown on MODAL surface.

import javax.inject.Inject

@InjectWith(ViewScope::class)
class CardsListRemoteMessageView @JvmOverloads constructor(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a custom view that displays a cards list.

class RealCommandActionMapper @Inject constructor(
private val surveyParameterManager: SurveyParameterManager,
) : CommandActionMapper {
override suspend fun asCommand(action: Action): Command {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not a fan of this approach as we already have a duplication of this. Will leave it as is for now, but think of a different approach in a new PR.

Copy link
Contributor

Choose a reason for hiding this comment

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

Agree 👍


@InjectWith(ActivityScope::class)
@ContributeToActivityStarter(ModalSurfaceActivityFromMessageId::class)
class ModalSurfaceActivity : DuckDuckGoActivity(), CardsListRemoteMessageView.CardsListRemoteMessageListener {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the activity showing a remote message with MODAL surface.

private fun render(viewState: ModalSurfaceViewModel.ViewState?) {
if (viewState == null) return

if (viewState.showCardsListView) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

For now this only shows a cards list view as this PR is to verify this UI.
However, in the future this will be generic and show any remote message that has the MODAL surface.

@anikiki anikiki marked this pull request as ready for review December 4, 2025 12:24
@anikiki anikiki requested a review from cmonfortep December 4, 2025 12:24
private fun render(viewState: CardsListRemoteMessageViewModel.ViewState?) {
viewState?.cardsLists?.let {
modalSurfaceAdapter.submitList(it.listItems)
binding.headerImage.setImageResource(it.placeholder.drawable(true))
Copy link

Choose a reason for hiding this comment

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

Bug: Hardcoded light mode ignores dark theme for placeholders

The Placeholder.drawable() function is called with a hardcoded true (light mode) instead of dynamically checking the actual theme. The drawable() function uses the isLightModeEnabled parameter to return different drawables for placeholders like VISUAL_DESIGN_UPDATE (light vs dark artwork). Despite the PR showing both light and dark mode UI screenshots, the appBuildConfig is injected but appTheme.isLightModeEnabled() is not used, causing dark mode users to always see light mode images.


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

Additional Locations (1)

Fix in Cursor Fix in Web

)
intent?.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
applicationContext.startActivity(intent)
}
Copy link

Choose a reason for hiding this comment

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

Bug: Modal activity shows blank screen for unsupported message types

There's a mismatch between the evaluator and the modal activity logic. The RemoteMessageModalSurfaceEvaluator launches ModalSurfaceActivity for any message with Surface.MODAL, regardless of its messageType. However, ModalSurfaceViewModel.onInitialise() only sets the view state when messageType == CARDS_LIST. For any other message type (e.g., SMALL, MEDIUM), the view state remains null, rendering a blank activity with no content and no way to dismiss except pressing the back button. The evaluator needs to also check that the message type is supported before launching the activity.


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

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor

@cmonfortep cmonfortep left a comment

Choose a reason for hiding this comment

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

Sending Review, moving into Testing.

android:exported="false"
android:label="@string/appName" />

<activity
Copy link
Contributor

Choose a reason for hiding this comment

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

We can declare this activity inside remote messaging impl module manifest.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch 👍

val messageId = activityParams?.messageId ?: return
val messageType = activityParams.messageType

if (messageType == Content.MessageType.CARDS_LIST) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Since we have some logic around valid message types, I'd include some logic to handle an invalid type (not necessary here). Ideally, we would close the activity if type invalid, crash with illegalState, or handling before opening the activity.

val viewState: Flow<ViewState?> = _viewState.asStateFlow()

fun onInitialise(activityParams: ModalSurfaceActivityFromMessageId?) {
val messageId = activityParams?.messageId ?: return
Copy link
Contributor

Choose a reason for hiding this comment

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

Kind of related to valid message types, I'd be more explicit with the expectations here. If this is null, activity won't render anything, which will be unexpected. So I would handle that state somehow or crash it if illegal state. Follow your preference.

lifecycleScope.launch {
viewModel.viewState
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.collectLatest { render(it) }
Copy link
Contributor

Choose a reason for hiding this comment

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

Just checking if this is intended (usage of collectLatest) which on any new emission, will cancel the previous suspend action (if there's a suspend point), and apply latest state.

lifecycleScope.launch {
viewModel.commands
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.collectLatest { processCommand(it) }
Copy link
Contributor

Choose a reason for hiding this comment

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

Ditto. just checking if is collectLatest intended here? right now doesn't seem problematic, unsure if we will have more commands later which can change the assumption.


findViewTreeLifecycleOwner()?.lifecycle?.addObserver(viewModel)

val coroutineScope = findViewTreeLifecycleOwner()?.lifecycleScope
Copy link
Contributor

Choose a reason for hiding this comment

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

if this returns null best to return or similar, just based on the !! later.


viewModelScope.launch(dispatchers.io()) {
val message = remoteMessagingRepository.getMessageById(messageId)
val cardsList = message?.content as? Content.CardsList
Copy link
Contributor

Choose a reason for hiding this comment

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

should handle the case where this is null and we are here?

class RealCommandActionMapper @Inject constructor(
private val surveyParameterManager: SurveyParameterManager,
) : CommandActionMapper {
override suspend fun asCommand(action: Action): Command {
Copy link
Contributor

Choose a reason for hiding this comment

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

Agree 👍

android:id="@+id/actionButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
Copy link
Contributor

Choose a reason for hiding this comment

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

NIT: use dimen references

xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
Copy link
Contributor

Choose a reason for hiding this comment

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

NIT: use dimen references (keylines)

Copy link
Contributor

@cmonfortep cmonfortep left a comment

Choose a reason for hiding this comment

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

Overall testing works fine, but found 2 issues:

  • once, after opening the app, it showed the what's new, and later it showed the new input widget screen on top of it. Pressing back on the input widget took me to the what's new. I think there's a race condition here between what's new (activity) vs widget input (activity)

  • What's new is rendered first as blank, and items take 1 second to appear. So loading is pretty async and not great.

@anikiki anikiki force-pushed the feature/ana/android_whats_new_promo_message_add_the_new_ui_elements branch from ff64a45 to 1957d95 Compare December 8, 2025 23:31

intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
applicationContext.startActivity(intent)
}
Copy link

Choose a reason for hiding this comment

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

Bug: Modal message displayed repeatedly on every app resume

The RemoteMessageModalSurfaceEvaluator.onResume() launches the modal activity on every app resume when a modal message exists, but neither the evaluator nor CardsListRemoteMessageViewModel.onCloseButtonClicked() actually dismisses or marks the message as shown in the repository. The modal will display every time the user foregrounds the app until the message naturally expires. The existing RemoteMessageViewModel properly calls remoteMessagingModel.onMessageDismissed(), but the new code only sends a DismissMessage command that closes the activity without updating message state. The TODO comment on line 83 acknowledges this issue.


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

Additional Locations (1)

Fix in Cursor Fix in Web

if (messageType == Content.MessageType.CARDS_LIST) {
_viewState.value = ViewState(messageId = messageId, showCardsListView = true)
}
}
Copy link

Choose a reason for hiding this comment

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

Bug: Non-CardsList modal messages show blank activity screen

When a message with MODAL surface but a non-CARDS_LIST messageType is configured, ModalSurfaceViewModel.onInitialise does nothing after the early return check passes - the viewState remains null because only CARDS_LIST type sets it. The RemoteMessageModalSurfaceEvaluator launches the activity for any message with Surface.MODAL, and the RemoteMessagingConfigJsonMapper keeps all message types when the feature flag is enabled. This results in a blank activity screen with no way to dismiss it except pressing back. The method could send a dismiss command when an unsupported message type is encountered.


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

Fix in Cursor Fix in Web

…arity; improve intent handling in RemoteMessageModalSurfaceEvaluator.
@anikiki anikiki force-pushed the feature/ana/android_whats_new_promo_message_add_the_new_ui_elements branch from f56fe00 to a2f7db4 Compare December 9, 2025 18:05
viewModelScope.launch {
_command.send(Command.DismissMessage)
}
}
Copy link

Choose a reason for hiding this comment

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

Bug: Modal message never dismissed, reappears on every resume

The CardsListRemoteMessageViewModel sends dismiss commands to the UI but never calls remoteMessagingRepository.dismissMessage(messageId) to mark the message as dismissed in the database. Since RemoteMessageModalSurfaceEvaluator.evaluate() calls remoteMessagingRepository.message() on every onResume, the modal will keep reappearing each time the user brings the app to foreground until the message is properly dismissed. Compare with how other message views use RealRemoteMessageModel.onMessageDismissed() which calls the repository's dismiss method.


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

Additional Locations (1)

Fix in Cursor Fix in Web

conflatedCommandJob += viewModel.commands
.onEach { processCommand(it) }
.launchIn(coroutineScope!!)
}
Copy link

Choose a reason for hiding this comment

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

Bug: View initializes before messageId set, content never loads

The CardsListRemoteMessageView.onAttachedToWindow() is called during layout inflation (when setContentView runs), before the Activity has a chance to set messageId. At this point messageId is null (default), so viewModel.init(null) returns early without loading content. When the Activity later sets messageId in render() and calls show(), init() is not called again since onAttachedToWindow() only fires once. The result is the modal displays with no content - empty header, title, and cards list.


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

Additional Locations (1)

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants