1 /*
<lambda>null2 * Copyright (C) 2023 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.annotation.WorkerThread
20 import android.app.ActivityManager
21 import android.content.Context
22 import android.graphics.drawable.Drawable
23 import android.graphics.drawable.Icon
24 import android.util.Dumpable
25 import android.util.Log
26 import android.util.Size
27 import androidx.annotation.MainThread
28 import com.android.internal.R
29 import com.android.internal.widget.NotificationDrawableConsumer
30 import com.android.internal.widget.NotificationIconManager
31 import com.android.systemui.dagger.qualifiers.Application
32 import com.android.systemui.dagger.qualifiers.Background
33 import com.android.systemui.dagger.qualifiers.Main
34 import com.android.systemui.graphics.ImageLoader
35 import com.android.systemui.statusbar.notification.row.BigPictureIconManager.DrawableState.Empty
36 import com.android.systemui.statusbar.notification.row.BigPictureIconManager.DrawableState.FullImage
37 import com.android.systemui.statusbar.notification.row.BigPictureIconManager.DrawableState.Initial
38 import com.android.systemui.statusbar.notification.row.BigPictureIconManager.DrawableState.PlaceHolder
39 import com.android.systemui.util.Assert
40 import java.io.PrintWriter
41 import javax.inject.Inject
42 import kotlin.math.min
43 import kotlinx.coroutines.CoroutineDispatcher
44 import kotlinx.coroutines.CoroutineScope
45 import kotlinx.coroutines.Job
46 import kotlinx.coroutines.delay
47 import kotlinx.coroutines.launch
48 import kotlinx.coroutines.withContext
49
50 private const val TAG = "BigPicImageLoader"
51 private const val DEBUG = false
52 private const val FREE_IMAGE_DELAY_MS = 3000L
53
54 /**
55 * A helper class for [com.android.internal.widget.BigPictureNotificationImageView] to lazy-load
56 * images from SysUI. It replaces the placeholder image with the fully loaded one, and vica versa.
57 *
58 * TODO(b/283082473) move the logs to a [com.android.systemui.log.LogBuffer]
59 */
60 @SuppressWarnings("DumpableNotRegistered")
61 class BigPictureIconManager
62 @Inject
63 constructor(
64 private val context: Context,
65 private val imageLoader: ImageLoader,
66 private val statsManager: BigPictureStatsManager,
67 @Application private val scope: CoroutineScope,
68 @Main private val mainDispatcher: CoroutineDispatcher,
69 @Background private val bgDispatcher: CoroutineDispatcher
70 ) : NotificationIconManager, Dumpable {
71
72 private var lastLoadingJob: Job? = null
73 private var drawableConsumer: NotificationDrawableConsumer? = null
74 private var displayedState: DrawableState = Initial
75 private var viewShown = false
76
77 private var maxWidth = getMaxWidth()
78 private var maxHeight = getMaxHeight()
79
80 /**
81 * Called when the displayed state changes of the view.
82 *
83 * @param shown true if the view is shown, and the image needs to be displayed.
84 */
85 fun onViewShown(shown: Boolean) {
86 log("onViewShown:$shown")
87
88 if (this.viewShown != shown) {
89 this.viewShown = shown
90
91 val state = displayedState
92
93 this.lastLoadingJob?.cancel()
94 this.lastLoadingJob =
95 when {
96 skipLazyLoading(state.icon) -> null
97 state is PlaceHolder && shown -> startLoadingJob(state.icon)
98 state is FullImage && !shown ->
99 startFreeImageJob(state.icon, state.drawableSize)
100 else -> null
101 }
102 }
103 }
104
105 /**
106 * Update the maximum width and height allowed for bitmaps, ex. after a configuration change.
107 */
108 fun updateMaxImageSizes() {
109 log("updateMaxImageSizes")
110 maxWidth = getMaxWidth()
111 maxHeight = getMaxHeight()
112 }
113
114 /** Cancels all currently running jobs. */
115 fun cancelJobs() {
116 lastLoadingJob?.cancel()
117 }
118
119 @WorkerThread
120 override fun updateIcon(drawableConsumer: NotificationDrawableConsumer, icon: Icon?): Runnable {
121 this.drawableConsumer = drawableConsumer
122 this.lastLoadingJob?.cancel()
123
124 val drawableAndState = loadImageOrPlaceHolderSync(icon)
125 log("icon updated")
126
127 return Runnable { applyDrawableAndState(drawableAndState) }
128 }
129
130 override fun dump(pw: PrintWriter, args: Array<out String>?) {
131 pw.println("BigPictureIconManager ${getDebugString()}")
132 }
133
134 @WorkerThread
135 private fun loadImageOrPlaceHolderSync(icon: Icon?): Pair<Drawable, DrawableState>? {
136 icon ?: return null
137
138 if (viewShown || skipLazyLoading(icon)) {
139 return loadImageSync(icon)
140 }
141
142 return loadPlaceHolderSync(icon) ?: loadImageSync(icon)
143 }
144
145 @WorkerThread
146 private fun loadImageSync(icon: Icon): Pair<Drawable, DrawableState>? {
147 return imageLoader.loadDrawableSync(icon, context, maxWidth, maxHeight)?.let { drawable ->
148 checkPlaceHolderSizeForDrawable(this.displayedState, drawable)
149 Pair(drawable, FullImage(icon, drawable.intrinsicSize))
150 }
151 }
152
153 private fun checkPlaceHolderSizeForDrawable(
154 displayedState: DrawableState,
155 newDrawable: Drawable
156 ) {
157 if (displayedState is PlaceHolder) {
158 val (oldWidth, oldHeight) = displayedState.drawableSize
159 val (newWidth, newHeight) = newDrawable.intrinsicSize
160
161 if (oldWidth != newWidth || oldHeight != newHeight) {
162 Log.e(
163 TAG,
164 "Mismatch in dimensions, when replacing PlaceHolder " +
165 "$oldWidth X $oldHeight with Drawable $newWidth X $newHeight."
166 )
167 }
168 }
169 }
170
171 @WorkerThread
172 private fun loadPlaceHolderSync(icon: Icon): Pair<Drawable, DrawableState>? {
173 return imageLoader
174 .loadSizeSync(icon, context)
175 ?.resizeToMax(maxWidth, maxHeight) // match the dimensions of the fully loaded drawable
176 ?.let { size -> createPlaceHolder(icon, size) }
177 }
178
179 @MainThread
180 private fun applyDrawableAndState(drawableAndState: Pair<Drawable, DrawableState>?) {
181 Assert.isMainThread()
182 drawableConsumer?.setImageDrawable(drawableAndState?.first)
183 displayedState = drawableAndState?.second ?: Empty
184 }
185
186 private fun startLoadingJob(icon: Icon): Job = scope.launch {
187 statsManager.measure { loadImage(icon) }
188 }
189
190 private suspend fun loadImage(icon: Icon) {
191 val drawableAndState = withContext(bgDispatcher) { loadImageSync(icon) }
192 withContext(mainDispatcher) { applyDrawableAndState(drawableAndState) }
193 log("full image loaded")
194 }
195
196 private fun startFreeImageJob(icon: Icon, drawableSize: Size): Job =
197 scope.launch {
198 delay(FREE_IMAGE_DELAY_MS)
199 val drawableAndState = createPlaceHolder(icon, drawableSize)
200 withContext(mainDispatcher) { applyDrawableAndState(drawableAndState) }
201 log("placeholder loaded")
202 }
203
204 private fun createPlaceHolder(icon: Icon, size: Size): Pair<Drawable, DrawableState> {
205 val drawable = PlaceHolderDrawable(width = size.width, height = size.height)
206 val state = PlaceHolder(icon, drawable.intrinsicSize)
207 return Pair(drawable, state)
208 }
209
210 private fun isLowRam(): Boolean {
211 return ActivityManager.isLowRamDeviceStatic()
212 }
213
214 private fun getMaxWidth() =
215 context.resources.getDimensionPixelSize(
216 if (isLowRam()) {
217 R.dimen.notification_big_picture_max_width_low_ram
218 } else {
219 R.dimen.notification_big_picture_max_width
220 }
221 )
222
223 private fun getMaxHeight() =
224 context.resources.getDimensionPixelSize(
225 if (isLowRam()) {
226 R.dimen.notification_big_picture_max_height_low_ram
227 } else {
228 R.dimen.notification_big_picture_max_height
229 }
230 )
231
232 /**
233 * We don't support lazy-loading or set placeholders for bitmap and data based icons, because
234 * they gonna stay in memory anyways.
235 */
236 private fun skipLazyLoading(icon: Icon?): Boolean =
237 when (icon?.type) {
238 Icon.TYPE_BITMAP,
239 Icon.TYPE_ADAPTIVE_BITMAP,
240 Icon.TYPE_DATA,
241 null -> true
242 else -> false
243 }
244
245 private fun log(msg: String) {
246 if (DEBUG) {
247 Log.d(TAG, "$msg state=${getDebugString()}")
248 }
249 }
250
251 private fun getDebugString() =
252 "{ state:$displayedState, hasConsumer:${drawableConsumer != null}, viewShown:$viewShown}"
253
254 private sealed class DrawableState(open val icon: Icon?) {
255 data object Initial : DrawableState(null)
256 data object Empty : DrawableState(null)
257 data class PlaceHolder(override val icon: Icon, val drawableSize: Size) :
258 DrawableState(icon)
259 data class FullImage(override val icon: Icon, val drawableSize: Size) : DrawableState(icon)
260 }
261 }
262
263 /**
264 * @return an image size that conforms to the maxWidth / maxHeight parameters. It can be the same
265 * instance, if the provided size was already small enough.
266 */
Sizenull267 private fun Size.resizeToMax(maxWidth: Int, maxHeight: Int): Size {
268 if (width <= maxWidth && height <= maxHeight) {
269 return this
270 }
271
272 // Calculate the scale factor for both dimensions
273 val wScale =
274 if (maxWidth <= 0) {
275 1.0f
276 } else {
277 maxWidth.toFloat() / width.toFloat()
278 }
279
280 val hScale =
281 if (maxHeight <= 0) {
282 1.0f
283 } else {
284 maxHeight.toFloat() / height.toFloat()
285 }
286
287 // Scale down to the smaller scale factor
288 val scale = min(wScale, hScale)
289 if (scale < 1.0f) {
290 val targetWidth = (width * scale).toInt()
291 val targetHeight = (height * scale).toInt()
292
293 return Size(targetWidth, targetHeight)
294 }
295
296 return this
297 }
298
299 private val Drawable.intrinsicSize
300 get() = Size(/*width=*/ intrinsicWidth, /*height=*/ intrinsicHeight)
301
component1null302 private operator fun Size.component1() = width
303
304 private operator fun Size.component2() = height
305