Commit 19d55909 authored by Roger Yang's avatar Roger Yang
Browse files

Closes #2295, #4549: Implement Web Notification Feature

parent 43be615a
......@@ -10,6 +10,7 @@ import mozilla.components.browser.engine.gecko.integration.LocaleSettingUpdater
import mozilla.components.browser.engine.gecko.mediaquery.from
import mozilla.components.browser.engine.gecko.mediaquery.toGeckoValue
import mozilla.components.browser.engine.gecko.webextension.GeckoWebExtension
import mozilla.components.browser.engine.gecko.webnotifications.GeckoWebNotificationDelegate
import mozilla.components.concept.engine.Engine
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.EngineSession.TrackingProtectionPolicy
......@@ -25,6 +26,7 @@ import mozilla.components.concept.engine.mediaquery.PreferredColorScheme
import mozilla.components.concept.engine.utils.EngineVersion
import mozilla.components.concept.engine.webextension.WebExtension
import mozilla.components.concept.engine.webextension.WebExtensionDelegate
import mozilla.components.concept.engine.webnotifications.WebNotificationDelegate
import org.json.JSONObject
import org.mozilla.geckoview.ContentBlocking
import org.mozilla.geckoview.ContentBlockingController
......@@ -173,6 +175,15 @@ class GeckoEngine(
runtime.webExtensionController.tabDelegate = tabsDelegate
}
/**
* See [Engine.registerWebNotificationDelegate].
*/
override fun registerWebNotificationDelegate(
webNotificationDelegate: WebNotificationDelegate
) {
runtime.webNotificationDelegate = GeckoWebNotificationDelegate(webNotificationDelegate)
}
/**
* See [Engine.clearData].
*/
......
/* 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 mozilla.components.browser.engine.gecko.webnotifications
import mozilla.components.concept.engine.webnotifications.WebNotification
import mozilla.components.concept.engine.webnotifications.WebNotificationDelegate
import org.mozilla.geckoview.WebNotification as GeckoViewWebNotification
import org.mozilla.geckoview.WebNotificationDelegate as GeckoViewWebNotificationDelegate
internal class GeckoWebNotificationDelegate(
private val webNotificationDelegate: WebNotificationDelegate
) : GeckoViewWebNotificationDelegate {
override fun onShowNotification(webNotification: GeckoViewWebNotification) {
webNotificationDelegate.onShowNotification(webNotification.toWebNotification())
}
override fun onCloseNotification(webNotification: GeckoViewWebNotification) {
webNotificationDelegate.onCloseNotification(webNotification.toWebNotification())
}
private fun GeckoViewWebNotification.toWebNotification(): WebNotification {
return WebNotification(title, tag, text, imageUrl, textDirection, lang, requireInteraction)
}
}
/* 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 mozilla.components.browser.engine.gecko.webnotifications
import androidx.test.ext.junit.runners.AndroidJUnit4
import mozilla.components.concept.engine.webnotifications.WebNotificationDelegate
import mozilla.components.support.test.any
import mozilla.components.support.test.mock
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.doNothing
import java.lang.IllegalStateException
import org.mozilla.geckoview.WebNotification as GeckoViewWebNotification
@RunWith(AndroidJUnit4::class)
class GeckoWebNotificationDelegateTest {
@Test
fun `register background message handler`() {
val webNotificationDelegate: WebNotificationDelegate = mock()
val geckoViewWebNotification: GeckoViewWebNotification = mock()
val geckoWebNotificationDelegate = GeckoWebNotificationDelegate(webNotificationDelegate)
var message: String? = null
doNothing().`when`(webNotificationDelegate).onShowNotification(any())
try {
geckoWebNotificationDelegate.onShowNotification(geckoViewWebNotification)
} catch (e: IllegalStateException) {
message = e.localizedMessage
}
assertEquals(message, "tag must not be null")
message = null
doNothing().`when`(webNotificationDelegate).onCloseNotification(any())
try {
geckoWebNotificationDelegate.onCloseNotification(geckoViewWebNotification)
} catch (e: IllegalStateException) {
message = e.localizedMessage
}
assertEquals(message, "tag must not be null")
}
}
\ No newline at end of file
......@@ -12,12 +12,14 @@ import mozilla.components.concept.engine.content.blocking.TrackerLog
import mozilla.components.concept.engine.utils.EngineVersion
import mozilla.components.concept.engine.webextension.WebExtension
import mozilla.components.concept.engine.webextension.WebExtensionDelegate
import mozilla.components.concept.engine.webnotifications.WebNotificationDelegate
import org.json.JSONObject
import java.lang.UnsupportedOperationException
/**
* Entry point for interacting with the engine implementation.
*/
@Suppress("TooManyFunctions")
interface Engine {
/**
......@@ -140,6 +142,16 @@ interface Engine {
webExtensionDelegate: WebExtensionDelegate
): Unit = throw UnsupportedOperationException("Web extension support is not available in this engine")
/**
* Registers a [WebNotificationDelegate] to be notified of engine events
* related to web notifications
*
* @param webNotificationDelegate callback to be invoked for web notification events.
*/
fun registerWebNotificationDelegate(
webNotificationDelegate: WebNotificationDelegate
): Unit = throw UnsupportedOperationException("Web notification support is not available in this engine")
/**
* Clears browsing data stored by the engine.
*
......
......@@ -7,31 +7,23 @@ package mozilla.components.concept.engine.webnotifications
/**
* A notification sent by the Web Notifications API.
*
* @property origin The website that fired this notification.
* @property title Title of the notification to be displayed in the first row.
* @property body Body of the notification to be displayed in the second row.
* @property tag Tag used to identify the notification.
* @property iconUrl Medium image to display in the notification.
* @property body Body of the notification to be displayed in the second row.
* @property iconUrl Large icon url to display in the notification.
* Corresponds to [android.app.Notification.Builder.setLargeIcon].
* @property vibrate Vibration pattern felt when the notification is displayed.
* @property direction Preference for text direction.
* @property lang language of the notification.
* @property requireInteraction Preference flag that indicates the notification should remain.
* @property timestamp Time when the notification was created.
* @property requireInteraction Preference flag that indicates the notification should remain
* active until the user clicks or dismisses it.
* @property silent Preference flag that indicates no sounds or vibrations should be made.
* @property onClick Callback called with the selected action, or null if the main body of the
* notification was clicked.
* @property onClose Callback called when the notification is dismissed.
*/
data class WebNotification(
val origin: String,
val title: String? = null,
val body: String? = null,
val tag: String? = null,
val iconUrl: String? = null,
val vibrate: LongArray = longArrayOf(),
val timestamp: Long? = null,
val requireInteraction: Boolean = false,
val silent: Boolean = false,
val onClick: () -> Unit,
val onClose: () -> Unit
val title: String?,
val tag: String,
val body: String?,
val iconUrl: String?,
val direction: String?,
val lang: String?,
val requireInteraction: Boolean,
val timestamp: Long = System.currentTimeMillis()
)
/* 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 mozilla.components.concept.engine.webnotifications
/**
* Notifies applications or other components of engine events related to web
* notifications e.g. an notification is to be shown or is to be closed
*/
interface WebNotificationDelegate {
/**
* Invoked when a web notification is to be shown.
*
* @param webNotification The web notification intended to be shown.
*/
fun onShowNotification(webNotification: WebNotification) = Unit
/**
* Invoked when a web notification is to be closed.
*
* @param webNotification The web notification intended to be closed.
*/
fun onCloseNotification(webNotification: WebNotification) = Unit
}
......@@ -87,12 +87,7 @@ internal object DownloadNotification {
val channel = notificationManager.getNotificationChannel(channelId)
if (channel.importance == IMPORTANCE_NONE) return false
if (SDK_INT >= Build.VERSION_CODES.P) {
val group = notificationManager.getNotificationChannelGroup(channel.group)
group?.isBlocked != true
} else {
true
}
true
} else {
NotificationManagerCompat.from(context).areNotificationsEnabled()
}
......
......@@ -4,12 +4,16 @@
package mozilla.components.feature.webnotifications
import android.app.Activity
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.Build.VERSION.SDK_INT
import androidx.annotation.DrawableRes
import androidx.core.net.toUri
import mozilla.components.browser.icons.BrowserIcons
import mozilla.components.browser.icons.Icon.Source
......@@ -18,16 +22,23 @@ import mozilla.components.browser.icons.IconRequest.Size
import mozilla.components.concept.engine.webnotifications.WebNotification
internal class NativeNotificationBridge(
private val icons: BrowserIcons
private val icons: BrowserIcons,
@DrawableRes private val smallIcon: Int
) {
companion object {
private const val EXTRA_ON_CLICK = "mozac.feature.webnotifications.generic.onclick"
}
/**
* Create a system [Notification] from this [WebNotification].
*/
@Suppress("LongParameterList")
suspend fun convertToAndroidNotification(
notification: WebNotification,
context: Context,
channelId: String
channelId: String,
activityClass: Class<out Activity>?,
requestId: Int
): Notification {
val builder = if (SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(context, channelId)
......@@ -37,22 +48,25 @@ internal class NativeNotificationBridge(
}
with(notification) {
loadIcon(iconUrl?.toUri(), origin, Size.DEFAULT)?.let { icon ->
builder.setLargeIcon(icon)
}
builder.setContentTitle(title).setContentText(body)
activityClass?.let {
val intent = Intent(context, activityClass).apply {
putExtra(EXTRA_ON_CLICK, tag)
}
@Suppress("Deprecation")
builder.setVibrate(vibrate)
timestamp?.let {
builder.setShowWhen(true).setWhen(it)
PendingIntent.getActivity(context, requestId, intent, 0).apply {
builder.setContentIntent(this)
}
}
if (silent) {
@Suppress("Deprecation")
builder.setDefaults(0)
builder.setSmallIcon(smallIcon)
.setContentTitle(title)
.setContentText(body)
.setShowWhen(true)
.setWhen(timestamp)
.setAutoCancel(true)
loadIcon(iconUrl?.toUri(), Size.DEFAULT)?.let { iconBitmap ->
builder.setLargeIcon(iconBitmap)
}
}
......@@ -62,10 +76,10 @@ internal class NativeNotificationBridge(
/**
* Load an icon for a notification.
*/
private suspend fun loadIcon(url: Uri?, origin: String, size: Size): Bitmap? {
private suspend fun loadIcon(url: Uri?, size: Size): Bitmap? {
url ?: return null
val icon = icons.loadIcon(IconRequest(
url = origin,
url = url.toString(),
size = size,
resources = listOf(IconRequest.Resource(
url = url.toString(),
......
/* 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 mozilla.components.feature.webnotifications
import android.app.Activity
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.annotation.DrawableRes
import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import mozilla.components.browser.icons.BrowserIcons
import mozilla.components.concept.engine.Engine
import mozilla.components.concept.engine.webnotifications.WebNotification
import mozilla.components.concept.engine.webnotifications.WebNotificationDelegate
import mozilla.components.support.base.log.logger.Logger
import java.lang.UnsupportedOperationException
private const val NOTIFICATION_CHANNEL_ID = "mozac.feature.webnotifications.generic.channel"
/**
* Feature implementation for configuring and displaying web notifications to the user.
*
* Initialize this feature globally once on app start
* ```Kotlin
* WebNotificationFeature(
* applicationContext, engine, icons, R.mipmap.ic_launcher, BrowserActivity::class.java
* )
* ```
*
* @param context The application Context.
* @param engine The browser engine.
* @param browserIcons The entry point for loading the large icon for the notification.
* @param smallIcon The small icon for the notification.
* @param activityClass The Activity that the notification will launch if user taps on it
*/
class WebNotificationFeature(
private val context: Context,
private val engine: Engine,
private val browserIcons: BrowserIcons,
@DrawableRes private val smallIcon: Int,
private val activityClass: Class<out Activity>?
) : WebNotificationDelegate {
private val logger = Logger("WebNotificationFeature")
private var pendingRequestId = 0
private var notificationId = 0
private val notificationIdMap = HashMap<String, Int>()
private val notificationManager = context.getSystemService<NotificationManager>()
private val nativeNotificationBridge = NativeNotificationBridge(browserIcons, smallIcon)
init {
try {
engine.registerWebNotificationDelegate(this)
} catch (e: UnsupportedOperationException) {
logger.error("failed to register for web notification delegate", e)
}
}
override fun onShowNotification(webNotification: WebNotification) {
ensureNotificationGroupAndChannelExists()
notificationIdMap[webNotification.tag]?.let {
notificationManager?.cancel(it)
}
pendingRequestId++
notificationId++
notificationIdMap[webNotification.tag] = notificationId
GlobalScope.launch(Dispatchers.IO) {
val notification = nativeNotificationBridge.convertToAndroidNotification(
webNotification, context, NOTIFICATION_CHANNEL_ID, activityClass, pendingRequestId)
notificationManager?.notify(notificationId, notification)
}
}
override fun onCloseNotification(webNotification: WebNotification) {
notificationIdMap[webNotification.tag]?.let {
notificationManager?.cancel(it)
}
}
private fun ensureNotificationGroupAndChannelExists() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
context.getString(R.string.mozac_feature_notification_channel_name),
NotificationManager.IMPORTANCE_LOW
)
channel.setShowBadge(true)
channel.lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
notificationManager?.createNotificationChannel(channel)
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<resources>
<!-- Default Web Notification Channel Name. -->
<string name="mozac_feature_notification_channel_name">Site notifications</string>
</resources>
......@@ -15,8 +15,8 @@ import mozilla.components.concept.engine.webnotifications.WebNotification
import mozilla.components.support.test.any
import mozilla.components.support.test.mock
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
......@@ -24,15 +24,16 @@ import org.junit.runner.RunWith
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.verify
private const val TEST_TITLE = "test title"
private const val TEST_TAG = "test tag"
private const val TEST_TEXT = "test text"
private const val TEST_CHANNEL = "testChannel"
@RunWith(AndroidJUnit4::class)
@ExperimentalCoroutinesApi
class NativeNotificationBridgeTest {
private val blankNotificaiton = WebNotification(
origin = "https://example.com",
onClick = {},
onClose = {}
)
private val blankNotification = WebNotification(TEST_TITLE, TEST_TAG, TEST_TEXT, null, null,
null, true, 0)
private lateinit var icons: BrowserIcons
private lateinit var bridge: NativeNotificationBridge
......@@ -40,7 +41,7 @@ class NativeNotificationBridgeTest {
@Before
fun setup() {
icons = mock()
bridge = NativeNotificationBridge(icons)
bridge = NativeNotificationBridge(icons, android.R.drawable.ic_dialog_alert)
val mockIcon = Icon(mock(), source = Icon.Source.GENERATOR)
doReturn(CompletableDeferred(mockIcon)).`when`(icons).loadIcon(any())
......@@ -49,38 +50,28 @@ class NativeNotificationBridgeTest {
@Test
fun `create blank notification`() = runBlockingTest {
val notification = bridge.convertToAndroidNotification(
blankNotificaiton,
blankNotification,
testContext,
"channel"
TEST_CHANNEL,
null,
0
)
assertNull(notification.actions)
@Suppress("Deprecation")
assertArrayEquals(longArrayOf(), notification.vibrate)
assertEquals(TEST_CHANNEL, notification.channelId)
assertEquals(0, notification.`when`)
assertEquals("channel", notification.channelId)
assertNotNull(notification.smallIcon)
assertNull(notification.getLargeIcon())
assertNull(notification.smallIcon)
}
@Test
fun `set vibration pattern`() = runBlockingTest {
val notification = bridge.convertToAndroidNotification(
blankNotificaiton.copy(vibrate = longArrayOf(1, 2, 3)),
testContext,
"channel"
)
@Suppress("Deprecation")
assertArrayEquals(longArrayOf(1, 2, 3), notification.vibrate)
}
@Test
fun `set when`() = runBlockingTest {
val notification = bridge.convertToAndroidNotification(
blankNotificaiton.copy(timestamp = 1234567890),
blankNotification.copy(timestamp = 1234567890),
testContext,
"channel"
TEST_CHANNEL,
null,
0
)
assertEquals(1234567890, notification.`when`)
......@@ -89,14 +80,16 @@ class NativeNotificationBridgeTest {
@Test
fun `icon is loaded from BrowserIcons`() = runBlockingTest {
bridge.convertToAndroidNotification(
blankNotificaiton.copy(iconUrl = "https://example.com/large.png"),
blankNotification.copy(iconUrl = "https://example.com/large.png"),
testContext,
"channel"
TEST_CHANNEL,
null,
0
)
verify(icons).loadIcon(
IconRequest(
url = "https://example.com",
url = "https://example.com/large.png",
size = IconRequest.Size.DEFAULT,
resources = listOf(IconRequest.Resource(
url = "https://example.com/large.png",
......
/* 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 mozilla.components.feature.webnotifications