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