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 // TODO (b/194833441): remove channel level settings when we move to a permission 160 val channels = groupList 161 .flatMap { group -> 162 group.channels.asSequence().filterNot { channel -> 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 val channel = if (providedChannels.size == 1) providedChannels[0] else null 284 onSettingsClickListener?.onClick(sender, channel, appUid!!) 285 } 286 287 private fun initDialog() { 288 dialogBuilder.setContext(context) 289 dialog = dialogBuilder.build() 290 291 dialog.window?.requestFeature(Window.FEATURE_NO_TITLE) 292 // Prevent a11y readers from reading the first element in the dialog twice 293 dialog.setTitle("\u00A0") 294 dialog.apply { 295 setContentView(R.layout.notif_half_shelf) 296 setCanceledOnTouchOutside(true) 297 setOnDismissListener { onFinishListener?.onChannelEditorDialogFinished() } 298 299 val listView = findViewById<ChannelEditorListView>(R.id.half_shelf_container) 300 listView?.apply { 301 controller = this@ChannelEditorDialogController 302 appIcon = this@ChannelEditorDialogController.appIcon 303 appName = this@ChannelEditorDialogController.appName 304 channels = paddedChannels 305 } 306 307 setOnShowListener { 308 // play a highlight animation for the given channels 309 for (channel in providedChannels) { 310 listView?.highlightChannel(channel) 311 } 312 } 313 314 findViewById<TextView>(R.id.done_button)?.setOnClickListener { 315 apply() 316 done() 317 } 318 319 findViewById<TextView>(R.id.see_more_button)?.setOnClickListener { 320 launchSettings(it) 321 done() 322 } 323 324 window?.apply { 325 setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) 326 addFlags(wmFlags) 327 setType(WindowManager.LayoutParams.TYPE_STATUS_BAR_SUB_PANEL) 328 setWindowAnimations(com.android.internal.R.style.Animation_InputMethod) 329 330 attributes = attributes.apply { 331 format = PixelFormat.TRANSLUCENT 332 title = ChannelEditorDialogController::class.java.simpleName 333 gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL 334 fitInsetsTypes = attributes.fitInsetsTypes and statusBars().inv() 335 width = MATCH_PARENT 336 height = WRAP_CONTENT 337 } 338 } 339 } 340 } 341 342 private val wmFlags = (WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS 343 or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH 344 or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED) 345 } 346 347 class ChannelEditorDialog(context: Context, theme: Int) : Dialog(context, theme) { 348 fun updateDoneButtonText(hasChanges: Boolean) { 349 findViewById<TextView>(R.id.done_button)?.setText( 350 if (hasChanges) 351 R.string.inline_ok_button 352 else 353 R.string.inline_done_button) 354 } 355 356 class Builder @Inject constructor() { 357 private lateinit var context: Context 358 fun setContext(context: Context): Builder { 359 this.context = context 360 return this 361 } 362 363 fun build(): ChannelEditorDialog { 364 return ChannelEditorDialog(context, R.style.Theme_SystemUI_Dialog) 365 } 366 } 367 } 368 369 interface OnChannelEditorDialogFinishedListener { 370 fun onChannelEditorDialogFinished() 371 } 372