From 64e95925063f7798b06d20b7fb1701e24a03ae2a Mon Sep 17 00:00:00 2001 From: t-p-white Date: Mon, 23 Jan 2023 15:15:15 +0000 Subject: [PATCH] [fenix] Bug 1809444: Add Worker to generate Notifications using Nimbus messaging (https://github.com/mozilla-mobile/fenix/pull/28605) * For 1809444: Added a MessageNotificationWorker to poll Nimbus for new messages and create a notification configured using the highest priority new message (if available). * For 1809444: Changes from PR review Co-authored-by: t-p-white Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .../main/java/org/mozilla/fenix/HomeActivity.kt | 18 +- .../fenix/gleanplumb/DefaultMessageController.kt | 18 +- .../fenix/gleanplumb/MessageNotificationWorker.kt | 125 ++++++ .../fenix/gleanplumb/NimbusMessagingController.kt | 49 +-- .../fenix/gleanplumb/NimbusMessagingStorage.kt | 2 +- .../fenix/gleanplumb/state/MessagingMiddleware.kt | 2 +- .../java/org/mozilla/fenix/home/HomeFragment.kt | 3 +- .../onboarding/DefaultBrowserNotificationWorker.kt | 25 +- .../onboarding/ReEngagementNotificationWorker.kt | 26 +- .../org/mozilla/fenix/utils/NotificationBase.kt | 35 ++ .../gleanplumb/DefaultMessageControllerTest.kt | 45 +-- .../gleanplumb/NimbusMessagingControllerTest.kt | 425 ++++++++++++--------- .../gleanplumb/state/MessagingMiddlewareTest.kt | 6 +- mobile/android/fenix/messaging.fml.yaml | 11 +- 14 files changed, 500 insertions(+), 290 deletions(-) create mode 100644 mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/MessageNotificationWorker.kt create mode 100644 mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/NotificationBase.kt rewrite mobile/android/fenix/app/src/test/java/org/mozilla/fenix/gleanplumb/NimbusMessagingControllerTest.kt (62%) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/HomeActivity.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/HomeActivity.kt index b5a354934b3b..a4eed14d08d4 100644 --- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/HomeActivity.kt +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/HomeActivity.kt @@ -38,7 +38,6 @@ import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.NavigationUI import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -91,9 +90,11 @@ import org.mozilla.fenix.ext.areNotificationsEnabledSafe import org.mozilla.fenix.ext.breadcrumb import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.hasTopDestination +import org.mozilla.fenix.ext.isNotificationChannelEnabled import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.setNavigationIcon import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.gleanplumb.MessageNotificationWorker import org.mozilla.fenix.home.HomeFragmentDirections import org.mozilla.fenix.home.intent.AssistIntentProcessor import org.mozilla.fenix.home.intent.CrashReporterIntentProcessor @@ -111,6 +112,7 @@ import org.mozilla.fenix.library.recentlyclosed.RecentlyClosedFragmentDirections import org.mozilla.fenix.nimbus.FxNimbus import org.mozilla.fenix.onboarding.DefaultBrowserNotificationWorker import org.mozilla.fenix.onboarding.FenixOnboarding +import org.mozilla.fenix.onboarding.MARKETING_CHANNEL_ID import org.mozilla.fenix.onboarding.ReEngagementNotificationWorker import org.mozilla.fenix.onboarding.ensureMarketingChannelExists import org.mozilla.fenix.perf.MarkersActivityLifecycleCallbacks @@ -152,7 +154,6 @@ import java.util.Locale * - home screen * - browser screen */ -@OptIn(ExperimentalCoroutinesApi::class) @SuppressWarnings("TooManyFunctions", "LargeClass", "LongParameterList", "LongMethod") open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { // DO NOT MOVE ANYTHING ABOVE THIS, GETTING INIT TIME IS CRITICAL @@ -423,8 +424,15 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { // that we should not rely on the application being killed between user sessions. components.appStore.dispatch(AppAction.ResumedMetricsAction) - DefaultBrowserNotificationWorker.setDefaultBrowserNotificationIfNeeded(applicationContext) - ReEngagementNotificationWorker.setReEngagementNotificationIfNeeded(applicationContext) + with(applicationContext) { + // Only set up Workers if notifications are enabled + val notificationManagerCompat = NotificationManagerCompat.from(this) + if (notificationManagerCompat.isNotificationChannelEnabled(MARKETING_CHANNEL_ID)) { + DefaultBrowserNotificationWorker.setDefaultBrowserNotificationIfNeeded(this) + ReEngagementNotificationWorker.setReEngagementNotificationIfNeeded(this) + MessageNotificationWorker.setMessageNotificationWorker(this) + } + } } // This was done in order to refresh search engines when app is running in background @@ -1143,8 +1151,6 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { const val OPEN_TO_BROWSER_AND_LOAD = "open_to_browser_and_load" const val OPEN_TO_SEARCH = "open_to_search" const val PRIVATE_BROWSING_MODE = "private_browsing_mode" - const val EXTRA_DELETE_PRIVATE_TABS = "notification_delete_and_open" - const val EXTRA_OPENED_FROM_NOTIFICATION = "notification_open" const val START_IN_RECENTS_SCREEN = "start_in_recents_screen" // PWA must have been used within last 30 days to be considered "recently used" for the diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/DefaultMessageController.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/DefaultMessageController.kt index f3ee28d82b1e..ebdef0aad530 100644 --- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/DefaultMessageController.kt +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/DefaultMessageController.kt @@ -5,8 +5,6 @@ package org.mozilla.fenix.gleanplumb import android.content.Intent -import androidx.annotation.VisibleForTesting -import androidx.core.net.toUri import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.MessageClicked @@ -17,26 +15,18 @@ import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.MessageDi */ class DefaultMessageController( private val appStore: AppStore, - messagingStorage: NimbusMessagingStorage, - private val messagingController: NimbusMessagingController = NimbusMessagingController(messagingStorage), + private val messagingController: NimbusMessagingController, private val homeActivity: HomeActivity, ) : MessageController { override fun onMessagePressed(message: Message) { - val action = messagingController.processMessageAction(message) - handleAction(action) + val actionUri = messagingController.processMessageActionToUri(message) + homeActivity.processIntent(Intent(Intent.ACTION_VIEW, actionUri)) + appStore.dispatch(MessageClicked(message)) } override fun onMessageDismissed(message: Message) { appStore.dispatch(MessageDismissed(message)) } - - @VisibleForTesting - internal fun handleAction(action: String): Intent { - val intent = Intent(Intent.ACTION_VIEW, action.toUri()) - homeActivity.processIntent(intent) - - return intent - } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/MessageNotificationWorker.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/MessageNotificationWorker.kt new file mode 100644 index 000000000000..516466d69261 --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/MessageNotificationWorker.kt @@ -0,0 +1,125 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.gleanplumb + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationManagerCompat +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import mozilla.components.support.base.ids.SharedIdsHelper +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.nimbus.FxNimbus +import org.mozilla.fenix.nimbus.MessageSurfaceId +import org.mozilla.fenix.onboarding.MARKETING_CHANNEL_ID +import org.mozilla.fenix.utils.IntentUtils +import org.mozilla.fenix.utils.createBaseNotification +import java.util.concurrent.TimeUnit + +/** + * Background [Worker] that polls Nimbus for available [Message]s at a given interval. + * A [Notification] will be created using the configuration of the next highest priority [Message] + * if it has not already been displayed. + */ +class MessageNotificationWorker( + context: Context, + workerParameters: WorkerParameters, +) : Worker(context, workerParameters) { + + @OptIn(DelicateCoroutinesApi::class) // GlobalScope usage + override fun doWork(): Result { + GlobalScope.launch(Dispatchers.IO) { + val context = applicationContext + val messagingStorage = context.components.analytics.messagingStorage + val messages = messagingStorage.getMessages() + val nextMessage = + messagingStorage.getNextMessage(MessageSurfaceId.NOTIFICATION, messages) + + if (nextMessage == null || !nextMessage.shouldDisplayMessage()) { + return@launch + } + + val nimbusMessagingController = NimbusMessagingController(messagingStorage) + + // Update message as displayed. + val messageAsDisplayed = + nimbusMessagingController.updateMessageAsDisplayed(nextMessage) + nimbusMessagingController.onMessageDisplayed(messageAsDisplayed) + + // Generate the processed Message action + val processedAction = nimbusMessagingController.processMessageActionToUri(nextMessage) + val actionIntent = Intent(Intent.ACTION_VIEW, processedAction) + + NotificationManagerCompat.from(context).notify( + MESSAGE_TAG, + SharedIdsHelper.getNextIdForTag(context, nextMessage.id), + buildNotification(nextMessage, actionIntent), + ) + } + + return Result.success() + } + + private fun Message.shouldDisplayMessage() = metadata.displayCount == 0 + + private fun buildNotification(message: Message, intent: Intent): Notification { + with(applicationContext) { + val pendingIntent = PendingIntent.getActivity( + this, + SharedIdsHelper.getNextIdForTag(this, NOTIFICATION_PENDING_INTENT_TAG), + intent, + IntentUtils.defaultIntentPendingFlags, + ) + + return createBaseNotification( + this, + MARKETING_CHANNEL_ID, + message.data.title, + message.data.text, + pendingIntent, + ) + } + } + + companion object { + private const val NOTIFICATION_PENDING_INTENT_TAG = "org.mozilla.fenix.message" + private const val MESSAGE_TAG = "org.mozilla.fenix.message.tag" + private const val MESSAGE_WORK_NAME = "org.mozilla.fenix.message.work" + + /** + * Initialize the [Worker] to begin polling Nimbus. + */ + fun setMessageNotificationWorker(context: Context) { + val featureConfig = FxNimbus.features.messaging.value() + val notificationConfig = featureConfig.notificationConfig + val pollingInterval = notificationConfig.pollingInterval.toLong() + + val messageWorkRequest = PeriodicWorkRequest.Builder( + MessageNotificationWorker::class.java, + pollingInterval, + TimeUnit.MINUTES, + ) // Only start polling after the given interval + .setInitialDelay(pollingInterval, TimeUnit.MINUTES) + .build() + + val instanceWorkManager = WorkManager.getInstance(context) + instanceWorkManager.enqueueUniquePeriodicWork( + MESSAGE_WORK_NAME, + // We want to keep any existing scheduled work + ExistingPeriodicWorkPolicy.KEEP, + messageWorkRequest, + ) + } + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/NimbusMessagingController.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/NimbusMessagingController.kt index 4cb45d232533..5e9baed7d77d 100644 --- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/NimbusMessagingController.kt +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/NimbusMessagingController.kt @@ -5,12 +5,12 @@ package org.mozilla.fenix.gleanplumb import android.net.Uri +import androidx.core.net.toUri import org.mozilla.fenix.BuildConfig import org.mozilla.fenix.GleanMetrics.Messaging /** - * Class extracted from [MessagingMiddleware] to do the bookkeeping for message actions, in terms - * of Glean messages and the messaging store. + * Bookkeeping for message actions in terms of Glean messages and the messaging store. */ class NimbusMessagingController( private val messagingStorage: NimbusMessagingStorage, @@ -19,15 +19,15 @@ class NimbusMessagingController( /** * Called when a message is just about to be shown to the user. * - * This updates the display count, and expires the message if necessary. + * Update the display count and time shown metadata for the given [message]. */ - fun processDisplayedMessage(oldMessage: Message): Message { - val newMetadata = oldMessage.metadata.copy( - displayCount = oldMessage.metadata.displayCount + 1, + fun updateMessageAsDisplayed(message: Message): Message { + val updatedMetadata = message.metadata.copy( + displayCount = message.metadata.displayCount + 1, lastTimeShown = now(), ) - return oldMessage.copy( - metadata = newMetadata, + return message.copy( + metadata = updatedMetadata, ) } @@ -59,26 +59,20 @@ class NimbusMessagingController( * and any `uuid` needs to be recorded in the Glean event. * * We call this `process` as it has a side effect of logging a Glean event while it - * creates a URI string for + * creates a URI string for the message action. */ - fun processMessageAction(message: Message): String { - val (uuid, action) = messagingStorage.getMessageAction(message.action) + fun processMessageActionToUri(message: Message): Uri { + val (uuid, action) = messagingStorage.generateUuidAndFormatAction(message.action) sendClickedMessageTelemetry(message.id, uuid) - return if (action.startsWith("http", ignoreCase = true)) { - "${BuildConfig.DEEP_LINK_SCHEME}://open?url=${Uri.encode(action)}" - } else if (action.startsWith("://")) { - "${BuildConfig.DEEP_LINK_SCHEME}$action" - } else { - action - } + return action.toDeepLinkSchemeUri() } /** * Called once the user has clicked on a message. * * This records that the message has been clicked on, but does not record a - * glean event. That should be done via [processMessageAction]. + * glean event. That should be done via [processMessageActionToUri]. */ suspend fun onMessageClicked(message: Message) { val updatedMetadata = message.metadata.copy(pressed = true) @@ -99,10 +93,19 @@ class NimbusMessagingController( private fun sendClickedMessageTelemetry(messageId: String, uuid: String?) { Messaging.messageClicked.record( - Messaging.MessageClickedExtra( - messageKey = messageId, - actionUuid = uuid, - ), + Messaging.MessageClickedExtra(messageKey = messageId, actionUuid = uuid), ) } } + +private fun String.toDeepLinkSchemeUri(): Uri { + val actionWithDeepLinkScheme = if (startsWith("http", ignoreCase = true)) { + "${BuildConfig.DEEP_LINK_SCHEME}://open?url=${Uri.encode(this)}" + } else if (startsWith("://")) { + "${BuildConfig.DEEP_LINK_SCHEME}$this" + } else { + this + } + + return actionWithDeepLinkScheme.toUri() +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/NimbusMessagingStorage.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/NimbusMessagingStorage.kt index bec0a09d65da..5681d6fdf169 100644 --- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/NimbusMessagingStorage.kt +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/NimbusMessagingStorage.kt @@ -133,7 +133,7 @@ class NimbusMessagingStorage( * The fully resolved (with all substitutions) action is returned as the second value * of the [Pair]. */ - fun getMessageAction(action: String): Pair { + fun generateUuidAndFormatAction(action: String): Pair { val helper = gleanPlumb.createMessageHelper(customAttributes) val uuid = helper.getUuid(action) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/state/MessagingMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/state/MessagingMiddleware.kt index ff98ead075dc..f492cb09fabc 100644 --- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/state/MessagingMiddleware.kt +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/gleanplumb/state/MessagingMiddleware.kt @@ -73,7 +73,7 @@ class MessagingMiddleware( oldMessage: Message, context: AppStoreMiddlewareContext, ) { - val newMessage = controller.processDisplayedMessage(oldMessage) + val newMessage = controller.updateMessageAsDisplayed(oldMessage) val newMessages = if (!newMessage.isExpired) { updateMessage(context, oldMessage, newMessage) } else { diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt index ae2218f7cdda..f9f9b87c4701 100644 --- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt @@ -103,6 +103,7 @@ import org.mozilla.fenix.ext.scaleToBottomOfView import org.mozilla.fenix.ext.settings import org.mozilla.fenix.gleanplumb.DefaultMessageController import org.mozilla.fenix.gleanplumb.MessagingFeature +import org.mozilla.fenix.gleanplumb.NimbusMessagingController import org.mozilla.fenix.home.mozonline.showPrivacyPopWindow import org.mozilla.fenix.home.pocket.DefaultPocketStoriesController import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory @@ -372,7 +373,7 @@ class HomeFragment : Fragment() { engine = components.core.engine, messageController = DefaultMessageController( appStore = components.appStore, - messagingStorage = components.analytics.messagingStorage, + messagingController = NimbusMessagingController(components.analytics.messagingStorage), homeActivity = activity, ), store = store, diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/onboarding/DefaultBrowserNotificationWorker.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/onboarding/DefaultBrowserNotificationWorker.kt index 5cc3a105f6d8..d76c2168fe61 100644 --- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/onboarding/DefaultBrowserNotificationWorker.kt +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/onboarding/DefaultBrowserNotificationWorker.kt @@ -8,9 +8,7 @@ import android.app.Notification import android.app.PendingIntent import android.content.Context import android.content.Intent -import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import androidx.core.content.ContextCompat import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager @@ -24,6 +22,7 @@ import org.mozilla.fenix.R import org.mozilla.fenix.ext.settings import org.mozilla.fenix.utils.IntentUtils import org.mozilla.fenix.utils.Settings +import org.mozilla.fenix.utils.createBaseNotification import java.util.concurrent.TimeUnit class DefaultBrowserNotificationWorker( @@ -65,21 +64,13 @@ class DefaultBrowserNotificationWorker( with(applicationContext) { val appName = getString(R.string.app_name) - return NotificationCompat.Builder(this, channelId) - .setSmallIcon(R.drawable.ic_status_logo) - .setContentTitle( - applicationContext.getString(R.string.notification_default_browser_title, appName), - ) - .setContentText( - applicationContext.getString(R.string.notification_default_browser_text, appName), - ) - .setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL) - .setColor(ContextCompat.getColor(this, R.color.primary_text_light_theme)) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setShowWhen(false) - .setContentIntent(pendingIntent) - .setAutoCancel(true) - .build() + return createBaseNotification( + this, + channelId, + getString(R.string.notification_default_browser_title, appName), + getString(R.string.notification_default_browser_text, appName), + pendingIntent, + ) } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/onboarding/ReEngagementNotificationWorker.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/onboarding/ReEngagementNotificationWorker.kt index c4ec1421ed7c..c30a583e1e4e 100644 --- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/onboarding/ReEngagementNotificationWorker.kt +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/onboarding/ReEngagementNotificationWorker.kt @@ -9,9 +9,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import androidx.annotation.VisibleForTesting -import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import androidx.core.content.ContextCompat import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager @@ -26,6 +24,7 @@ import org.mozilla.fenix.ext.settings import org.mozilla.fenix.nimbus.FxNimbus import org.mozilla.fenix.utils.IntentUtils import org.mozilla.fenix.utils.Settings +import org.mozilla.fenix.utils.createBaseNotification import java.util.concurrent.TimeUnit /** @@ -79,22 +78,13 @@ class ReEngagementNotificationWorker( ) with(applicationContext) { - val appName = getString(R.string.app_name) - return NotificationCompat.Builder(this, channelId) - .setSmallIcon(R.drawable.ic_status_logo) - .setContentTitle( - applicationContext.getString(R.string.notification_re_engagement_title), - ) - .setContentText( - applicationContext.getString(R.string.notification_re_engagement_text, appName), - ) - .setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL) - .setColor(ContextCompat.getColor(this, R.color.primary_text_light_theme)) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setShowWhen(false) - .setContentIntent(pendingIntent) - .setAutoCancel(true) - .build() + return createBaseNotification( + this, + channelId, + getString(R.string.notification_re_engagement_title), + getString(R.string.notification_re_engagement_text, getString(R.string.app_name)), + pendingIntent, + ) } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/NotificationBase.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/NotificationBase.kt new file mode 100644 index 000000000000..c2d2a39c86b4 --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/NotificationBase.kt @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.utils + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import org.mozilla.fenix.R + +/** + * Create a [Notification] with default behaviour and styling. + */ +fun createBaseNotification( + context: Context, + channelId: String, + title: String?, + text: String, + pendingIntent: PendingIntent, +): Notification { + return NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_status_logo) + .setContentTitle(title) + .setContentText(text) + .setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL) + .setColor(ContextCompat.getColor(context, R.color.primary_text_light_theme)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setShowWhen(false) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/gleanplumb/DefaultMessageControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/gleanplumb/DefaultMessageControllerTest.kt index 7b237c173aaa..ff44605fee44 100644 --- a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/gleanplumb/DefaultMessageControllerTest.kt +++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/gleanplumb/DefaultMessageControllerTest.kt @@ -4,12 +4,12 @@ package org.mozilla.fenix.gleanplumb +import androidx.core.net.toUri +import io.mockk.every import io.mockk.mockk -import io.mockk.spyk import io.mockk.verify import mozilla.components.support.test.robolectric.testContext import mozilla.telemetry.glean.testing.GleanTestRule -import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test @@ -27,51 +27,38 @@ class DefaultMessageControllerTest { @get:Rule val gleanTestRule = GleanTestRule(testContext) - private val activity: HomeActivity = mockk(relaxed = true) - private val storageNimbus: NimbusMessagingStorage = mockk(relaxed = true) - private val controllerNimbus: NimbusMessagingController = mockk(relaxed = true) - private lateinit var controller: DefaultMessageController + private val homeActivity: HomeActivity = mockk(relaxed = true) + private val messagingController: NimbusMessagingController = mockk(relaxed = true) + private lateinit var defaultMessageController: DefaultMessageController private val appStore: AppStore = mockk(relaxed = true) @Before fun setup() { - controller = DefaultMessageController( - messagingStorage = storageNimbus, - messagingController = controllerNimbus, + defaultMessageController = DefaultMessageController( + messagingController = messagingController, appStore = appStore, - homeActivity = activity, + homeActivity = homeActivity, ) } @Test - fun `WHEN calling onMessagePressed THEN update the app store and handle the action`() { - val customController = spyk(controller) + fun `WHEN calling onMessagePressed THEN process the action intent and update the app store`() { val message = mockMessage() + val uri = "action".toUri() + every { messagingController.processMessageActionToUri(message) }.returns(uri) - customController.onMessagePressed(message) + defaultMessageController.onMessagePressed(message) - verify { controllerNimbus.processMessageAction(message) } - verify { customController.handleAction(any()) } + verify { messagingController.processMessageActionToUri(message) } + verify { homeActivity.processIntent(any()) } verify { appStore.dispatch(MessageClicked(message)) } } @Test - fun `GIVEN an URL WHEN calling handleAction THEN process the intent with an open uri`() { - val intent = controller.handleAction("http://mozilla.org") - - verify { activity.processIntent(any()) } - - assertEquals( - "http://mozilla.org", - intent.data.toString(), - ) - } - - @Test - fun `WHEN calling onMessageDismissed THEN report to the messageManager`() { + fun `WHEN calling onMessageDismissed THEN update the app store`() { val message = mockMessage() - controller.onMessageDismissed(message) + defaultMessageController.onMessageDismissed(message) verify { appStore.dispatch(AppAction.MessagingAction.MessageDismissed(message)) } } diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/gleanplumb/NimbusMessagingControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/gleanplumb/NimbusMessagingControllerTest.kt dissimilarity index 62% index 06a463ad4cfd..9f981d0c931f 100644 --- a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/gleanplumb/NimbusMessagingControllerTest.kt +++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/gleanplumb/NimbusMessagingControllerTest.kt @@ -1,175 +1,250 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.gleanplumb - -import android.net.Uri -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import mozilla.components.support.test.robolectric.testContext -import mozilla.components.support.test.rule.MainCoroutineRule -import mozilla.telemetry.glean.testing.GleanTestRule -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.mozilla.experiments.nimbus.NullVariables -import org.mozilla.fenix.BuildConfig -import org.mozilla.fenix.GleanMetrics.Messaging -import org.mozilla.fenix.helpers.FenixRobolectricTestRunner -import org.mozilla.fenix.nimbus.MessageData -import org.mozilla.fenix.nimbus.StyleData -import java.util.* - -@RunWith(FenixRobolectricTestRunner::class) -class NimbusMessagingControllerTest { - private val storage: NimbusMessagingStorage = mockk(relaxed = true) - - @get:Rule - val gleanTestRule = GleanTestRule(testContext) - - private val coroutinesTestRule = MainCoroutineRule() - private val coroutineScope = coroutinesTestRule.scope - - private val controller = NimbusMessagingController(storage) { 0L } - - @Before - fun setup() { - NullVariables.instance.setContext(testContext) - } - - @Test - fun `WHEN calling onMessageDismissed THEN record a messageDismissed event and updates metadata`() = coroutineScope.runTest { - val message = createMessage("id-1") - assertNull(Messaging.messageDismissed.testGetValue()) - - controller.onMessageDismissed(message) - - assertNotNull(Messaging.messageDismissed.testGetValue()) - val event = Messaging.messageDismissed.testGetValue()!! - assertEquals(1, event.size) - assertEquals(message.id, event.single().extra!!["message_key"]) - - coVerify { storage.updateMetadata(message.metadata.copy(dismissed = true)) } - } - - @Test - fun `WHEN calling processDisplayedMessage THEN record a messageDisplayed event and updates metadata`() = coroutineScope.runTest { - val message = createMessage("id-1") - assertNull(Messaging.messageShown.testGetValue()) - assertEquals(0, message.metadata.displayCount) - - val updated = controller.processDisplayedMessage(message) - controller.onMessageDisplayed(updated) - - assertNotNull(Messaging.messageShown.testGetValue()) - val event = Messaging.messageShown.testGetValue()!! - assertEquals(1, event.size) - assertEquals(message.id, event.single().extra!!["message_key"]) - - coVerify { storage.updateMetadata(message.metadata.copy(displayCount = 1)) } - assertEquals(1, updated.metadata.displayCount) - } - - @Test - fun `WHEN calling processDisplayedMessage on an expiring message THEN record a messageExpired event`() = coroutineScope.runTest { - val message = createMessage("id-1", style = StyleData(maxDisplayCount = 1)) - assertNull(Messaging.messageShown.testGetValue()) - assertEquals(0, message.metadata.displayCount) - - val updated = controller.processDisplayedMessage(message) - controller.onMessageDisplayed(updated) - - assertNotNull(Messaging.messageShown.testGetValue()) - val shownEvent = Messaging.messageShown.testGetValue()!! - assertEquals(1, shownEvent.size) - assertEquals(message.id, shownEvent.single().extra!!["message_key"]) - - coVerify { storage.updateMetadata(message.metadata.copy(displayCount = 1)) } - assertEquals(1, updated.metadata.displayCount) - - assertNotNull(Messaging.messageExpired.testGetValue()) - val expiredEvent = Messaging.messageExpired.testGetValue()!! - assertEquals(1, expiredEvent.size) - assertEquals(message.id, expiredEvent.single().extra!!["message_key"]) - } - - @Test - fun `GIVEN a URL WHEN calling createMessageAction THEN treat it as an open uri deeplink`() { - val message = createMessage("id-1", action = "http://mozilla.org") - every { storage.getMessageAction(any()) } returns Pair(null, message.action) - - val uri = controller.processMessageAction(message) - - val encodedUrl = Uri.encode("http://mozilla.org") - assertEquals( - "${BuildConfig.DEEP_LINK_SCHEME}://open?url=$encodedUrl", - uri, - ) - } - - @Test - fun `GIVEN an deeplink WHEN calling createMessageAction THEN treat it as a deeplink`() { - val message = createMessage("id-1", action = "://a-deep-link") - every { storage.getMessageAction(any()) } returns Pair(null, message.action) - - val uri = controller.processMessageAction(message) - - assertEquals( - "${BuildConfig.DEEP_LINK_SCHEME}://a-deep-link", - uri, - ) - } - - @Test - fun `GIVEN a URL WHEN calling createMessageAction THEN record a messageClicked event`() { - val message = createMessage("id-1", action = "http://mozilla.org") - every { storage.getMessageAction(any()) } returns Pair(null, message.action) - - controller.processMessageAction(message) - - val clickedEvents = Messaging.messageClicked.testGetValue() - assertNotNull(clickedEvents) - val clickedEvent = clickedEvents!!.single() - - assertEquals(message.id, clickedEvent.extra!!["message_key"]) - assertEquals(null, clickedEvent.extra!!["action_uuid"]) - } - - @Test - fun `GIVEN a URL with a {uuid} WHEN calling createMessageAction THEN record a messageClicked event with a uuid`() { - val message = createMessage("id-1", action = "http://mozilla.org?uuid={uuid}") - val uuid = UUID.randomUUID().toString() - every { storage.getMessageAction(any()) } returns Pair(uuid, message.action) - - controller.processMessageAction(message) - - val clickedEvents = Messaging.messageClicked.testGetValue() - assertNotNull(clickedEvents) - val clickedEvent = clickedEvents!!.single() - - assertEquals(message.id, clickedEvent.extra!!["message_key"]) - assertEquals(uuid, clickedEvent.extra!!["action_uuid"]) - } - - private fun createMessage( - id: String, - messageData: MessageData = MessageData(), - action: String = messageData.action, - style: StyleData = StyleData(), - ): Message = - Message( - id, - data = messageData, - style = style, - metadata = Message.Metadata(id), - triggers = emptyList(), - action = action, - ) -} +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.gleanplumb + +import android.net.Uri +import androidx.core.net.toUri +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.telemetry.glean.testing.GleanTestRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.experiments.nimbus.NullVariables +import org.mozilla.fenix.BuildConfig +import org.mozilla.fenix.GleanMetrics.Messaging +import org.mozilla.fenix.helpers.FenixRobolectricTestRunner +import org.mozilla.fenix.nimbus.MessageData +import org.mozilla.fenix.nimbus.StyleData +import java.util.UUID + +private const val MOCK_TIME_MILLIS = 1000L + +@RunWith(FenixRobolectricTestRunner::class) +class NimbusMessagingControllerTest { + private val storage: NimbusMessagingStorage = mockk(relaxed = true) + + @get:Rule + val gleanTestRule = GleanTestRule(testContext) + + private val coroutinesTestRule = MainCoroutineRule() + private val coroutineScope = coroutinesTestRule.scope + + private val controller = NimbusMessagingController(storage) { MOCK_TIME_MILLIS } + + @Before + fun setup() { + NullVariables.instance.setContext(testContext) + } + + @Test + fun `WHEN calling updateMessageAsDisplayed message THEN message metadata is updated`() = + coroutineScope.runTest { + val message = createMessage("id-1") + assertEquals(0, message.metadata.displayCount) + assertEquals(0L, message.metadata.lastTimeShown) + + val expectedMessage = with(message) { + copy(metadata = metadata.copy(displayCount = 1, lastTimeShown = MOCK_TIME_MILLIS)) + } + + assertEquals(expectedMessage, controller.updateMessageAsDisplayed(message)) + } + + @Test + fun `GIVEN message not expired WHEN calling onMessageDisplayed THEN record a messageShown event and update storage`() = + coroutineScope.runTest { + val message = createMessage("id-1", style = StyleData(maxDisplayCount = 1)) + // Assert telemetry is initially null + assertNull(Messaging.messageShown.testGetValue()) + assertNull(Messaging.messageExpired.testGetValue()) + + controller.onMessageDisplayed(message) + + // Shown telemetry + assertNotNull(Messaging.messageShown.testGetValue()) + val shownEvent = Messaging.messageShown.testGetValue()!! + assertEquals(1, shownEvent.size) + assertEquals(message.id, shownEvent.single().extra!!["message_key"]) + + // Expired telemetry + assertNull(Messaging.messageExpired.testGetValue()) + + coVerify { storage.updateMetadata(message.metadata) } + } + + @Test + fun `GIVEN message is expired WHEN calling onMessageDisplayed THEN record messageShown, messageExpired events and update storage`() = + coroutineScope.runTest { + val message = + createMessage("id-1", style = StyleData(maxDisplayCount = 1), displayCount = 1) + // Assert telemetry is initially null + assertNull(Messaging.messageShown.testGetValue()) + assertNull(Messaging.messageExpired.testGetValue()) + + controller.onMessageDisplayed(message) + + // Shown telemetry + assertNotNull(Messaging.messageShown.testGetValue()) + val shownEvent = Messaging.messageShown.testGetValue()!! + assertEquals(1, shownEvent.size) + assertEquals(message.id, shownEvent.single().extra!!["message_key"]) + + // Expired telemetry + assertNotNull(Messaging.messageExpired.testGetValue()) + val expiredEvent = Messaging.messageExpired.testGetValue()!! + assertEquals(1, expiredEvent.size) + assertEquals(message.id, expiredEvent.single().extra!!["message_key"]) + + coVerify { storage.updateMetadata(message.metadata) } + } + + @Test + fun `WHEN calling onMessageDismissed THEN record a messageDismissed event and update metadata`() = + coroutineScope.runTest { + val message = createMessage("id-1") + assertNull(Messaging.messageDismissed.testGetValue()) + + controller.onMessageDismissed(message) + + assertNotNull(Messaging.messageDismissed.testGetValue()) + val event = Messaging.messageDismissed.testGetValue()!! + assertEquals(1, event.size) + assertEquals(message.id, event.single().extra!!["message_key"]) + + coVerify { storage.updateMetadata(message.metadata.copy(dismissed = true)) } + } + + @Test + fun `GIVEN action is URL WHEN calling processMessageActionToUri THEN record a clicked telemetry event and return an open URI`() { + val url = "http://mozilla.org" + val message = createMessage("id-1", action = url) + every { storage.generateUuidAndFormatAction(message.action) } returns Pair( + null, + message.action, + ) + // Assert telemetry is initially null + assertNull(Messaging.messageClicked.testGetValue()) + + val encodedUrl = Uri.encode(url) + val expectedUri = "${BuildConfig.DEEP_LINK_SCHEME}://open?url=$encodedUrl".toUri() + + val actualUri = controller.processMessageActionToUri(message) + + // Updated telemetry + assertNotNull(Messaging.messageClicked.testGetValue()) + val clickedEvent = Messaging.messageClicked.testGetValue()!! + assertEquals(1, clickedEvent.size) + assertEquals(message.id, clickedEvent.single().extra!!["message_key"]) + + assertEquals(expectedUri, actualUri) + } + + @Test + fun `GIVEN a URL with a {uuid} WHEN calling processMessageActionToUri THEN record a clicked telemetry event and return an open URI`() { + val url = "http://mozilla.org?uuid={uuid}" + val message = createMessage("id-1", action = url) + val uuid = UUID.randomUUID().toString() + every { storage.generateUuidAndFormatAction(any()) } returns Pair(uuid, message.action) + + // Assert telemetry is initially null + assertNull(Messaging.messageClicked.testGetValue()) + + val encodedUrl = Uri.encode(url) + val expectedUri = "${BuildConfig.DEEP_LINK_SCHEME}://open?url=$encodedUrl".toUri() + + val actualUri = controller.processMessageActionToUri(message) + + // Updated telemetry + val clickedEvents = Messaging.messageClicked.testGetValue() + assertNotNull(clickedEvents) + val clickedEvent = clickedEvents!!.single() + assertEquals(message.id, clickedEvent.extra!!["message_key"]) + assertEquals(uuid, clickedEvent.extra!!["action_uuid"]) + + assertEquals(expectedUri, actualUri) + } + + @Test + fun `GIVEN action is deeplink WHEN calling processMessageActionToUri THEN return a deeplink URI`() { + val message = createMessage("id-1", action = "://a-deep-link") + every { storage.generateUuidAndFormatAction(message.action) } returns Pair( + null, + message.action, + ) + // Assert telemetry is initially null + assertNull(Messaging.messageClicked.testGetValue()) + + val expectedUri = "${BuildConfig.DEEP_LINK_SCHEME}${message.action}".toUri() + val actualUri = controller.processMessageActionToUri(message) + + // Updated telemetry + assertNotNull(Messaging.messageClicked.testGetValue()) + val clickedEvent = Messaging.messageClicked.testGetValue()!! + assertEquals(1, clickedEvent.size) + assertEquals(message.id, clickedEvent.single().extra!!["message_key"]) + + assertEquals(expectedUri, actualUri) + } + + @Test + fun `GIVEN action unknown format WHEN calling processMessageActionToUri THEN return the action URI`() { + val message = createMessage("id-1", action = "unknown") + every { storage.generateUuidAndFormatAction(message.action) } returns Pair( + null, + message.action, + ) + // Assert telemetry is initially null + assertNull(Messaging.messageClicked.testGetValue()) + + val expectedUri = message.action.toUri() + val actualUri = controller.processMessageActionToUri(message) + + // Updated telemetry + assertNotNull(Messaging.messageClicked.testGetValue()) + val clickedEvent = Messaging.messageClicked.testGetValue()!! + assertEquals(1, clickedEvent.size) + assertEquals(message.id, clickedEvent.single().extra!!["message_key"]) + + assertEquals(expectedUri, actualUri) + } + + @Test + fun `WHEN calling onMessageClicked THEN update stored metadata for message`() = + coroutineScope.runTest { + val message = createMessage("id-1") + assertFalse(message.metadata.pressed) + + controller.onMessageClicked(message) + + val updatedMetadata = message.metadata.copy(pressed = true) + coVerify { storage.updateMetadata(updatedMetadata) } + } + + private fun createMessage( + id: String, + messageData: MessageData = MessageData(), + action: String = messageData.action, + style: StyleData = StyleData(), + displayCount: Int = 0, + ): Message = + Message( + id, + data = messageData, + style = style, + metadata = Message.Metadata(id, displayCount), + triggers = emptyList(), + action = action, + ) +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/gleanplumb/state/MessagingMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/gleanplumb/state/MessagingMiddlewareTest.kt index 45340cab7b7a..03e9fd9d2a41 100644 --- a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/gleanplumb/state/MessagingMiddlewareTest.kt +++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/gleanplumb/state/MessagingMiddlewareTest.kt @@ -169,7 +169,7 @@ class MessagingMiddlewareTest { spiedMiddleware.onMessagedDisplayed(message, middlewareContext) - verify { messagingController.processDisplayedMessage(message) } + verify { messagingController.updateMessageAsDisplayed(message) } coVerify { messagingController.onMessageDisplayed(any()) } coVerify { messagingStorage.updateMetadata(message.metadata.copy(displayCount = 1)) } verify { middlewareContext.dispatch(UpdateMessages(emptyList())) } @@ -310,7 +310,7 @@ class MessagingMiddlewareTest { spiedMiddleware.onMessagedDisplayed(oldMessage, middlewareContext) verify { spiedMiddleware.updateMessage(middlewareContext, oldMessage, updatedMessage) } - verify { messagingController.processDisplayedMessage(oldMessage) } + verify { messagingController.updateMessageAsDisplayed(oldMessage) } verify { middlewareContext.dispatch(UpdateMessages(emptyList())) } coVerify { messagingController.onMessageDisplayed(updatedMessage) } coVerify { messagingStorage.updateMetadata(updatedMessage.metadata) } @@ -342,7 +342,7 @@ class MessagingMiddlewareTest { spiedMiddleware.onMessagedDisplayed(oldMessage, middlewareContext) - verify { messagingController.processDisplayedMessage(oldMessage) } + verify { messagingController.updateMessageAsDisplayed(oldMessage) } verify { spiedMiddleware.consumeMessageToShowIfNeeded(middlewareContext, oldMessage) } verify { spiedMiddleware.removeMessage(middlewareContext, oldMessage) } verify { middlewareContext.dispatch(UpdateMessages(emptyList())) } diff --git a/mobile/android/fenix/messaging.fml.yaml b/mobile/android/fenix/messaging.fml.yaml index 9e5ba90bf607..85872ff5524a 100644 --- a/mobile/android/fenix/messaging.fml.yaml +++ b/mobile/android/fenix/messaging.fml.yaml @@ -115,6 +115,13 @@ features: max-display-count: 1 notification-config: polling-interval: 15 # minutes + messages: + test-notification: + title: "Test title" + text: "Test text" + surface: notification + trigger: [ "ALWAYS" ] + action: "MAKE_DEFAULT_BROWSER" objects: MessageData: @@ -191,9 +198,9 @@ objects: polling-interval: type: Int description: > - How often, in seconds, the notification message worker will wake up and check for new + How often, in minutes, the notification message worker will wake up and check for new messages. - default: 3600 + default: 60 enums: ControlMessageBehavior: -- 2.11.4.GIT