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