• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * 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 
17 package com.android.systemui.statusbar.notification.collection
18 
19 import android.annotation.SuppressLint
20 import com.android.internal.annotations.VisibleForTesting
21 import com.android.systemui.Dumpable
22 import com.android.systemui.util.asIndenting
23 import com.android.systemui.util.printCollection
24 import com.android.systemui.util.time.SystemClock
25 import com.android.systemui.util.time.SystemClockImpl
26 import com.android.systemui.util.withIncreasedIndent
27 import java.io.PrintWriter
28 import java.util.concurrent.ConcurrentHashMap
29 import java.util.concurrent.atomic.AtomicInteger
30 
31 /**
32  * A cache in which entries can "survive" getting purged [retainCount] times, given consecutive
33  * [purge] calls made at least [purgeTimeoutMillis] apart. See also [purge].
34  *
35  * This cache is safe for multithreaded usage, and is recommended for objects that take a while to
36  * resolve (such as drawables, or things that require binder calls). As such, [getOrFetch] is
37  * recommended to be run on a background thread, while [purge] can be done from any thread.
38  *
39  * Important: This cache does NOT have a maximum size, cleaning it up (via [purge]) is the
40  * responsibility of the caller, to avoid keeping things in memory unnecessarily.
41  */
42 @SuppressLint("DumpableNotRegistered") // this will be dumped by container classes
43 class NotifCollectionCache<V>(
44     private val retainCount: Int = 1,
45     private val purgeTimeoutMillis: Long = 1000L,
46     private val systemClock: SystemClock = SystemClockImpl(),
47 ) : Dumpable {
48     @get:VisibleForTesting val cache = ConcurrentHashMap<String, CacheEntry>()
49 
50     // Counters for cache hits and misses to be used to calculate and dump the hit ratio
51     @get:VisibleForTesting val misses = AtomicInteger(0)
52     @get:VisibleForTesting val hits = AtomicInteger(0)
53 
54     init {
55         if (retainCount < 0) {
56             throw IllegalArgumentException("retainCount cannot be negative")
57         }
58     }
59 
60     inner class CacheEntry(val key: String, val value: V) {
61         /**
62          * The "lives" represent how many times the entry will remain in the cache when purging it
63          * is attempted.
64          */
65         @get:VisibleForTesting var lives: Int = retainCount + 1
66         /**
67          * The last time this entry lost a "life". Starts at a negative value chosen so that the
68          * first purge is always considered "valid".
69          */
70         private var lastValidPurge: Long = -purgeTimeoutMillis
71 
resetLivesnull72         fun resetLives() {
73             // Lives/timeouts don't matter if retainCount is 0
74             if (retainCount == 0) {
75                 return
76             }
77 
78             synchronized(key) {
79                 lives = retainCount + 1
80                 lastValidPurge = -purgeTimeoutMillis
81             }
82             // Add it to the cache again just in case it was deleted before we could reset the lives
83             cache[key] = this
84         }
85 
tryPurgenull86         fun tryPurge(): Boolean {
87             // Lives/timeouts don't matter if retainCount is 0
88             if (retainCount == 0) {
89                 return true
90             }
91 
92             // Using elapsedRealtime since it's guaranteed to be monotonic, as we don't want a
93             // timezone/clock change to break us
94             val now = UseElapsedRealtimeForCreationTime.getCurrentTime(systemClock)
95 
96             // Cannot purge the same entry from two threads simultaneously
97             synchronized(key) {
98                 if (now - lastValidPurge < purgeTimeoutMillis) {
99                     return false
100                 }
101                 lastValidPurge = now
102                 return --lives <= 0
103             }
104         }
105 
toStringnull106         override fun toString(): String {
107             return "$key = $value"
108         }
109     }
110 
111     /**
112      * Get value from cache, or fetch it and add it to cache if not found. This can be called from
113      * any thread, but is usually expected to be called from the background.
114      *
115      * @param key key for the object to be obtained
116      * @param fetch method to fetch the object and add it to the cache if not present; note that
117      *   there is no guarantee that two [fetch] cannot run in parallel for the same [key] (if
118      *   [getOrFetch] is called simultaneously from different threads), so be mindful of potential
119      *   side effects
120      */
getOrFetchnull121     fun getOrFetch(key: String, fetch: (String) -> V): V {
122         val entry = cache[key]
123         if (entry != null) {
124             hits.incrementAndGet()
125             // Refresh lives on access
126             entry.resetLives()
127             return entry.value
128         }
129 
130         misses.incrementAndGet()
131         val value = fetch(key)
132         cache[key] = CacheEntry(key, value)
133         return value
134     }
135 
136     /**
137      * Clear entries that are NOT in [wantedKeys] if appropriate. This can be called from any
138      * thread.
139      *
140      * If retainCount > 0, a given entry will need to not be present in [wantedKeys] for
141      * ([retainCount] + 1) consecutive [purge] calls made within at least [purgeTimeoutMillis] of
142      * each other in order to be cleared. This count will be reset for any given entry 1) if
143      * [getOrFetch] is called for the entry or 2) if the entry is present in [wantedKeys] in a
144      * subsequent [purge] call. We prioritize keeping the entry if possible, so if [purge] is called
145      * simultaneously with [getOrFetch] on different threads for example, we will try to keep it in
146      * the cache, although it is not guaranteed. If avoiding cache misses is a concern, consider
147      * increasing the [retainCount] or [purgeTimeoutMillis].
148      *
149      * For example, say [retainCount] = 1 and [purgeTimeoutMillis] = 1000 and we start with entries
150      * (a, b, c) in the cache:
151      * ```kotlin
152      * purge((a, c)); // marks b for deletion
153      * Thread.sleep(500)
154      * purge((a, c)); // does nothing as it was called earlier than the min 1s
155      * Thread.sleep(500)
156      * purge((b, c)); // b is no longer marked for deletion, but now a is
157      * Thread.sleep(1000);
158      * purge((c));    // deletes a from the cache and marks b for deletion, etc.
159      * ```
160      */
purgenull161     fun purge(wantedKeys: Collection<String>) {
162         for ((key, entry) in cache) {
163             if (key in wantedKeys) {
164                 entry.resetLives()
165             } else if (entry.tryPurge()) {
166                 cache.remove(key)
167             }
168         }
169     }
170 
171     /** Clear all entries from the cache. */
clearnull172     fun clear() {
173         cache.clear()
174     }
175 
dumpnull176     override fun dump(pwOrig: PrintWriter, args: Array<out String>) {
177         val pw = pwOrig.asIndenting()
178 
179         pw.println("$TAG(retainCount = $retainCount, purgeTimeoutMillis = $purgeTimeoutMillis)")
180         pw.withIncreasedIndent {
181             pw.printCollection(
182                 "entries present in cache",
183                 cache.values.stream().map { it.toString() }.sorted().toList(),
184             )
185 
186             val misses = misses.get()
187             val hits = hits.get()
188             pw.println(
189                 "cache hit ratio = ${(hits.toFloat() / (hits + misses)) * 100}% " +
190                     "($hits hits, $misses misses)"
191             )
192         }
193     }
194 
195     companion object {
196         const val TAG = "NotifCollectionCache"
197     }
198 }
199