/* * Copyright (C) 2019 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.row import android.app.Dialog import android.app.INotificationManager import android.app.NotificationChannel import android.app.NotificationChannel.DEFAULT_CHANNEL_ID import android.app.NotificationChannelGroup import android.app.NotificationManager.Importance import android.content.Context import android.graphics.Color import android.graphics.PixelFormat import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.util.Log import android.view.Gravity import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.Window import android.view.WindowInsets.Type.statusBars import android.view.WindowManager import android.widget.TextView import com.android.internal.annotations.VisibleForTesting import com.android.systemui.dagger.SysUISingleton import com.android.systemui.res.R import com.android.systemui.shade.domain.interactor.ShadeDialogContextInteractor import javax.inject.Inject private const val TAG = "ChannelDialogController" /** * ChannelEditorDialogController is the controller for the dialog half-shelf that allows users to * quickly turn off channels. It is launched from the NotificationInfo guts view and displays * controls for toggling app notifications as well as up to 4 channels from that app like so: * * APP TOGGLE * - Channel from which we launched * - * - the next 3 channels sorted alphabetically for that app * - */ @SysUISingleton class ChannelEditorDialogController @Inject constructor( private val shadeDialogContextInteractor: ShadeDialogContextInteractor, private val noMan: INotificationManager, private val dialogBuilder: ChannelEditorDialog.Builder, ) { private var prepared = false private lateinit var dialog: ChannelEditorDialog private var appIcon: Drawable? = null private var appUid: Int? = null private var packageName: String? = null private var appName: String? = null private var channel: NotificationChannel? = null private var onSettingsClickListener: NotificationInfo.OnSettingsClickListener? = null // Caller should set this if they care about when we dismiss var onFinishListener: OnChannelEditorDialogFinishedListener? = null // Channels handed to us from NotificationInfo @VisibleForTesting internal val channelList = mutableListOf() // Map from NotificationChannel to importance private val edits = mutableMapOf() private var appNotificationsEnabled = true // System settings for app notifications private var appNotificationsCurrentlyEnabled: Boolean? = null // Keep a mapping of NotificationChannel.getGroup() to the actual group name for display @VisibleForTesting internal val groupNameLookup = hashMapOf() private val channelGroupList = mutableListOf() /** * Give the controller all the information it needs to present the dialog for a given app. Does * a bunch of querying of NoMan, but won't present anything yet */ fun prepareDialogForApp( appName: String, packageName: String, uid: Int, channel: NotificationChannel, appIcon: Drawable?, onSettingsClickListener: NotificationInfo.OnSettingsClickListener?, ) { this.appName = appName this.packageName = packageName this.appUid = uid this.appIcon = appIcon this.appNotificationsEnabled = checkAreAppNotificationsOn() this.onSettingsClickListener = onSettingsClickListener this.channel = channel // These will always start out the same appNotificationsCurrentlyEnabled = appNotificationsEnabled channelGroupList.clear() channelGroupList.addAll(fetchNotificationChannelGroups()) buildGroupNameLookup() populateChannelList() initDialog() prepared = true } private fun buildGroupNameLookup() { channelGroupList.forEach { group -> if (group.id != null) { groupNameLookup[group.id] = group.name } } } private fun populateChannelList() { channelList.clear() if (DEFAULT_CHANNEL_ID != channel!!.id) { channelList.add(0, channel!!) channelList.addAll( getDisplayableChannels(channelGroupList.asSequence()) .filterNot { it.id == channel!!.id } .distinct() ) } } private fun getDisplayableChannels( groupList: Sequence ): Sequence { val channels = groupList.flatMap { group -> group.channels .asSequence() .sortedWith(compareBy { group.name?.toString() ?: group.id }) .filterNot { channel -> channel.isImportanceLockedByCriticalDeviceFunction } } // TODO: sort these by avgSentWeekly, but for now let's just do alphabetical (why not) return channels.sortedWith(compareBy { it.name?.toString() ?: it.id }) } fun show() { if (!prepared) { throw IllegalStateException("Must call prepareDialogForApp() before calling show()") } dialog.show() } /** Close the dialog without saving. For external callers */ fun close() { done() } private fun done() { resetState() dialog.dismiss() } private fun resetState() { appIcon = null appUid = null packageName = null appName = null appNotificationsCurrentlyEnabled = null edits.clear() channelList.clear() groupNameLookup.clear() } fun groupNameForId(groupId: String?): CharSequence { return groupNameLookup[groupId] ?: "" } fun proposeEditForChannel(channel: NotificationChannel, @Importance edit: Int) { if (channel.importance == edit) { edits.remove(channel) } else { edits[channel] = edit } dialog.updateDoneButtonText(hasChanges()) } fun proposeSetAppNotificationsEnabled(enabled: Boolean) { appNotificationsEnabled = enabled dialog.updateDoneButtonText(hasChanges()) } fun areAppNotificationsEnabled(): Boolean { return appNotificationsEnabled } private fun hasChanges(): Boolean { return edits.isNotEmpty() || (appNotificationsEnabled != appNotificationsCurrentlyEnabled) } @Suppress("unchecked_cast") private fun fetchNotificationChannelGroups(): List { return try { noMan.getRecentBlockedNotificationChannelGroupsForPackage(packageName!!, appUid!!).list as? List ?: listOf() } catch (e: Exception) { Log.e(TAG, "Error fetching channel groups", e) listOf() } } private fun checkAreAppNotificationsOn(): Boolean { return try { noMan.areNotificationsEnabledForPackage(packageName!!, appUid!!) } catch (e: Exception) { Log.e(TAG, "Error calling NoMan", e) false } } private fun applyAppNotificationsOn(b: Boolean) { try { noMan.setNotificationsEnabledForPackage(packageName!!, appUid!!, b) } catch (e: Exception) { Log.e(TAG, "Error calling NoMan", e) } } private fun setChannelImportance(channel: NotificationChannel, importance: Int) { try { channel.importance = importance noMan.updateNotificationChannelForPackage(packageName!!, appUid!!, channel) } catch (e: Exception) { Log.e(TAG, "Unable to update notification importance", e) } } @VisibleForTesting fun apply() { for ((channel, importance) in edits) { if (channel.importance != importance) { setChannelImportance(channel, importance) } } if (appNotificationsEnabled != appNotificationsCurrentlyEnabled) { applyAppNotificationsOn(appNotificationsEnabled) } } @VisibleForTesting fun launchSettings(sender: View) { onSettingsClickListener?.onClick(sender, channel, appUid!!) } private fun initDialog() { dialogBuilder.setContext(shadeDialogContextInteractor.context) dialog = dialogBuilder.build() dialog.window?.requestFeature(Window.FEATURE_NO_TITLE) // Prevent a11y readers from reading the first element in the dialog twice dialog.setTitle("\u00A0") dialog.apply { setContentView(R.layout.notif_half_shelf) setCanceledOnTouchOutside(true) setOnDismissListener { onFinishListener?.onChannelEditorDialogFinished() } val listView = findViewById(R.id.half_shelf_container) listView?.apply { controller = this@ChannelEditorDialogController appIcon = this@ChannelEditorDialogController.appIcon appName = this@ChannelEditorDialogController.appName channels = channelList } setOnShowListener { // play a highlight animation for the given channel listView?.highlightChannel(channel!!) } findViewById(R.id.done_button)?.setOnClickListener { apply() done() } findViewById(R.id.see_more_button)?.setOnClickListener { launchSettings(it) done() } window?.apply { setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) addFlags(wmFlags) setType(WindowManager.LayoutParams.TYPE_STATUS_BAR_SUB_PANEL) setWindowAnimations(com.android.internal.R.style.Animation_InputMethod) attributes = attributes.apply { format = PixelFormat.TRANSLUCENT title = ChannelEditorDialogController::class.java.simpleName gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL fitInsetsTypes = attributes.fitInsetsTypes and statusBars().inv() width = MATCH_PARENT height = WRAP_CONTENT } } } } private val wmFlags = (WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED) } class ChannelEditorDialog(context: Context, theme: Int) : Dialog(context, theme) { fun updateDoneButtonText(hasChanges: Boolean) { findViewById(R.id.done_button) ?.setText(if (hasChanges) R.string.inline_ok_button else R.string.inline_done_button) } class Builder @Inject constructor() { private lateinit var context: Context fun setContext(context: Context): Builder { this.context = context return this } fun build(): ChannelEditorDialog { return ChannelEditorDialog(context, R.style.Theme_SystemUI_Dialog) } } } interface OnChannelEditorDialogFinishedListener { fun onChannelEditorDialogFinished() }