Commit ec8049ea authored by Tiger Oakes's avatar Tiger Oakes
Browse files

Closes #5250 - Add monochrome purpose support

parent b2f21153
......@@ -72,7 +72,7 @@ internal val sharedDiskCache = IconDiskCache()
* Entry point for loading icons for websites.
*
* @param generator The [IconGenerator] to generate an icon if no icon could be loaded.
* @param decoders List of [IconDecoder] instances to use when decoding a loaded icon into a [android.graphics.Bitmap].
* @param decoders List of [ImageDecoder] instances to use when decoding a loaded icon into a [android.graphics.Bitmap].
*/
class BrowserIcons(
private val context: Context,
......
......@@ -4,6 +4,7 @@
package mozilla.components.browser.icons.extension
import android.graphics.Color
import android.os.Build
import android.os.Build.VERSION.SDK_INT
import mozilla.components.browser.icons.IconRequest
......@@ -19,13 +20,25 @@ import mozilla.components.concept.engine.manifest.WebAppManifest.Icon.Purpose
fun WebAppManifest.toIconRequest() = IconRequest(
url = startUrl,
size = if (SDK_INT >= Build.VERSION_CODES.O) LAUNCHER_ADAPTIVE else LAUNCHER,
resources = icons.mapNotNull { it.toIconResource() },
resources = icons
.filter { Purpose.MASKABLE in it.purpose || Purpose.ANY in it.purpose }
.map { it.toIconResource() },
color = backgroundColor
)
private fun WebAppManifest.Icon.toIconResource(): IconRequest.Resource? {
if (Purpose.MASKABLE !in purpose && Purpose.ANY !in purpose) return null
/**
* Creates an [IconRequest] for retrieving a monochrome icon specified in the manifest.
*/
fun WebAppManifest.toMonochromeIconRequest() = IconRequest(
url = startUrl,
size = IconRequest.Size.DEFAULT,
resources = icons
.filter { Purpose.MONOCHROME in it.purpose }
.map { it.toIconResource() },
color = Color.WHITE
)
private fun WebAppManifest.Icon.toIconResource(): IconRequest.Resource {
return IconRequest.Resource(
url = src,
type = MANIFEST_ICON,
......
......@@ -113,7 +113,7 @@ data class WebAppManifest(
* A user agent can present this icon where space constraints and/or color requirements differ from those
* of the application icon.
*/
BADGE,
MONOCHROME,
/**
* The image is designed with icon masks and safe zone in mind, such that any part of the image that is
......
......@@ -64,7 +64,7 @@ private fun parsePurposes(json: JSONObject): Set<WebAppManifest.Icon.Purpose> {
return purpose
.mapNotNull {
when (it.toLowerCase(Locale.ROOT)) {
"badge" -> WebAppManifest.Icon.Purpose.BADGE
"monochrome" -> WebAppManifest.Icon.Purpose.MONOCHROME
"maskable" -> WebAppManifest.Icon.Purpose.MASKABLE
"any" -> WebAppManifest.Icon.Purpose.ANY
else -> null
......
......@@ -489,7 +489,7 @@ class WebAppManifestParserTest {
assertEquals(96, sizes[1].height)
assertEquals(128, sizes[2].width)
assertEquals(128, sizes[2].height)
assertEquals(setOf(WebAppManifest.Icon.Purpose.BADGE), purpose)
assertEquals(setOf(WebAppManifest.Icon.Purpose.MONOCHROME), purpose)
}
manifest.icons[1].apply {
......@@ -535,7 +535,7 @@ class WebAppManifestParserTest {
assertEquals(96, sizes[1].height)
assertEquals(128, sizes[2].width)
assertEquals(128, sizes[2].height)
assertEquals(setOf(WebAppManifest.Icon.Purpose.BADGE), purpose)
assertEquals(setOf(WebAppManifest.Icon.Purpose.MONOCHROME), purpose)
}
manifest.icons[1].apply {
......
......@@ -6,7 +6,7 @@
"src": "/images/icon/favicon.ico",
"type": "image/png",
"sizes": "48x48 96x96 128x128",
"purpose": ["badge"]
"purpose": ["monochrome"]
},
{
"src": "/images/icon/512-512.png",
......
......@@ -6,7 +6,7 @@
"src": "/images/icon/favicon.ico",
"type": "image/png",
"sizes": "48x48 96x96 128x128",
"purpose": "badge"
"purpose": "monochrome"
},
{
"src": "/images/icon/512-512.png",
......
......@@ -55,6 +55,7 @@ dependencies {
implementation Dependencies.androidx_browser
implementation Dependencies.androidx_core_ktx
implementation Dependencies.androidx_lifecycle_runtime
implementation Dependencies.kotlin_stdlib
implementation Dependencies.kotlin_coroutines
......
......@@ -61,7 +61,6 @@ class WebAppShortcutManager(
internal val supportWebApps: Boolean = true
) {
@VisibleForTesting
internal val icons = webAppIcons(context, httpClient)
private val fallbackLabel = context.getString(R.string.mozac_feature_pwa_default_shortcut_label)
......
......@@ -4,14 +4,17 @@
package mozilla.components.feature.pwa.feature
import android.app.Notification
import android.app.PendingIntent
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.drawable.Icon
import android.os.Build
import android.os.Build.VERSION.SDK_INT
import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
import mozilla.components.browser.session.Session
import mozilla.components.feature.pwa.R
......@@ -27,7 +30,7 @@ interface SiteControlsBuilder {
* and additional actions can be added here. Actions should be represented as [PendingIntent]
* that are filtered by [getFilter] and handled in [onReceiveBroadcast].
*/
fun buildNotification(context: Context, builder: NotificationCompat.Builder, channelId: String)
fun buildNotification(context: Context, builder: Notification.Builder)
/**
* Return an intent filter that matches the actions specified in [buildNotification].
......@@ -48,7 +51,7 @@ interface SiteControlsBuilder {
addAction(ACTION_COPY)
}
override fun buildNotification(context: Context, builder: NotificationCompat.Builder, channelId: String) {
override fun buildNotification(context: Context, builder: Notification.Builder) {
val copyIntent = createPendingIntent(context, ACTION_COPY, 1)
builder.setContentText(context.getString(R.string.mozac_feature_pwa_site_controls_notification_text))
......@@ -93,13 +96,21 @@ interface SiteControlsBuilder {
addAction(ACTION_REFRESH)
}
override fun buildNotification(context: Context, builder: NotificationCompat.Builder, channelId: String) {
super.buildNotification(context, builder, channelId)
val refreshAction = NotificationCompat.Action(
R.drawable.ic_refresh,
context.getString(R.string.mozac_feature_pwa_site_controls_refresh),
createPendingIntent(context, ACTION_REFRESH, 2)
)
override fun buildNotification(context: Context, builder: Notification.Builder) {
super.buildNotification(context, builder)
val title = context.getString(R.string.mozac_feature_pwa_site_controls_refresh)
val intent = createPendingIntent(context, ACTION_REFRESH, 2)
val refreshAction = if (SDK_INT >= Build.VERSION_CODES.M) {
Notification.Action.Builder(
Icon.createWithResource(context, R.drawable.ic_refresh),
title,
intent
)
} else {
@Suppress("Deprecation")
Notification.Action.Builder(R.drawable.ic_refresh, title, intent)
}.build()
builder.addAction(refreshAction)
}
......
......@@ -11,14 +11,23 @@ import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.Icon
import android.os.Build
import android.os.Build.VERSION.SDK_INT
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.BADGE_ICON_NONE
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.OnLifecycleEvent
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.launch
import mozilla.components.browser.icons.BrowserIcons
import mozilla.components.browser.icons.extension.toMonochromeIconRequest
import mozilla.components.browser.session.SessionManager
import mozilla.components.concept.engine.manifest.WebAppManifest
import mozilla.components.feature.pwa.R
......@@ -30,12 +39,14 @@ import mozilla.components.feature.session.SessionUseCases
* @param manifest Web App Manifest reference used to populate the notification.
* @param controlsBuilder Customizes the created notification.
*/
@Suppress("LongParameterList")
class WebAppSiteControlsFeature(
private val applicationContext: Context,
private val sessionManager: SessionManager,
private val sessionId: String,
private val manifest: WebAppManifest? = null,
private val controlsBuilder: SiteControlsBuilder = SiteControlsBuilder.Default()
private val controlsBuilder: SiteControlsBuilder = SiteControlsBuilder.Default(),
private val icons: BrowserIcons? = null
) : BroadcastReceiver(), LifecycleObserver {
constructor(
......@@ -44,24 +55,57 @@ class WebAppSiteControlsFeature(
reloadUrlUseCase: SessionUseCases.ReloadUrlUseCase,
sessionId: String,
manifest: WebAppManifest? = null,
controlsBuilder: SiteControlsBuilder = SiteControlsBuilder.CopyAndRefresh(reloadUrlUseCase)
controlsBuilder: SiteControlsBuilder = SiteControlsBuilder.CopyAndRefresh(reloadUrlUseCase),
icons: BrowserIcons? = null
) : this(
applicationContext,
sessionManager,
sessionId,
manifest,
controlsBuilder
controlsBuilder,
icons
)
private var notificationIcon: Deferred<mozilla.components.browser.icons.Icon>? = null
/**
* Starts loading the [notificationIcon] on create.
*/
@OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
fun onCreate() {
if (SDK_INT >= Build.VERSION_CODES.M && manifest != null && icons != null) {
val request = manifest.toMonochromeIconRequest()
if (request.resources.isNotEmpty()) {
notificationIcon = icons.loadIcon(request)
}
}
}
/**
* Displays a notification from the given [SiteControlsBuilder.buildNotification] that will be
* shown as long as the lifecycle is in the foreground. Registers this class as a broadcast
* receiver to receive events from the notification and call [SiteControlsBuilder.onReceiveBroadcast].
*/
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun onResume() {
fun onResume(owner: LifecycleOwner) {
val filter = controlsBuilder.getFilter()
applicationContext.registerReceiver(this, filter)
NotificationManagerCompat.from(applicationContext)
.notify(NOTIFICATION_TAG, NOTIFICATION_ID, buildNotification())
val manager = NotificationManagerCompat.from(applicationContext)
val iconAsync = notificationIcon
if (iconAsync != null) {
owner.lifecycleScope.launch {
val bitmap = iconAsync.await().bitmap
manager.notify(NOTIFICATION_TAG, NOTIFICATION_ID, buildNotification(bitmap))
}
} else {
manager.notify(NOTIFICATION_TAG, NOTIFICATION_ID, buildNotification(null))
}
}
/**
* Cancels the site controls notification and unregisters the broadcast receiver.
*/
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
fun onPause() {
applicationContext.unregisterReceiver(this)
......@@ -70,6 +114,14 @@ class WebAppSiteControlsFeature(
.cancel(NOTIFICATION_TAG, NOTIFICATION_ID)
}
/**
* Cancels the [notificationIcon] loading job on destroy.
*/
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun onDestroy() {
notificationIcon?.cancel()
}
/**
* Responds to [PendingIntent]s fired by the site controls notification.
*/
......@@ -82,19 +134,29 @@ class WebAppSiteControlsFeature(
/**
* Build the notification with site controls to be displayed while the web app is active.
*/
private fun buildNotification(): Notification {
val channelId = ensureChannelExists()
return NotificationCompat.Builder(applicationContext, channelId)
.setSmallIcon(R.drawable.ic_pwa)
.setContentTitle(manifest?.name ?: manifest?.shortName)
.setBadgeIconType(BADGE_ICON_NONE)
.setColor(manifest?.themeColor ?: NotificationCompat.COLOR_DEFAULT)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setShowWhen(false)
.setOngoing(true)
.also { controlsBuilder.buildNotification(applicationContext, it, channelId) }
.build()
private fun buildNotification(icon: Bitmap?): Notification {
val builder = if (SDK_INT >= Build.VERSION_CODES.O) {
val channelId = ensureChannelExists()
Notification.Builder(applicationContext, channelId).apply {
setBadgeIconType(BADGE_ICON_NONE)
}
} else {
@Suppress("Deprecation")
Notification.Builder(applicationContext).apply {
setPriority(NotificationCompat.PRIORITY_MIN)
}
}
if (icon != null && SDK_INT >= Build.VERSION_CODES.M) {
builder.setSmallIcon(Icon.createWithBitmap(icon))
} else {
builder.setSmallIcon(R.drawable.ic_pwa)
}
builder.setContentTitle(manifest?.name ?: manifest?.shortName)
builder.setColor(manifest?.themeColor ?: NotificationCompat.COLOR_DEFAULT)
builder.setShowWhen(false)
builder.setOngoing(true)
controlsBuilder.buildNotification(applicationContext, builder)
return builder.build()
}
/**
......@@ -103,7 +165,7 @@ class WebAppSiteControlsFeature(
* Returns the channel id to be used for notifications.
*/
private fun ensureChannelExists(): String {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager: NotificationManager = applicationContext.getSystemService()!!
val channel = NotificationChannel(
......
......@@ -60,7 +60,7 @@ class SessionKtTest {
demoManifest.copy(icons = listOf(
demoIcon.copy(
sizes = listOf(Size(512, 512)),
purpose = setOf(WebAppManifest.Icon.Purpose.BADGE)
purpose = setOf(WebAppManifest.Icon.Purpose.MONOCHROME)
)
))
)
......@@ -128,7 +128,7 @@ class SessionKtTest {
demoIcon.copy(sizes = listOf(Size(512, 512))),
demoIcon.copy(
sizes = listOf(Size(192, 192)),
purpose = setOf(WebAppManifest.Icon.Purpose.BADGE)
purpose = setOf(WebAppManifest.Icon.Purpose.MONOCHROME)
)
))
)
......@@ -139,7 +139,7 @@ class SessionKtTest {
demoIcon.copy(sizes = listOf(Size(512, 512))),
demoIcon.copy(
sizes = listOf(Size(192, 192)),
purpose = setOf(WebAppManifest.Icon.Purpose.BADGE)
purpose = setOf(WebAppManifest.Icon.Purpose.MONOCHROME)
)
)),
multiIconSession.installableManifest()
......
......@@ -110,7 +110,7 @@ class WebAppManifestKtTest {
val onlyBadgeIconManifest = demoManifest.copy(icons = listOf(
demoIcon.copy(
sizes = listOf(Size(512, 512)),
purpose = setOf(WebAppManifest.Icon.Purpose.BADGE)
purpose = setOf(WebAppManifest.Icon.Purpose.MONOCHROME)
)
))
assertFalse(onlyBadgeIconManifest.hasLargeIcons())
......@@ -143,7 +143,7 @@ class WebAppManifestKtTest {
demoIcon.copy(sizes = listOf(Size(512, 512))),
demoIcon.copy(
sizes = listOf(Size(192, 192)),
purpose = setOf(WebAppManifest.Icon.Purpose.BADGE)
purpose = setOf(WebAppManifest.Icon.Purpose.MONOCHROME)
)
))
assertTrue(multiIconManifest.hasLargeIcons())
......@@ -152,7 +152,7 @@ class WebAppManifestKtTest {
demoIcon.copy(sizes = listOf(Size(191, 191))),
demoIcon.copy(
sizes = listOf(Size(192, 192)),
purpose = setOf(WebAppManifest.Icon.Purpose.BADGE)
purpose = setOf(WebAppManifest.Icon.Purpose.MONOCHROME)
)
))
assertFalse(onlyBadgeManifest.hasLargeIcons())
......
......@@ -5,9 +5,14 @@
package mozilla.components.feature.pwa.feature
import android.content.Intent
import android.graphics.Color
import androidx.test.ext.junit.runners.AndroidJUnit4
import mozilla.components.browser.icons.BrowserIcons
import mozilla.components.browser.icons.IconRequest
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import mozilla.components.concept.engine.manifest.Size
import mozilla.components.concept.engine.manifest.WebAppManifest
import mozilla.components.feature.session.SessionUseCases
import mozilla.components.support.test.any
import mozilla.components.support.test.eq
......@@ -28,7 +33,7 @@ class WebAppSiteControlsFeatureTest {
val context = spy(testContext)
val feature = WebAppSiteControlsFeature(context, mock(), "session-id", mock())
feature.onResume()
feature.onResume(mock())
verify(context).registerReceiver(eq(feature), any())
}
......@@ -58,4 +63,48 @@ class WebAppSiteControlsFeatureTest {
verify(reloadUrlUseCase).invoke(session)
}
@Test
fun `load monochrome icon if defined in manifest`() {
val sessionManager: SessionManager = mock()
val session: Session = mock()
val icons: BrowserIcons = mock()
val manifest = WebAppManifest(
name = "Mozilla",
startUrl = "https://mozilla.org",
scope = "https://mozilla.org",
icons = listOf(
WebAppManifest.Icon(
src = "https://mozilla.org/logo_color.svg",
sizes = listOf(Size.ANY),
type = "image/svg+xml",
purpose = setOf(WebAppManifest.Icon.Purpose.ANY, WebAppManifest.Icon.Purpose.MASKABLE)
),
WebAppManifest.Icon(
src = "https://mozilla.org/logo_black.svg",
sizes = listOf(Size.ANY),
type = "image/svg+xml",
purpose = setOf(WebAppManifest.Icon.Purpose.MONOCHROME)
)
)
)
doReturn(session).`when`(sessionManager).findSessionById("session-id")
val feature = WebAppSiteControlsFeature(testContext, sessionManager, "session-id", manifest, icons = icons)
feature.onCreate()
verify(icons).loadIcon(IconRequest(
url = "https://mozilla.org",
size = IconRequest.Size.DEFAULT,
resources = listOf(IconRequest.Resource(
url = "https://mozilla.org/logo_black.svg",
type = IconRequest.Resource.Type.MANIFEST_ICON,
sizes = listOf(Size.ANY),
mimeType = "image/svg+xml",
maskable = false
)),
color = Color.WHITE
))
}
}
......@@ -48,6 +48,10 @@ permalink: /changelog/
* **feature-downloads**
* 🚒 Fix [issue #8202](https://github.com/mozilla-mobile/android-components/issues/8202) Download's ui were always showing failed status.
* **feature-pwa**
* ⚠️ **This is a breaking change**: The `SiteControlsBuilder` interface has changed. `buildNotification` now takes two parameters: `Context` and `Notification.Builder`.
* `WebAppSiteControlsFeature` now supports displaying monochrome icons.
# 55.0.0
* [Commits](https://github.com/mozilla-mobile/android-components/compare/v54.0.0...v55.0.0)
......
......@@ -93,7 +93,8 @@ class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler
components.sessionManager,
components.sessionUseCases.reload,
sessionId!!,
manifest
manifest,
icons = components.icons
)
)
}
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment