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