/* * Copyright (C) 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.systemui.statusbar.notification.icon import android.app.Notification import android.app.Notification.MessagingStyle import android.app.Person import android.content.Context import android.content.pm.LauncherApps import android.graphics.drawable.Icon import android.os.Build import android.os.Bundle import android.util.Log import android.view.View import android.widget.ImageView import com.android.app.tracing.coroutines.launchTraced as launch import com.android.app.tracing.traceSection import com.android.internal.statusbar.StatusBarIcon import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.dagger.qualifiers.Background import com.android.systemui.dagger.qualifiers.Main import com.android.systemui.res.R import com.android.systemui.shade.ShadeDisplayAware import com.android.systemui.shade.shared.flag.ShadeWindowGoesAround import com.android.systemui.statusbar.StatusBarIconView import com.android.systemui.statusbar.core.StatusBarConnectedDisplays import com.android.systemui.statusbar.notification.InflationException import com.android.systemui.statusbar.notification.collection.BundleEntry import com.android.systemui.statusbar.notification.collection.NotificationEntry import com.android.systemui.statusbar.notification.collection.PipelineEntry import com.android.systemui.statusbar.notification.collection.notifcollection.CommonNotifCollection import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.withContext /** * Inflates and updates icons associated with notifications * * Notifications are represented by icons in a few different places -- in the status bar, in the * notification shelf, in AOD, etc. This class is in charge of inflating the views that hold these * icons and keeping the icon assets themselves up to date as notifications change. * * TODO: Much of this code was copied whole-sale in order to get it out of NotificationEntry. * Long-term, it should probably live somewhere in the content inflation pipeline. */ @SysUISingleton class IconManager @Inject constructor( private val notifCollection: CommonNotifCollection, private val launcherApps: LauncherApps, private val iconBuilder: IconBuilder, @Application private val applicationCoroutineScope: CoroutineScope, @Background private val bgCoroutineContext: CoroutineContext, @Main private val mainCoroutineContext: CoroutineContext, @ShadeDisplayAware private val shadeContext: Context, ) : ConversationIconManager { /** * A listener that is notified when a [NotificationEntry] has been updated and the associated * icons have to be updated as well. */ fun interface OnIconUpdateRequiredListener { fun onIconUpdateRequired(entry: NotificationEntry) } private val onIconUpdateRequiredListeners = mutableSetOf() private var unimportantConversationKeys: Set = emptySet() /** * A map of running jobs for fetching the person avatar from launcher. The key is the * notification entry key. */ private var launcherPeopleAvatarIconJobs: ConcurrentHashMap = ConcurrentHashMap() fun addIconsUpdateListener(listener: OnIconUpdateRequiredListener) { StatusBarConnectedDisplays.unsafeAssertInNewMode() onIconUpdateRequiredListeners += listener } fun removeIconsUpdateListener(listener: OnIconUpdateRequiredListener) { StatusBarConnectedDisplays.unsafeAssertInNewMode() onIconUpdateRequiredListeners -= listener } fun attach() { notifCollection.addCollectionListener(entryListener) } private val entryListener = object : NotifCollectionListener { override fun onEntryInit(entry: NotificationEntry) { entry.addOnSensitivityChangedListener(sensitivityListener) } override fun onEntryCleanUp(entry: NotificationEntry) { entry.removeOnSensitivityChangedListener(sensitivityListener) } override fun onRankingApplied() { // rankings affect whether a conversation is important, which can change the icons recalculateForImportantConversationChange() } } private val sensitivityListener = PipelineEntry.OnSensitivityChangedListener { entry -> updateIconsSafe(entry) } private fun recalculateForImportantConversationChange() { for (entry in notifCollection.allNotifs) { val isImportant = isImportantConversation(entry) if ( entry.icons.areIconsAvailable && isImportant != entry.icons.isImportantConversation ) { updateIconsSafe(entry) } entry.icons.isImportantConversation = isImportant } } /** * Inflate the [StatusBarIconView] for the given [NotificationEntry], using the specified * [Context]. */ fun createSbIconView(context: Context, entry: NotificationEntry): StatusBarIconView = traceSection("IconManager.createSbIconView") { StatusBarConnectedDisplays.unsafeAssertInNewMode() val sbIcon = iconBuilder.createIconView(entry, context) sbIcon.scaleType = ImageView.ScaleType.CENTER_INSIDE val (normalIconDescriptor, _) = getIconDescriptors(entry) setIcon(entry, normalIconDescriptor, sbIcon) return sbIcon } /** * Inflate icon views for each icon variant and assign appropriate icons to them. Stores the * result in [NotificationEntry.getIcons]. * * @throws InflationException Exception if required icons are not valid or specified */ @Throws(InflationException::class) fun createIcons(entry: NotificationEntry) = traceSection("IconManager.createIcons") { // Construct the status bar icon view. val sbIcon = iconBuilder.createIconView(entry) sbIcon.scaleType = ImageView.ScaleType.CENTER_INSIDE val sbChipIcon: StatusBarIconView? if (!StatusBarConnectedDisplays.isEnabled) { sbChipIcon = iconBuilder.createIconView(entry) sbChipIcon.scaleType = ImageView.ScaleType.CENTER_INSIDE } else { sbChipIcon = null } // Construct the shelf icon view. val shelfIcon = if (ShadeWindowGoesAround.isEnabled) { iconBuilder.createIconView(entry, shadeContext) } else { iconBuilder.createIconView(entry) } shelfIcon.scaleType = ImageView.ScaleType.CENTER_INSIDE shelfIcon.visibility = View.INVISIBLE // Construct the aod icon view. val aodIcon = iconBuilder.createIconView(entry) aodIcon.scaleType = ImageView.ScaleType.CENTER_INSIDE aodIcon.setIncreasedSize(true) // Set the icon views' icons val (normalIconDescriptor, sensitiveIconDescriptor) = getIconDescriptors(entry) try { setIcon(entry, normalIconDescriptor, sbIcon) if ( android.app.Flags.hideStatusBarNotification() && entry.sbn.notification.extras?.getBoolean( Notification.EXTRA_HIDE_STATUS_BAR_NOTIFICATION ) == true ) { Log.i(TAG, "EXTRA_HIDE_STATUS_BAR_NOTIFICATION set, hiding the icon.") sbIcon.visibility = View.GONE } if (sbChipIcon != null) { setIcon(entry, normalIconDescriptor, sbChipIcon) } setIcon(entry, sensitiveIconDescriptor, shelfIcon) setIcon(entry, sensitiveIconDescriptor, aodIcon) entry.icons = IconPack.buildPack(sbIcon, sbChipIcon, shelfIcon, aodIcon, entry.icons) } catch (e: InflationException) { entry.icons = IconPack.buildEmptyPack(entry.icons) throw e } } /** Update the [StatusBarIconView] for the given [NotificationEntry]. */ fun updateSbIcon(entry: NotificationEntry, iconView: StatusBarIconView) = traceSection("IconManager.updateSbIcon") { StatusBarConnectedDisplays.unsafeAssertInNewMode() val (normalIconDescriptor, _) = getIconDescriptors(entry) val notificationContentDescription = entry.sbn.notification?.let { iconBuilder.getIconContentDescription(it) } iconView.setNotification(entry.sbn, notificationContentDescription) setIcon(entry, normalIconDescriptor, iconView) } /** * Update the notification icons. * * @param entry the notification to read the icon from. * @param usingCache whether to fetch the icon from cache if present * @throws InflationException Exception if required icons are not valid or specified */ @Throws(InflationException::class) fun updateIcons(entry: NotificationEntry, usingCache: Boolean = false) = traceSection("IconManager.updateIcons") { if (!entry.icons.areIconsAvailable) { return@traceSection } if (!usingCache) { entry.icons.smallIconDescriptor = null entry.icons.peopleAvatarDescriptor = null } if (StatusBarConnectedDisplays.isEnabled) { onIconUpdateRequiredListeners.onEach { it.onIconUpdateRequired(entry) } } val (normalIconDescriptor, sensitiveIconDescriptor) = getIconDescriptors(entry) val notificationContentDescription = entry.sbn.notification?.let { iconBuilder.getIconContentDescription(it) } entry.icons.statusBarIcon?.let { it.setNotification(entry.sbn, notificationContentDescription) setIcon(entry, normalIconDescriptor, it) } entry.icons.statusBarChipIcon?.let { it.setNotification(entry.sbn, notificationContentDescription) setIcon(entry, normalIconDescriptor, it) } entry.icons.shelfIcon?.let { it.setNotification(entry.sbn, notificationContentDescription) setIcon(entry, sensitiveIconDescriptor, it) } entry.icons.aodIcon?.let { it.setNotification(entry.sbn, notificationContentDescription) setIcon(entry, sensitiveIconDescriptor, it) } } /** * Inflate icon views for each icon variant and assign appropriate icons to them. Stores the * result in [BundleEntry.getIcons]. These icons never change and can be shown on a redacted * lockscreen, so they don't have an equivalent update method. * * @throws InflationException Exception if required icons are not valid or specified */ @Throws(InflationException::class) fun createIcons(context: Context, entry: BundleEntry) = traceSection("IconManager.createIcons") { // Construct the status bar icon view. val sbIcon = iconBuilder.createIconView(entry) sbIcon.scaleType = ImageView.ScaleType.CENTER_INSIDE val sbChipIcon: StatusBarIconView? if (!StatusBarConnectedDisplays.isEnabled) { sbChipIcon = iconBuilder.createIconView(entry) sbChipIcon.scaleType = ImageView.ScaleType.CENTER_INSIDE } else { sbChipIcon = null } // Construct the shelf icon view. val shelfIcon = if (ShadeWindowGoesAround.isEnabled) { iconBuilder.createIconView(entry, context) } else { iconBuilder.createIconView(entry) } shelfIcon.scaleType = ImageView.ScaleType.CENTER_INSIDE shelfIcon.visibility = View.INVISIBLE // Construct the aod icon view. val aodIcon = iconBuilder.createIconView(entry) aodIcon.scaleType = ImageView.ScaleType.CENTER_INSIDE aodIcon.setIncreasedSize(true) // Set the icon views' icons val (normalIconDescriptor, sensitiveIconDescriptor) = getIconDescriptors(context, entry) try { setIcon(entry, normalIconDescriptor, sbIcon) if (sbChipIcon != null) { setIcon(entry, normalIconDescriptor, sbChipIcon) } setIcon(entry, sensitiveIconDescriptor, shelfIcon) setIcon(entry, sensitiveIconDescriptor, aodIcon) entry.icons = IconPack.buildPack(sbIcon, sbChipIcon, shelfIcon, aodIcon, entry.icons) } catch (e: InflationException) { entry.icons = IconPack.buildEmptyPack(entry.icons) throw e } } private fun updateIconsSafe(entry: NotificationEntry) { try { updateIcons(entry) } catch (e: InflationException) { // TODO This should mark the entire row as involved in an inflation error Log.e(TAG, "Unable to update icon", e) } } @Throws(InflationException::class) private fun getIconDescriptors(entry: NotificationEntry): Pair { val iconDescriptor = getIconDescriptor(entry, redact = false) val sensitiveDescriptor = if (entry.isSensitive.value) { getIconDescriptor(entry, redact = true) } else { iconDescriptor } return Pair(iconDescriptor, sensitiveDescriptor) } @Throws(InflationException::class) private fun getIconDescriptors( context: Context, entry: BundleEntry, ): Pair { val iconDescriptor = getIconDescriptor(context, entry) return Pair(iconDescriptor, iconDescriptor) } @Throws(InflationException::class) private fun getIconDescriptor(context: Context, entry: BundleEntry): StatusBarIcon { // If the descriptor is already cached, return it entry.icons.smallIconDescriptor?.also { return it } val (icon: Icon?, type: StatusBarIcon.Type) = Icon.createWithResource(context, entry.bundleRepository.bundleIcon) to StatusBarIcon.Type.ResourceIcon if (icon == null) { throw InflationException("No icon for bundle ${entry.bundleRepository.bundleType}") } val sbi = icon.toStatusBarIcon(context, entry, type) entry.icons.smallIconDescriptor = sbi return sbi } @Throws(InflationException::class) private fun getIconDescriptor(entry: NotificationEntry, redact: Boolean): StatusBarIcon { val showPeopleAvatar = !redact && isImportantConversation(entry) // If the descriptor is already cached, return it getCachedIconDescriptor(entry, showPeopleAvatar)?.also { return it } val n = entry.sbn.notification val (icon: Icon?, type: StatusBarIcon.Type) = if (showPeopleAvatar) { createPeopleAvatar(entry) to StatusBarIcon.Type.PeopleAvatar } else { n.smallIcon to StatusBarIcon.Type.NotifSmallIcon } if (icon == null) { throw InflationException("No icon in notification from ${entry.sbn.packageName}") } val sbi = icon.toStatusBarIcon(entry, type) cacheIconDescriptor(entry, sbi) return sbi } private fun getCachedIconDescriptor( entry: NotificationEntry, showPeopleAvatar: Boolean, ): StatusBarIcon? { val peopleAvatarDescriptor = entry.icons.peopleAvatarDescriptor val smallIconDescriptor = entry.icons.smallIconDescriptor // If cached, return corresponding cached values return when { showPeopleAvatar && peopleAvatarDescriptor != null -> peopleAvatarDescriptor smallIconDescriptor != null -> smallIconDescriptor else -> null } } private fun cacheIconDescriptor(entry: NotificationEntry, descriptor: StatusBarIcon) { if (android.app.Flags.notificationsRedesignAppIcons()) { // Although we're not actually using the app icon in the status bar, let's make sure // we cache the icon all the time when the flag is on. when (descriptor.type) { StatusBarIcon.Type.PeopleAvatar -> entry.icons.peopleAvatarDescriptor = descriptor // When notificationsUseAppIcon is enabled, the app icon overrides the small icon. // But either way, it's a good idea to cache the descriptor. else -> entry.icons.smallIconDescriptor = descriptor } } else if (isImportantConversation(entry)) { // Old approach: cache only if important conversation. if (descriptor.type == StatusBarIcon.Type.PeopleAvatar) { entry.icons.peopleAvatarDescriptor = descriptor } else { entry.icons.smallIconDescriptor = descriptor } } } @Throws(InflationException::class) private fun setIcon( entry: NotificationEntry, iconDescriptor: StatusBarIcon, iconView: StatusBarIconView, ) { iconView.setShowsConversation(showsConversation(entry, iconView, iconDescriptor)) iconView.setTag(R.id.icon_is_pre_L, entry.targetSdk < Build.VERSION_CODES.LOLLIPOP) if (!iconView.set(iconDescriptor)) { throw InflationException("Couldn't create icon $iconDescriptor") } } @Throws(InflationException::class) private fun setIcon( entry: BundleEntry, iconDescriptor: StatusBarIcon, iconView: StatusBarIconView, ) { iconView.setShowsConversation(false) iconView.setTag(R.id.icon_is_pre_L, false) if (!iconView.set(iconDescriptor)) { throw InflationException("Couldn't create icon $iconDescriptor") } } private fun Icon.toStatusBarIcon( entry: NotificationEntry, type: StatusBarIcon.Type, ): StatusBarIcon { val n = entry.sbn.notification return StatusBarIcon( entry.sbn.user, entry.sbn.packageName, /* icon = */ this, n.iconLevel, n.number, iconBuilder.getIconContentDescription(n), type, ) } private fun Icon.toStatusBarIcon( context: Context, entry: BundleEntry, type: StatusBarIcon.Type, ): StatusBarIcon { return StatusBarIcon( context.user, context.packageName, /* icon = */ this, 0, 0, context.getString(entry.bundleRepository.titleText), type, ) } private suspend fun getLauncherShortcutIconForPeopleAvatar(entry: NotificationEntry) = withContext(bgCoroutineContext) { var icon: Icon? = null val shortcut = entry.ranking.conversationShortcutInfo if (shortcut != null) { try { icon = launcherApps.getShortcutIcon(shortcut) } catch (e: Exception) { Log.e( TAG, "Error calling LauncherApps#getShortcutIcon for notification $entry: $e", ) } } // Once we have the icon, updating it should happen on the main thread. if (icon != null) { withContext(mainCoroutineContext) { val iconDescriptor = icon.toStatusBarIcon(entry, StatusBarIcon.Type.PeopleAvatar) // Cache the value entry.icons.peopleAvatarDescriptor = iconDescriptor // Update the icons using the cached value updateIcons(entry = entry, usingCache = true) } } } @Throws(InflationException::class) private fun createPeopleAvatar(entry: NotificationEntry): Icon { // Ideally we want to get the icon from launcher, but this is a binder transaction that may // take longer so let's kick it off on a background thread and use a placeholder in the // meantime. launcherPeopleAvatarIconJobs[entry.key]?.cancel() // cancel the previous job if necessary launcherPeopleAvatarIconJobs[entry.key] = applicationCoroutineScope .launch { getLauncherShortcutIconForPeopleAvatar(entry) } .apply { invokeOnCompletion { launcherPeopleAvatarIconJobs.remove(entry.key) } } var placeholderIcon: Icon? = null // First try to extract from message val extras: Bundle = entry.sbn.notification.extras val messages = MessagingStyle.Message.getMessagesFromBundleArray( extras.getParcelableArray(Notification.EXTRA_MESSAGES) ) val user = extras.getParcelable(Notification.EXTRA_MESSAGING_PERSON) for (i in messages.indices.reversed()) { val message = messages[i] val sender = message.senderPerson if (sender != null && sender !== user) { placeholderIcon = message.senderPerson!!.icon break } } // Fall back to notification large icon if available if (placeholderIcon == null) { placeholderIcon = entry.sbn.notification.getLargeIcon() } // Revert to small icon if still not available if (placeholderIcon == null) { placeholderIcon = entry.sbn.notification.smallIcon } if (placeholderIcon == null) { throw InflationException("No icon in notification from " + entry.sbn.packageName) } return placeholderIcon } /** * Determines if this icon shows a conversation based on the sensitivity of the icon, its * context and the user's indicated sensitivity preference. If we're using a fall back icon of * the small icon, we don't consider this to be showing a conversation * * @param iconView The icon that shows the conversation. */ private fun showsConversation( entry: NotificationEntry, iconView: StatusBarIconView, iconDescriptor: StatusBarIcon, ): Boolean { val usedInSensitiveContext = iconView === entry.icons.shelfIcon || iconView === entry.icons.aodIcon val isSmallIcon = iconDescriptor.icon.equals(entry.sbn.notification.smallIcon) return isImportantConversation(entry) && !isSmallIcon && (!usedInSensitiveContext || !entry.isSensitive.value) } private fun isImportantConversation(entry: NotificationEntry): Boolean { // Also verify that the Notification is MessagingStyle, since we're going to access // MessagingStyle-specific data (EXTRA_MESSAGES, EXTRA_MESSAGING_PERSON). return entry.ranking.channel != null && entry.ranking.channel.isImportantConversation && entry.sbn.notification.isStyle(MessagingStyle::class.java) && entry.key !in unimportantConversationKeys } override fun setUnimportantConversations(keys: Collection) { val newKeys = keys.toSet() val changed = unimportantConversationKeys != newKeys unimportantConversationKeys = newKeys if (changed) { recalculateForImportantConversationChange() } } } private const val TAG = "IconManager" interface ConversationIconManager { /** * Sets the complete current set of notification keys which should (for the purposes of icon * presentation) be considered unimportant. This tells the icon manager to remove the avatar of * a group from which the priority notification has been removed. */ fun setUnimportantConversations(keys: Collection) }