1 /* 2 * Copyright (C) 2019 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License 15 */ 16 17 package com.android.systemui.statusbar.notification.row 18 19 import android.app.Dialog 20 import android.app.INotificationManager 21 import android.app.NotificationChannel 22 import android.app.NotificationChannel.DEFAULT_CHANNEL_ID 23 import android.app.NotificationChannelGroup 24 import android.app.NotificationManager.IMPORTANCE_NONE 25 import android.app.NotificationManager.Importance 26 import android.content.Context 27 import android.graphics.Color 28 import android.graphics.PixelFormat 29 import android.graphics.drawable.ColorDrawable 30 import android.graphics.drawable.Drawable 31 import android.util.Log 32 import android.view.Gravity 33 import android.view.View 34 import android.view.ViewGroup.LayoutParams.MATCH_PARENT 35 import android.view.ViewGroup.LayoutParams.WRAP_CONTENT 36 import android.view.Window 37 import android.view.WindowInsets.Type.statusBars 38 import android.view.WindowManager 39 import android.widget.TextView 40 import com.android.internal.annotations.VisibleForTesting 41 import com.android.systemui.R 42 import com.android.systemui.dagger.SysUISingleton 43 import javax.inject.Inject 44 45 private const val TAG = "ChannelDialogController" 46 47 /** 48 * ChannelEditorDialogController is the controller for the dialog half-shelf 49 * that allows users to quickly turn off channels. It is launched from the NotificationInfo 50 * guts view and displays controls for toggling app notifications as well as up to 4 channels 51 * from that app like so: 52 * 53 * APP TOGGLE <on/off> 54 * - Channel from which we launched <on/off> 55 * - <on/off> 56 * - the next 3 channels sorted alphabetically for that app <on/off> 57 * - <on/off> 58 */ 59 @SysUISingleton 60 class ChannelEditorDialogController @Inject constructor( 61 c: Context, 62 private val noMan: INotificationManager, 63 private val dialogBuilder: ChannelEditorDialog.Builder 64 ) { 65 val context: Context = c.applicationContext 66 67 private var prepared = false 68 private lateinit var dialog: ChannelEditorDialog 69 70 private var appIcon: Drawable? = null 71 private var appUid: Int? = null 72 private var packageName: String? = null 73 private var appName: String? = null 74 private var onSettingsClickListener: NotificationInfo.OnSettingsClickListener? = null 75 76 // Caller should set this if they care about when we dismiss 77 var onFinishListener: OnChannelEditorDialogFinishedListener? = null 78 79 @VisibleForTesting 80 internal val paddedChannels = mutableListOf<NotificationChannel>() 81 // Channels handed to us from NotificationInfo 82 private val providedChannels = mutableListOf<NotificationChannel>() 83 84 // Map from NotificationChannel to importance 85 private val edits = mutableMapOf<NotificationChannel, Int>() 86 private var appNotificationsEnabled = true 87 // System settings for app notifications 88 private var appNotificationsCurrentlyEnabled: Boolean? = null 89 90 // Keep a mapping of NotificationChannel.getGroup() to the actual group name for display 91 @VisibleForTesting 92 internal val groupNameLookup = hashMapOf<String, CharSequence>() 93 private val channelGroupList = mutableListOf<NotificationChannelGroup>() 94 95 /** 96 * Give the controller all of the information it needs to present the dialog 97 * for a given app. Does a bunch of querying of NoMan, but won't present anything yet 98 */ 99 fun prepareDialogForApp( 100 appName: String, 101 packageName: String, 102 uid: Int, 103 channels: Set<NotificationChannel>, 104 appIcon: Drawable, 105 onSettingsClickListener: NotificationInfo.OnSettingsClickListener? 106 ) { 107 this.appName = appName 108 this.packageName = packageName 109 this.appUid = uid 110 this.appIcon = appIcon 111 this.appNotificationsEnabled = checkAreAppNotificationsOn() 112 this.onSettingsClickListener = onSettingsClickListener 113 114 // These will always start out the same 115 appNotificationsCurrentlyEnabled = appNotificationsEnabled 116 117 channelGroupList.clear() 118 channelGroupList.addAll(fetchNotificationChannelGroups()) 119 buildGroupNameLookup() 120 providedChannels.clear() 121 providedChannels.addAll(channels) 122 padToFourChannels(channels) 123 initDialog() 124 125 prepared = true 126 } 127 128 private fun buildGroupNameLookup() { 129 channelGroupList.forEach { group -> 130 if (group.id != null) { 131 groupNameLookup[group.id] = group.name 132 } 133 } 134 } 135 136 private fun padToFourChannels(channels: Set<NotificationChannel>) { 137 paddedChannels.clear() 138 // First, add all of the given channels 139 paddedChannels.addAll(channels.asSequence().take(4)) 140 141 // Then pad to 4 if we haven't been given that many 142 paddedChannels.addAll(getDisplayableChannels(channelGroupList.asSequence()) 143 .filterNot { paddedChannels.contains(it) } 144 .distinct() 145 .take(4 - paddedChannels.size)) 146 147 // If we only got one channel and it has the default miscellaneous tag, then we actually 148 // are looking at an app with a targetSdk <= O, and it doesn't make much sense to show the 149 // channel 150 if (paddedChannels.size == 1 && DEFAULT_CHANNEL_ID == paddedChannels[0].id) { 151 paddedChannels.clear() 152 } 153 } 154 155 private fun getDisplayableChannels( 156 groupList: Sequence<NotificationChannelGroup> 157 ): Sequence<NotificationChannel> { 158 159 val channels = groupList 160 .flatMap { group -> 161 group.channels.asSequence().filterNot { channel -> 162 channel.isImportanceLockedByOEM || 163 channel.importance == IMPORTANCE_NONE || 164 channel.isImportanceLockedByCriticalDeviceFunction 165 } 166 } 167 168 // TODO: sort these by avgSentWeekly, but for now let's just do alphabetical (why not) 169 return channels.sortedWith(compareBy { it.name?.toString() ?: it.id }) 170 } 171 172 fun show() { 173 if (!prepared) { 174 throw IllegalStateException("Must call prepareDialogForApp() before calling show()") 175 } 176 dialog.show() 177 } 178 179 /** 180 * Close the dialog without saving. For external callers 181 */ 182 fun close() { 183 done() 184 } 185 186 private fun done() { 187 resetState() 188 dialog.dismiss() 189 } 190 191 private fun resetState() { 192 appIcon = null 193 appUid = null 194 packageName = null 195 appName = null 196 appNotificationsCurrentlyEnabled = null 197 198 edits.clear() 199 paddedChannels.clear() 200 providedChannels.clear() 201 groupNameLookup.clear() 202 } 203 204 fun groupNameForId(groupId: String?): CharSequence { 205 return groupNameLookup[groupId] ?: "" 206 } 207 208 fun proposeEditForChannel(channel: NotificationChannel, @Importance edit: Int) { 209 if (channel.importance == edit) { 210 edits.remove(channel) 211 } else { 212 edits[channel] = edit 213 } 214 215 dialog.updateDoneButtonText(hasChanges()) 216 } 217 218 fun proposeSetAppNotificationsEnabled(enabled: Boolean) { 219 appNotificationsEnabled = enabled 220 dialog.updateDoneButtonText(hasChanges()) 221 } 222 223 fun areAppNotificationsEnabled(): Boolean { 224 return appNotificationsEnabled 225 } 226 227 private fun hasChanges(): Boolean { 228 return edits.isNotEmpty() || (appNotificationsEnabled != appNotificationsCurrentlyEnabled) 229 } 230 231 @Suppress("unchecked_cast") 232 private fun fetchNotificationChannelGroups(): List<NotificationChannelGroup> { 233 return try { 234 noMan.getNotificationChannelGroupsForPackage(packageName!!, appUid!!, false) 235 .list as? List<NotificationChannelGroup> ?: listOf() 236 } catch (e: Exception) { 237 Log.e(TAG, "Error fetching channel groups", e) 238 listOf() 239 } 240 } 241 242 private fun checkAreAppNotificationsOn(): Boolean { 243 return try { 244 noMan.areNotificationsEnabledForPackage(packageName!!, appUid!!) 245 } catch (e: Exception) { 246 Log.e(TAG, "Error calling NoMan", e) 247 false 248 } 249 } 250 251 private fun applyAppNotificationsOn(b: Boolean) { 252 try { 253 noMan.setNotificationsEnabledForPackage(packageName!!, appUid!!, b) 254 } catch (e: Exception) { 255 Log.e(TAG, "Error calling NoMan", e) 256 } 257 } 258 259 private fun setChannelImportance(channel: NotificationChannel, importance: Int) { 260 try { 261 channel.importance = importance 262 noMan.updateNotificationChannelForPackage(packageName!!, appUid!!, channel) 263 } catch (e: Exception) { 264 Log.e(TAG, "Unable to update notification importance", e) 265 } 266 } 267 268 @VisibleForTesting 269 fun apply() { 270 for ((channel, importance) in edits) { 271 if (channel.importance != importance) { 272 setChannelImportance(channel, importance) 273 } 274 } 275 276 if (appNotificationsEnabled != appNotificationsCurrentlyEnabled) { 277 applyAppNotificationsOn(appNotificationsEnabled) 278 } 279 } 280 281 @VisibleForTesting 282 fun launchSettings(sender: View) { 283 onSettingsClickListener?.onClick(sender, null, appUid!!) 284 } 285 286 private fun initDialog() { 287 dialogBuilder.setContext(context) 288 dialog = dialogBuilder.build() 289 290 dialog.window?.requestFeature(Window.FEATURE_NO_TITLE) 291 // Prevent a11y readers from reading the first element in the dialog twice 292 dialog.setTitle("\u00A0") 293 dialog.apply { 294 setContentView(R.layout.notif_half_shelf) 295 setCanceledOnTouchOutside(true) 296 setOnDismissListener { onFinishListener?.onChannelEditorDialogFinished() } 297 298 val listView = findViewById<ChannelEditorListView>(R.id.half_shelf_container) 299 listView?.apply { 300 controller = this@ChannelEditorDialogController 301 appIcon = this@ChannelEditorDialogController.appIcon 302 appName = this@ChannelEditorDialogController.appName 303 channels = paddedChannels 304 } 305 306 setOnShowListener { 307 // play a highlight animation for the given channels 308 for (channel in providedChannels) { 309 listView?.highlightChannel(channel) 310 } 311 } 312 313 findViewById<TextView>(R.id.done_button)?.setOnClickListener { 314 apply() 315 done() 316 } 317 318 findViewById<TextView>(R.id.see_more_button)?.setOnClickListener { 319 launchSettings(it) 320 done() 321 } 322 323 window?.apply { 324 setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) 325 addFlags(wmFlags) 326 setType(WindowManager.LayoutParams.TYPE_STATUS_BAR_SUB_PANEL) 327 setWindowAnimations(com.android.internal.R.style.Animation_InputMethod) 328 329 attributes = attributes.apply { 330 format = PixelFormat.TRANSLUCENT 331 title = ChannelEditorDialogController::class.java.simpleName 332 gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL 333 fitInsetsTypes = attributes.fitInsetsTypes and statusBars().inv() 334 width = MATCH_PARENT 335 height = WRAP_CONTENT 336 } 337 } 338 } 339 } 340 341 private val wmFlags = (WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS 342 or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH 343 or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED) 344 } 345 346 class ChannelEditorDialog(context: Context) : Dialog(context) { 347 fun updateDoneButtonText(hasChanges: Boolean) { 348 findViewById<TextView>(R.id.done_button)?.setText( 349 if (hasChanges) 350 R.string.inline_ok_button 351 else 352 R.string.inline_done_button) 353 } 354 355 class Builder @Inject constructor() { 356 private lateinit var context: Context 357 fun setContext(context: Context): Builder { 358 this.context = context 359 return this 360 } 361 362 fun build(): ChannelEditorDialog { 363 return ChannelEditorDialog(context) 364 } 365 } 366 } 367 368 interface OnChannelEditorDialogFinishedListener { 369 fun onChannelEditorDialogFinished() 370 } 371