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