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 import com.android.internal.annotations.VisibleForTesting
41 import com.android.systemui.res.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 channel: NotificationChannel? = null
75     private var onSettingsClickListener: NotificationInfo.OnSettingsClickListener? = null
76 
77     // Caller should set this if they care about when we dismiss
78     var onFinishListener: OnChannelEditorDialogFinishedListener? = null
79 
80     // Channels handed to us from NotificationInfo
81     @VisibleForTesting
82     internal val channelList = 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 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         channel: 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         this.channel = channel
114 
115         // These will always start out the same
116         appNotificationsCurrentlyEnabled = appNotificationsEnabled
117 
118         channelGroupList.clear()
119         channelGroupList.addAll(fetchNotificationChannelGroups())
120         buildGroupNameLookup()
121         populateChannelList()
122         initDialog()
123 
124         prepared = true
125     }
126 
127     private fun buildGroupNameLookup() {
128         channelGroupList.forEach { group ->
129             if (group.id != null) {
130                 groupNameLookup[group.id] = group.name
131             }
132         }
133     }
134 
135     private fun populateChannelList() {
136         channelList.clear()
137         if (DEFAULT_CHANNEL_ID != channel!!.id) {
138             channelList.add(0, channel!!)
139             channelList.addAll(getDisplayableChannels(channelGroupList.asSequence())
140                     .filterNot { it.id == channel!!.id }
141                     .distinct())
142         }
143     }
144 
145     private fun getDisplayableChannels(
146         groupList: Sequence<NotificationChannelGroup>
147     ): Sequence<NotificationChannel> {
148         val channels = groupList
149                 .flatMap { group ->
150                     group.channels.asSequence()
151                             .sortedWith(compareBy {group.name?.toString() ?: group.id})
152                             .filterNot { channel ->
153                                 channel.isImportanceLockedByCriticalDeviceFunction
154                             }
155                 }
156 
157         // TODO: sort these by avgSentWeekly, but for now let's just do alphabetical (why not)
158         return channels.sortedWith(compareBy { it.name?.toString() ?: it.id })
159     }
160 
161     fun show() {
162         if (!prepared) {
163             throw IllegalStateException("Must call prepareDialogForApp() before calling show()")
164         }
165         dialog.show()
166     }
167 
168     /**
169      * Close the dialog without saving. For external callers
170      */
171     fun close() {
172         done()
173     }
174 
175     private fun done() {
176         resetState()
177         dialog.dismiss()
178     }
179 
180     private fun resetState() {
181         appIcon = null
182         appUid = null
183         packageName = null
184         appName = null
185         appNotificationsCurrentlyEnabled = null
186 
187         edits.clear()
188         channelList.clear()
189         groupNameLookup.clear()
190     }
191 
192     fun groupNameForId(groupId: String?): CharSequence {
193         return groupNameLookup[groupId] ?: ""
194     }
195 
196     fun proposeEditForChannel(channel: NotificationChannel, @Importance edit: Int) {
197         if (channel.importance == edit) {
198             edits.remove(channel)
199         } else {
200             edits[channel] = edit
201         }
202 
203         dialog.updateDoneButtonText(hasChanges())
204     }
205 
206     fun proposeSetAppNotificationsEnabled(enabled: Boolean) {
207         appNotificationsEnabled = enabled
208         dialog.updateDoneButtonText(hasChanges())
209     }
210 
211     fun areAppNotificationsEnabled(): Boolean {
212         return appNotificationsEnabled
213     }
214 
215     private fun hasChanges(): Boolean {
216         return edits.isNotEmpty() || (appNotificationsEnabled != appNotificationsCurrentlyEnabled)
217     }
218 
219     @Suppress("unchecked_cast")
220     private fun fetchNotificationChannelGroups(): List<NotificationChannelGroup> {
221         return try {
222             noMan.getRecentBlockedNotificationChannelGroupsForPackage(packageName!!, appUid!!)
223                     .list as? List<NotificationChannelGroup> ?: listOf()
224         } catch (e: Exception) {
225             Log.e(TAG, "Error fetching channel groups", e)
226             listOf()
227         }
228     }
229 
230     private fun checkAreAppNotificationsOn(): Boolean {
231         return try {
232             noMan.areNotificationsEnabledForPackage(packageName!!, appUid!!)
233         } catch (e: Exception) {
234             Log.e(TAG, "Error calling NoMan", e)
235             false
236         }
237     }
238 
239     private fun applyAppNotificationsOn(b: Boolean) {
240         try {
241             noMan.setNotificationsEnabledForPackage(packageName!!, appUid!!, b)
242         } catch (e: Exception) {
243             Log.e(TAG, "Error calling NoMan", e)
244         }
245     }
246 
247     private fun setChannelImportance(channel: NotificationChannel, importance: Int) {
248         try {
249             channel.importance = importance
250             noMan.updateNotificationChannelForPackage(packageName!!, appUid!!, channel)
251         } catch (e: Exception) {
252             Log.e(TAG, "Unable to update notification importance", e)
253         }
254     }
255 
256     @VisibleForTesting
257     fun apply() {
258         for ((channel, importance) in edits) {
259             if (channel.importance != importance) {
260                 setChannelImportance(channel, importance)
261             }
262         }
263 
264         if (appNotificationsEnabled != appNotificationsCurrentlyEnabled) {
265             applyAppNotificationsOn(appNotificationsEnabled)
266         }
267     }
268 
269     @VisibleForTesting
270     fun launchSettings(sender: View) {
271         onSettingsClickListener?.onClick(sender, channel, appUid!!)
272     }
273 
274     private fun initDialog() {
275         dialogBuilder.setContext(context)
276         dialog = dialogBuilder.build()
277 
278         dialog.window?.requestFeature(Window.FEATURE_NO_TITLE)
279         // Prevent a11y readers from reading the first element in the dialog twice
280         dialog.setTitle("\u00A0")
281         dialog.apply {
282             setContentView(R.layout.notif_half_shelf)
283             setCanceledOnTouchOutside(true)
284             setOnDismissListener { onFinishListener?.onChannelEditorDialogFinished() }
285 
286             val listView = findViewById<ChannelEditorListView>(R.id.half_shelf_container)
287             listView?.apply {
288                 controller = this@ChannelEditorDialogController
289                 appIcon = this@ChannelEditorDialogController.appIcon
290                 appName = this@ChannelEditorDialogController.appName
291                 channels = channelList
292             }
293 
294             setOnShowListener {
295                 // play a highlight animation for the given channel
296                 listView?.highlightChannel(channel!!)
297             }
298 
299             findViewById<TextView>(R.id.done_button)?.setOnClickListener {
300                 apply()
301                 done()
302             }
303 
304             findViewById<TextView>(R.id.see_more_button)?.setOnClickListener {
305                 launchSettings(it)
306                 done()
307             }
308 
309             window?.apply {
310                 setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
311                 addFlags(wmFlags)
312                 setType(WindowManager.LayoutParams.TYPE_STATUS_BAR_SUB_PANEL)
313                 setWindowAnimations(com.android.internal.R.style.Animation_InputMethod)
314 
315                 attributes = attributes.apply {
316                     format = PixelFormat.TRANSLUCENT
317                     title = ChannelEditorDialogController::class.java.simpleName
318                     gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
319                     fitInsetsTypes = attributes.fitInsetsTypes and statusBars().inv()
320                     width = MATCH_PARENT
321                     height = WRAP_CONTENT
322                 }
323             }
324         }
325     }
326 
327     private val wmFlags = (WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS
328             or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
329             or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED)
330 }
331 
332 class ChannelEditorDialog(context: Context, theme: Int) : Dialog(context, theme) {
updateDoneButtonTextnull333     fun updateDoneButtonText(hasChanges: Boolean) {
334         findViewById<TextView>(R.id.done_button)?.setText(
335                 if (hasChanges)
336                     R.string.inline_ok_button
337                 else
338                     R.string.inline_done_button)
339     }
340 
341     class Builder @Inject constructor() {
342         private lateinit var context: Context
setContextnull343         fun setContext(context: Context): Builder {
344             this.context = context
345             return this
346         }
347 
buildnull348         fun build(): ChannelEditorDialog {
349             return ChannelEditorDialog(context, R.style.Theme_SystemUI_Dialog)
350         }
351     }
352 }
353 
354 interface OnChannelEditorDialogFinishedListener {
onChannelEditorDialogFinishednull355     fun onChannelEditorDialogFinished()
356 }
357