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