• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2024 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 package com.android.systemui.statusbar.notification.headsup
17 
18 import android.os.Handler
19 import androidx.annotation.VisibleForTesting
20 import com.android.internal.logging.UiEvent
21 import com.android.internal.logging.UiEventLogger
22 import com.android.systemui.Dumpable
23 import com.android.systemui.dagger.SysUISingleton
24 import com.android.systemui.dagger.qualifiers.Background
25 import com.android.systemui.dump.DumpManager
26 import com.android.systemui.statusbar.chips.notification.shared.StatusBarNotifChips
27 import com.android.systemui.statusbar.notification.headsup.HeadsUpManagerImpl.HeadsUpEntry
28 import com.android.systemui.statusbar.notification.shared.NotificationThrottleHun
29 import java.io.PrintWriter
30 import javax.inject.Inject
31 
32 /*
33  * Control when heads up notifications show during an avalanche where notifications arrive in fast
34  * succession, by delaying visual listener side effects and removal handling from
35  * [HeadsUpManagerImpl].
36  *
37  * Dev note: disable suppression so avoid 2min period of no HUNs after every build
38  * Settings > Notifications > General > Notification cooldown
39  */
40 @SysUISingleton
41 class AvalancheController
42 @Inject
43 constructor(
44     dumpManager: DumpManager,
45     private val uiEventLogger: UiEventLogger,
46     private val headsUpManagerLogger: HeadsUpManagerLogger,
47     @Background private val bgHandler: Handler,
48 ) : Dumpable {
49 
50     private val tag = "AvalancheController"
51     private val debug = false // Compile.IS_DEBUG && Log.isLoggable(tag, Log.DEBUG)
52     var baseEntryMapStr: () -> String = { "baseEntryMapStr not initialized" }
53 
54     var enableAtRuntime = true
55         set(value) {
56             if (!value) {
57                 // Waiting HUNs in AvalancheController are shown in the HUN section in open shade.
58                 // Clear them so we don't show them again when the shade closes and reordering is
59                 // allowed again.
60                 logDroppedHunsInBackground(getWaitingKeys().size)
61                 clearNext()
62                 headsUpEntryShowing = null
63             }
64             if (field != value) {
65                 field = value
66             }
67         }
68 
69     // HUN showing right now, in the floating state where full shade is hidden, on launcher or AOD
70     @VisibleForTesting var headsUpEntryShowing: HeadsUpEntry? = null
71 
72     // Key of HUN previously showing, is being removed or was removed
73     var previousHunKey: String = ""
74 
75     // List of runnables to run for the HUN showing right now
76     private var headsUpEntryShowingRunnableList: MutableList<Runnable> = ArrayList()
77 
78     // HeadsUpEntry waiting to show
79     // Use sortable list instead of priority queue for debugging
80     private val nextList: MutableList<HeadsUpEntry> = ArrayList()
81 
82     // Map of HeadsUpEntry waiting to show, and runnables to run when it shows.
83     // Use HashMap instead of SortedMap for faster lookup, and also because the ordering
84     // provided by HeadsUpEntry.compareTo is not consistent over time or with HeadsUpEntry.equals
85     @VisibleForTesting var nextMap: MutableMap<HeadsUpEntry, MutableList<Runnable>> = HashMap()
86 
87     // Map of Runnable to label for debugging only
88     private val debugRunnableLabelMap: MutableMap<Runnable, String> = HashMap()
89 
90     enum class ThrottleEvent(private val id: Int) : UiEventLogger.UiEventEnum {
91         @UiEvent(doc = "HUN was shown.") AVALANCHE_THROTTLING_HUN_SHOWN(1821),
92         @UiEvent(doc = "HUN was dropped to show higher priority HUNs.")
93         AVALANCHE_THROTTLING_HUN_DROPPED(1822),
94         @UiEvent(doc = "HUN was removed while waiting to show.")
95         AVALANCHE_THROTTLING_HUN_REMOVED(1823);
96 
97         override fun getId(): Int {
98             return id
99         }
100     }
101 
102     init {
103         dumpManager.registerNormalDumpable(tag, /* module */ this)
104     }
105 
106     fun getShowingHunKey(): String {
107         return getKey(headsUpEntryShowing)
108     }
109 
110     fun isEnabled(): Boolean {
111         return NotificationThrottleHun.isEnabled && enableAtRuntime
112     }
113 
114     /** Run or delay Runnable for given HeadsUpEntry */
115     fun update(entry: HeadsUpEntry?, runnable: Runnable?, caller: String) {
116         val isEnabled = isEnabled()
117         val key = getKey(entry)
118 
119         if (runnable == null) {
120             headsUpManagerLogger.logAvalancheUpdate(
121                 caller,
122                 isEnabled,
123                 key,
124                 "Runnable NULL, stop. ${getStateStr()}",
125             )
126             return
127         }
128         if (!isEnabled) {
129             headsUpManagerLogger.logAvalancheUpdate(
130                 caller,
131                 isEnabled,
132                 key,
133                 "NOT ENABLED, run runnable. ${getStateStr()}",
134             )
135             runnable.run()
136             return
137         }
138         if (entry == null) {
139             headsUpManagerLogger.logAvalancheUpdate(
140                 caller,
141                 isEnabled,
142                 key,
143                 "Entry NULL, stop. ${getStateStr()}",
144             )
145             return
146         }
147         if (debug) {
148             debugRunnableLabelMap[runnable] = caller
149         }
150         var outcome = ""
151         if (isShowing(entry)) {
152             outcome = "update showing"
153             runnable.run()
154         } else if (entry in nextMap) {
155             outcome = "update next"
156             nextMap[entry]?.add(runnable)
157             checkNextPinnedByUser(entry)?.let { outcome = "$outcome & $it" }
158         } else if (headsUpEntryShowing == null) {
159             outcome = "show now"
160             showNow(entry, arrayListOf(runnable))
161         } else {
162             // Clean up invalid state when entry is in list but not map and vice versa
163             if (entry in nextMap) nextMap.remove(entry)
164             if (entry in nextList) nextList.remove(entry)
165 
166             outcome = "add next"
167             addToNext(entry, runnable)
168 
169             val nextIsPinnedByUserResult = checkNextPinnedByUser(entry)
170             if (nextIsPinnedByUserResult != null) {
171                 outcome = "$outcome & $nextIsPinnedByUserResult"
172             } else {
173                 // Shorten headsUpEntryShowing display time
174                 val nextIndex = nextList.indexOf(entry)
175                 val isOnlyNextEntry = nextIndex == 0 && nextList.size == 1
176                 if (isOnlyNextEntry) {
177                     // HeadsUpEntry.updateEntry recursively calls AvalancheController#update
178                     // and goes to the isShowing case above
179                     headsUpEntryShowing!!.updateEntry(
180                         /* updatePostTime= */ false,
181                         /* updateEarliestRemovalTime= */ false,
182                         /* reason= */ "shorten duration of previously-last HUN",
183                     )
184                 }
185             }
186         }
187         outcome += getStateStr()
188         headsUpManagerLogger.logAvalancheUpdate(caller, isEnabled, key, outcome)
189     }
190 
191     @VisibleForTesting
192     fun addToNext(entry: HeadsUpEntry, runnable: Runnable) {
193         nextMap[entry] = arrayListOf(runnable)
194         nextList.add(entry)
195     }
196 
197     /**
198      * Checks if the given entry is requesting [PinnedStatus.PinnedByUser] status and makes the
199      * correct updates if needed.
200      *
201      * @return a string representing the outcome, or null if nothing changed.
202      */
203     private fun checkNextPinnedByUser(entry: HeadsUpEntry): String? {
204         if (
205             StatusBarNotifChips.isEnabled &&
206                 entry.requestedPinnedStatus == PinnedStatus.PinnedByUser
207         ) {
208             val string = "next is PinnedByUser"
209             headsUpEntryShowing?.updateEntry(
210                 /* updatePostTime= */ false,
211                 /* updateEarliestRemovalTime= */ false,
212                 /* reason= */ string,
213             )
214             return string
215         }
216         return null
217     }
218 
219     /**
220      * Run or ignore Runnable for given HeadsUpEntry. If entry was never shown, ignore and delete
221      * all Runnables associated with that entry.
222      */
223     fun delete(entry: HeadsUpEntry?, runnable: Runnable?, caller: String) {
224         val isEnabled = isEnabled()
225         val key = getKey(entry)
226 
227         if (runnable == null) {
228             headsUpManagerLogger.logAvalancheDelete(
229                 caller,
230                 isEnabled,
231                 key,
232                 "Runnable NULL, stop. ${getStateStr()}",
233             )
234             return
235         }
236         if (!isEnabled) {
237             runnable.run()
238             headsUpManagerLogger.logAvalancheDelete(
239                 caller,
240                 isEnabled = false,
241                 key,
242                 "NOT ENABLED, run runnable. ${getStateStr()}",
243             )
244             return
245         }
246         if (entry == null) {
247             runnable.run()
248             headsUpManagerLogger.logAvalancheDelete(
249                 caller,
250                 isEnabled = true,
251                 key,
252                 "Entry NULL, run runnable. ${getStateStr()}",
253             )
254             return
255         }
256         val outcome: String
257         if (entry in nextMap) {
258             if (entry in nextMap) nextMap.remove(entry)
259             if (entry in nextList) nextList.remove(entry)
260             uiEventLogger.log(ThrottleEvent.AVALANCHE_THROTTLING_HUN_REMOVED)
261             outcome = "remove from next. ${getStateStr()}"
262         } else if (isShowing(entry)) {
263             previousHunKey = getKey(headsUpEntryShowing)
264             // Show the next HUN before removing this one, so that we don't tell listeners
265             // onHeadsUpPinnedModeChanged, which causes
266             // NotificationPanelViewController.updateTouchableRegion to hide the window while the
267             // HUN is animating out, resulting in a flicker.
268             showNext()
269             runnable.run()
270             outcome = "remove showing. ${getStateStr()}"
271         } else {
272             runnable.run()
273             outcome =
274                 "run runnable for untracked HUN " +
275                     "(was dropped or shown when AC was disabled). ${getStateStr()}"
276         }
277         headsUpManagerLogger.logAvalancheDelete(caller, isEnabled(), getKey(entry), outcome)
278     }
279 
280     /**
281      * Returns how much longer the given entry should show based on:
282      * 1) Whether HeadsUpEntry is the last one tracked by AvalancheController
283      * 2) The priority of the top HUN in the next batch
284      *
285      * Used by [HeadsUpManagerImpl.HeadsUpEntry]'s finishTimeCalculator to shorten display duration.
286      */
287     fun getDuration(entry: HeadsUpEntry?, autoDismissMsValue: Int): RemainingDuration {
288         val autoDismissMs = RemainingDuration.UpdatedDuration(autoDismissMsValue)
289         if (!isEnabled()) {
290             // Use default duration, like we did before AvalancheController existed
291             return autoDismissMs
292         }
293         if (entry == null) {
294             // This should never happen
295             return autoDismissMs
296         }
297         val showingList: MutableList<HeadsUpEntry> = mutableListOf()
298         if (headsUpEntryShowing != null) {
299             showingList.add(headsUpEntryShowing!!)
300         }
301         nextList.sort()
302         val entryList = showingList + nextList
303         val thisKey = getKey(entry)
304         if (entryList.isEmpty()) {
305             headsUpManagerLogger.logAvalancheDuration(
306                 thisKey,
307                 autoDismissMs,
308                 "No avalanche HUNs, use default",
309                 nextKey = "",
310             )
311             return autoDismissMs
312         }
313         // entryList.indexOf(entry) returns -1 even when the entry is in entryList
314         var thisEntryIndex = -1
315         for ((i, e) in entryList.withIndex()) {
316             if (e == entry) {
317                 thisEntryIndex = i
318             }
319         }
320         if (thisEntryIndex == -1) {
321             headsUpManagerLogger.logAvalancheDuration(
322                 thisKey,
323                 autoDismissMs,
324                 "Untracked entry, use default",
325                 nextKey = "",
326             )
327             return autoDismissMs
328         }
329         val nextEntryIndex = thisEntryIndex + 1
330         if (nextEntryIndex >= entryList.size) {
331             headsUpManagerLogger.logAvalancheDuration(
332                 thisKey,
333                 autoDismissMs,
334                 "Last entry, use default",
335                 nextKey = "",
336             )
337             return autoDismissMs
338         }
339         val nextEntry = entryList[nextEntryIndex]
340         val nextKey = getKey(nextEntry)
341 
342         if (
343             StatusBarNotifChips.isEnabled &&
344                 nextEntry.requestedPinnedStatus == PinnedStatus.PinnedByUser
345         ) {
346             return RemainingDuration.HideImmediately.also {
347                 headsUpManagerLogger.logAvalancheDuration(
348                     thisKey,
349                     duration = it,
350                     "next is PinnedByUser",
351                     nextKey,
352                 )
353             }
354         }
355         if (nextEntry.compareNonTimeFields(entry) == -1) {
356             return RemainingDuration.UpdatedDuration(500).also {
357                 headsUpManagerLogger.logAvalancheDuration(
358                     thisKey,
359                     duration = it,
360                     "LOWER priority than next: ",
361                     nextKey,
362                 )
363             }
364         } else if (nextEntry.compareNonTimeFields(entry) == 0) {
365             return RemainingDuration.UpdatedDuration(1000).also {
366                 headsUpManagerLogger.logAvalancheDuration(
367                     thisKey,
368                     duration = it,
369                     "SAME priority as next: ",
370                     nextKey,
371                 )
372             }
373         } else {
374             headsUpManagerLogger.logAvalancheDuration(
375                 thisKey,
376                 autoDismissMs,
377                 "HIGHER priority than next: ",
378                 nextKey,
379             )
380             return autoDismissMs
381         }
382     }
383 
384     /** Return true if entry is waiting to show. */
385     fun isWaiting(key: String): Boolean {
386         if (!isEnabled()) {
387             return false
388         }
389         for (entry in nextMap.keys) {
390             if (entry.mEntry?.key.equals(key)) {
391                 return true
392             }
393         }
394         return false
395     }
396 
397     /** Return list of keys for huns waiting */
398     fun getWaitingKeys(): MutableList<String> {
399         if (!isEnabled()) {
400             return mutableListOf()
401         }
402         val keyList = mutableListOf<String>()
403         for (entry in nextMap.keys) {
404             entry.mEntry?.let { keyList.add(entry.mEntry!!.key) }
405         }
406         return keyList
407     }
408 
409     fun getWaitingEntry(key: String): HeadsUpEntry? {
410         if (!isEnabled()) {
411             return null
412         }
413         for (headsUpEntry in nextMap.keys) {
414             if (headsUpEntry.mEntry?.key.equals(key)) {
415                 return headsUpEntry
416             }
417         }
418         return null
419     }
420 
421     fun getWaitingEntryList(): List<HeadsUpEntry> {
422         if (!isEnabled()) {
423             return mutableListOf()
424         }
425         return nextMap.keys.toList()
426     }
427 
428     private fun isShowing(entry: HeadsUpEntry): Boolean {
429         return headsUpEntryShowing != null && entry.mEntry?.key == headsUpEntryShowing?.mEntry?.key
430     }
431 
432     private fun showNow(entry: HeadsUpEntry, runnableList: MutableList<Runnable>) {
433         headsUpManagerLogger.logAvalancheStage("show", getKey(entry))
434         uiEventLogger.log(ThrottleEvent.AVALANCHE_THROTTLING_HUN_SHOWN)
435         headsUpEntryShowing = entry
436 
437         runnableList.forEach { runnable ->
438             if (debug) {
439                 debugRunnableLabelMap[runnable]?.let { label ->
440                     headsUpManagerLogger.logAvalancheStage("run", label)
441                     // Remove label after logging to avoid memory leak
442                     debugRunnableLabelMap.remove(runnable)
443                 }
444             }
445             runnable.run()
446         }
447     }
448 
449     private fun showNext() {
450         headsUpManagerLogger.logAvalancheStage("show next", key = "")
451         headsUpEntryShowing = null
452 
453         if (nextList.isEmpty()) {
454             headsUpManagerLogger.logAvalancheStage("no more", key = "")
455             previousHunKey = ""
456             return
457         }
458 
459         // Only show first (top priority) entry in next batch
460         nextList.sort()
461         headsUpEntryShowing = nextList[0]
462         headsUpEntryShowingRunnableList = nextMap[headsUpEntryShowing]!!
463 
464         // Remove runnable labels for dropped huns
465         val listToDrop = nextList.subList(1, nextList.size)
466         logDroppedHunsInBackground(listToDrop.size)
467 
468         if (debug) {
469             // Clear runnable labels
470             for (e in listToDrop) {
471                 val runnableList = nextMap[e]!!
472                 for (r in runnableList) {
473                     debugRunnableLabelMap.remove(r)
474                 }
475             }
476         }
477 
478         val dropListStr = listToDrop.joinToString("\n ") { getKey(it) }
479         headsUpManagerLogger.logDroppedHuns(dropListStr)
480 
481         clearNext()
482         showNow(headsUpEntryShowing!!, headsUpEntryShowingRunnableList)
483     }
484 
485     private fun logDroppedHunsInBackground(numDropped: Int) {
486         bgHandler.post(
487             Runnable {
488                 // Do this in the background to avoid missing frames when closing the shade
489                 for (n in 1..numDropped) {
490                     uiEventLogger.log(ThrottleEvent.AVALANCHE_THROTTLING_HUN_DROPPED)
491                 }
492             }
493         )
494     }
495 
496     fun clearNext() {
497         nextList.clear()
498         nextMap.clear()
499     }
500 
501     // Methods below are for logging only ==========================================================
502 
503     private fun getStateStr(): String {
504         return "\n[AC state]" +
505             "\nshow: ${getKey(headsUpEntryShowing)}" +
506             "\nprevious: $previousHunKey" +
507             "\n$nextStr" +
508             "\n[HeadsUpManagerImpl.mHeadsUpEntryMap] " +
509             baseEntryMapStr() +
510             "\n"
511     }
512 
513     private val nextStr: String
514         get() {
515             val nextListStr = nextList.joinToString("\n ") { getKey(it) }
516             if (nextList.toSet() == nextMap.keys.toSet()) {
517                 return "next (${nextList.size}):\n $nextListStr"
518             }
519             // This should never happen
520             val nextMapStr = nextMap.keys.joinToString("\n ") { getKey(it) }
521             return "next list (${nextList.size}):\n $nextListStr" +
522                 "\nnext map (${nextMap.size}):\n $nextMapStr"
523         }
524 
525     fun getKey(entry: HeadsUpEntry?): String {
526         if (entry == null) {
527             return "HeadsUpEntry null"
528         }
529         if (entry.mEntry == null) {
530             return "HeadsUpEntry.mEntry null"
531         }
532         return entry.mEntry!!.key
533     }
534 
535     override fun dump(pw: PrintWriter, args: Array<out String>) {
536         pw.println("AvalancheController: ${getStateStr()}")
537     }
538 }
539