1 /*
2  * Copyright (C) 2022 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 
18 package com.android.systemui.wallpapers;
19 
20 import android.app.WallpaperColors;
21 import android.graphics.Bitmap;
22 import android.graphics.Rect;
23 import android.graphics.RectF;
24 import android.os.Trace;
25 import android.util.ArraySet;
26 import android.util.Log;
27 import android.util.MathUtils;
28 
29 import androidx.annotation.NonNull;
30 import androidx.annotation.VisibleForTesting;
31 
32 import com.android.systemui.dagger.qualifiers.LongRunning;
33 import com.android.systemui.util.Assert;
34 
35 import java.io.FileDescriptor;
36 import java.io.PrintWriter;
37 import java.util.ArrayList;
38 import java.util.List;
39 import java.util.Set;
40 import java.util.concurrent.Executor;
41 
42 /**
43  * This class is used by the {@link ImageWallpaper} to extract colors from areas of a wallpaper.
44  * It uses a background executor, and uses callbacks to inform that the work is done.
45  * It uses  a downscaled version of the wallpaper to extract the colors.
46  */
47 public class WallpaperLocalColorExtractor {
48 
49     private Bitmap mMiniBitmap;
50 
51     @VisibleForTesting
52     static final int SMALL_SIDE = 128;
53 
54     private static final String TAG = WallpaperLocalColorExtractor.class.getSimpleName();
55     private static final @NonNull RectF LOCAL_COLOR_BOUNDS =
56             new RectF(0, 0, 1, 1);
57 
58     private int mDisplayWidth = -1;
59     private int mDisplayHeight = -1;
60     private int mPages = -1;
61     private int mBitmapWidth = -1;
62     private int mBitmapHeight = -1;
63 
64     private final Object mLock = new Object();
65 
66     private final List<RectF> mPendingRegions = new ArrayList<>();
67     private final Set<RectF> mProcessedRegions = new ArraySet<>();
68 
69     @LongRunning
70     private final Executor mLongExecutor;
71 
72     private final WallpaperLocalColorExtractorCallback mWallpaperLocalColorExtractorCallback;
73 
74     /**
75      * Interface to handle the callbacks after the different steps of the color extraction
76      */
77     public interface WallpaperLocalColorExtractorCallback {
78         /**
79          * Callback after the colors of new regions have been extracted
80          * @param regions the list of new regions that have been processed
81          * @param colors the resulting colors for these regions, in the same order as the regions
82          */
onColorsProcessed(List<RectF> regions, List<WallpaperColors> colors)83         void onColorsProcessed(List<RectF> regions, List<WallpaperColors> colors);
84 
85         /**
86          * Callback after the mini bitmap is computed, to indicate that the wallpaper bitmap is
87          * no longer used by the color extractor and can be safely recycled
88          */
onMiniBitmapUpdated()89         void onMiniBitmapUpdated();
90 
91         /**
92          * Callback to inform that the extractor has started processing colors
93          */
onActivated()94         void onActivated();
95 
96         /**
97          * Callback to inform that no more colors are being processed
98          */
onDeactivated()99         void onDeactivated();
100     }
101 
102     /**
103      * Creates a new color extractor.
104      * @param longExecutor the executor on which the color extraction will be performed
105      * @param wallpaperLocalColorExtractorCallback an interface to handle the callbacks from
106      *                                        the color extractor.
107      */
WallpaperLocalColorExtractor(@ongRunning Executor longExecutor, WallpaperLocalColorExtractorCallback wallpaperLocalColorExtractorCallback)108     public WallpaperLocalColorExtractor(@LongRunning Executor longExecutor,
109             WallpaperLocalColorExtractorCallback wallpaperLocalColorExtractorCallback) {
110         mLongExecutor = longExecutor;
111         mWallpaperLocalColorExtractorCallback = wallpaperLocalColorExtractorCallback;
112     }
113 
114     /**
115      * Used by the outside to inform that the display size has changed.
116      * The new display size will be used in the next computations, but the current colors are
117      * not recomputed.
118      */
setDisplayDimensions(int displayWidth, int displayHeight)119     public void setDisplayDimensions(int displayWidth, int displayHeight) {
120         mLongExecutor.execute(() ->
121                 setDisplayDimensionsSynchronized(displayWidth, displayHeight));
122     }
123 
setDisplayDimensionsSynchronized(int displayWidth, int displayHeight)124     private void setDisplayDimensionsSynchronized(int displayWidth, int displayHeight) {
125         synchronized (mLock) {
126             if (displayWidth == mDisplayWidth && displayHeight == mDisplayHeight) return;
127             mDisplayWidth = displayWidth;
128             mDisplayHeight = displayHeight;
129             processColorsInternal();
130         }
131     }
132 
133     /**
134      * @return whether color extraction is currently in use
135      */
isActive()136     private boolean isActive() {
137         return mPendingRegions.size() + mProcessedRegions.size() > 0;
138     }
139 
140     /**
141      * Should be called when the wallpaper is changed.
142      * This will recompute the mini bitmap
143      * and restart the extraction of all areas
144      * @param bitmap the new wallpaper
145      */
onBitmapChanged(@onNull Bitmap bitmap)146     public void onBitmapChanged(@NonNull Bitmap bitmap) {
147         mLongExecutor.execute(() -> onBitmapChangedSynchronized(bitmap));
148     }
149 
onBitmapChangedSynchronized(@onNull Bitmap bitmap)150     private void onBitmapChangedSynchronized(@NonNull Bitmap bitmap) {
151         synchronized (mLock) {
152             if (bitmap.getWidth() <= 0 || bitmap.getHeight() <= 0) {
153                 Log.e(TAG, "Attempt to extract colors from an invalid bitmap");
154                 return;
155             }
156             mBitmapWidth = bitmap.getWidth();
157             mBitmapHeight = bitmap.getHeight();
158             mMiniBitmap = createMiniBitmap(bitmap);
159             mWallpaperLocalColorExtractorCallback.onMiniBitmapUpdated();
160             recomputeColors();
161         }
162     }
163 
164     /**
165      * Should be called when the number of pages is changed
166      * This will restart the extraction of all areas
167      * @param pages the total number of pages of the launcher
168      */
onPageChanged(int pages)169     public void onPageChanged(int pages) {
170         mLongExecutor.execute(() -> onPageChangedSynchronized(pages));
171     }
172 
onPageChangedSynchronized(int pages)173     private void onPageChangedSynchronized(int pages) {
174         synchronized (mLock) {
175             if (mPages == pages) return;
176             mPages = pages;
177             if (mMiniBitmap != null && !mMiniBitmap.isRecycled()) {
178                 recomputeColors();
179             }
180         }
181     }
182 
183     // helper to recompute colors, to be called in synchronized methods
recomputeColors()184     private void recomputeColors() {
185         mPendingRegions.addAll(mProcessedRegions);
186         mProcessedRegions.clear();
187         processColorsInternal();
188     }
189 
190     /**
191      * Add new regions to extract
192      * This will trigger the color extraction and call the callback only for these new regions
193      * @param regions The areas of interest in our wallpaper (in screen pixel coordinates)
194      */
addLocalColorsAreas(@onNull List<RectF> regions)195     public void addLocalColorsAreas(@NonNull List<RectF> regions) {
196         if (regions.size() > 0) {
197             mLongExecutor.execute(() -> addLocalColorsAreasSynchronized(regions));
198         } else {
199             Log.w(TAG, "Attempt to add colors with an empty list");
200         }
201     }
202 
addLocalColorsAreasSynchronized(@onNull List<RectF> regions)203     private void addLocalColorsAreasSynchronized(@NonNull List<RectF> regions) {
204         synchronized (mLock) {
205             boolean wasActive = isActive();
206             mPendingRegions.addAll(regions);
207             if (!wasActive && isActive()) {
208                 mWallpaperLocalColorExtractorCallback.onActivated();
209             }
210             processColorsInternal();
211         }
212     }
213 
214     /**
215      * Remove regions to extract. If a color extraction is ongoing does not stop it.
216      * But if there are subsequent changes that restart the extraction, the removed regions
217      * will not be recomputed.
218      * @param regions The areas of interest in our wallpaper (in screen pixel coordinates)
219      */
removeLocalColorAreas(@onNull List<RectF> regions)220     public void removeLocalColorAreas(@NonNull List<RectF> regions) {
221         mLongExecutor.execute(() -> removeLocalColorAreasSynchronized(regions));
222     }
223 
removeLocalColorAreasSynchronized(@onNull List<RectF> regions)224     private void removeLocalColorAreasSynchronized(@NonNull List<RectF> regions) {
225         synchronized (mLock) {
226             boolean wasActive = isActive();
227             mPendingRegions.removeAll(regions);
228             regions.forEach(mProcessedRegions::remove);
229             if (wasActive && !isActive()) {
230                 mWallpaperLocalColorExtractorCallback.onDeactivated();
231             }
232         }
233     }
234 
235     /**
236      * Clean up the memory (in particular, the mini bitmap) used by this class.
237      */
cleanUp()238     public void cleanUp() {
239         mLongExecutor.execute(this::cleanUpSynchronized);
240     }
241 
cleanUpSynchronized()242     private void cleanUpSynchronized() {
243         synchronized (mLock) {
244             if (mMiniBitmap != null) {
245                 mMiniBitmap.recycle();
246                 mMiniBitmap = null;
247             }
248             mProcessedRegions.clear();
249             mPendingRegions.clear();
250         }
251     }
252 
createMiniBitmap(@onNull Bitmap bitmap)253     private Bitmap createMiniBitmap(@NonNull Bitmap bitmap) {
254         Trace.beginSection("WallpaperLocalColorExtractor#createMiniBitmap");
255         // if both sides of the image are larger than SMALL_SIDE, downscale the bitmap.
256         int smallestSide = Math.min(bitmap.getWidth(), bitmap.getHeight());
257         float scale = Math.min(1.0f, (float) SMALL_SIDE / smallestSide);
258         Bitmap result = createMiniBitmap(bitmap,
259                 (int) (scale * bitmap.getWidth()),
260                 (int) (scale * bitmap.getHeight()));
261         Trace.endSection();
262         return result;
263     }
264 
265     @VisibleForTesting
createMiniBitmap(@onNull Bitmap bitmap, int width, int height)266     Bitmap createMiniBitmap(@NonNull Bitmap bitmap, int width, int height) {
267         return Bitmap.createScaledBitmap(bitmap, width, height, false);
268     }
269 
getLocalWallpaperColors(@onNull RectF area)270     private WallpaperColors getLocalWallpaperColors(@NonNull RectF area) {
271         RectF imageArea = pageToImgRect(area);
272         if (imageArea == null || !LOCAL_COLOR_BOUNDS.contains(imageArea)) {
273             return null;
274         }
275         Rect subImage = new Rect(
276                 (int) Math.floor(imageArea.left * mMiniBitmap.getWidth()),
277                 (int) Math.floor(imageArea.top * mMiniBitmap.getHeight()),
278                 (int) Math.ceil(imageArea.right * mMiniBitmap.getWidth()),
279                 (int) Math.ceil(imageArea.bottom * mMiniBitmap.getHeight()));
280         if (subImage.isEmpty()) {
281             // Do not notify client. treat it as too small to sample
282             return null;
283         }
284         return getLocalWallpaperColors(subImage);
285     }
286 
287     @VisibleForTesting
getLocalWallpaperColors(@onNull Rect subImage)288     WallpaperColors getLocalWallpaperColors(@NonNull Rect subImage) {
289         Assert.isNotMainThread();
290         Bitmap colorImg = Bitmap.createBitmap(mMiniBitmap,
291                 subImage.left, subImage.top, subImage.width(), subImage.height());
292         return WallpaperColors.fromBitmap(colorImg);
293     }
294 
295     /**
296      * Transform the logical coordinates into wallpaper coordinates.
297      *
298      * Logical coordinates are organised such that the various pages are non-overlapping. So,
299      * if there are n pages, the first page will have its X coordinate on the range [0-1/n].
300      *
301      * The real pages are overlapping. If the Wallpaper are a width Ww and the screen a width
302      * Ws, the relative width of a page Wr is Ws/Ww. This does not change if the number of
303      * pages increase.
304      * If there are n pages, the page k starts at the offset k * (1 - Wr) / (n - 1), as the
305      * last page is at position (1-Wr) and the others are regularly spread on the range [0-
306      * (1-Wr)].
307      */
pageToImgRect(RectF area)308     private RectF pageToImgRect(RectF area) {
309         // Width of a page for the caller of this API.
310         float virtualPageWidth = 1f / (float) mPages;
311         float leftPosOnPage = (area.left % virtualPageWidth) / virtualPageWidth;
312         float rightPosOnPage = (area.right % virtualPageWidth) / virtualPageWidth;
313         int currentPage = (int) Math.floor(area.centerX() / virtualPageWidth);
314 
315         if (mDisplayWidth <= 0 || mDisplayHeight <= 0) {
316             Log.e(TAG, "Trying to extract colors with invalid display dimensions");
317             return null;
318         }
319 
320         RectF imgArea = new RectF();
321         imgArea.bottom = area.bottom;
322         imgArea.top = area.top;
323 
324         float imageScale = Math.min(((float) mBitmapHeight) / mDisplayHeight, 1);
325         float mappedScreenWidth = mDisplayWidth * imageScale;
326         float pageWidth = Math.min(1.0f,
327                 mBitmapWidth > 0 ? mappedScreenWidth / (float) mBitmapWidth : 1.f);
328         float pageOffset = (1 - pageWidth) / (float) (mPages - 1);
329 
330         imgArea.left = MathUtils.constrain(
331                 leftPosOnPage * pageWidth + currentPage * pageOffset, 0, 1);
332         imgArea.right = MathUtils.constrain(
333                 rightPosOnPage * pageWidth + currentPage * pageOffset, 0, 1);
334         if (imgArea.left > imgArea.right) {
335             // take full page
336             imgArea.left = 0;
337             imgArea.right = 1;
338         }
339         return imgArea;
340     }
341 
342     /**
343      * Extract the colors from the pending regions,
344      * then notify the callback with the resulting colors for these regions
345      * This method should only be called synchronously
346      */
processColorsInternal()347     private void processColorsInternal() {
348         /*
349          * if the miniBitmap is not yet loaded, that means the onBitmapChanged has not yet been
350          * called, and thus the wallpaper is not yet loaded. In that case, exit, the function
351          * will be called again when the bitmap is loaded and the miniBitmap is computed.
352          */
353         if (mMiniBitmap == null || mMiniBitmap.isRecycled())  return;
354 
355         /*
356          * if the screen size or number of pages is not yet known, exit
357          * the function will be called again once the screen size and page are known
358          */
359         if (mDisplayWidth < 0 || mDisplayHeight < 0 || mPages < 0) return;
360 
361         Trace.beginSection("WallpaperLocalColorExtractor#processColorsInternal");
362         List<WallpaperColors> processedColors = new ArrayList<>();
363         for (int i = 0; i < mPendingRegions.size(); i++) {
364             RectF nextArea = mPendingRegions.get(i);
365             WallpaperColors colors = getLocalWallpaperColors(nextArea);
366 
367             mProcessedRegions.add(nextArea);
368             processedColors.add(colors);
369         }
370         List<RectF> processedRegions = new ArrayList<>(mPendingRegions);
371         mPendingRegions.clear();
372         Trace.endSection();
373 
374         mWallpaperLocalColorExtractorCallback.onColorsProcessed(processedRegions, processedColors);
375     }
376 
377     /**
378      * Called to dump current state.
379      * @param prefix prefix.
380      * @param fd fd.
381      * @param out out.
382      * @param args args.
383      */
dump(String prefix, FileDescriptor fd, PrintWriter out, String[] args)384     public void dump(String prefix, FileDescriptor fd, PrintWriter out, String[] args) {
385         out.print(prefix); out.print("display="); out.println(mDisplayWidth + "x" + mDisplayHeight);
386         out.print(prefix); out.print("mPages="); out.println(mPages);
387 
388         out.print(prefix); out.print("bitmap dimensions=");
389         out.println(mBitmapWidth + "x" + mBitmapHeight);
390 
391         out.print(prefix); out.print("bitmap=");
392         out.println(mMiniBitmap == null ? "null"
393                 : mMiniBitmap.isRecycled() ? "recycled"
394                 : mMiniBitmap.getWidth() + "x" + mMiniBitmap.getHeight());
395 
396         out.print(prefix); out.print("PendingRegions size="); out.print(mPendingRegions.size());
397         out.print(prefix); out.print("ProcessedRegions size="); out.print(mProcessedRegions.size());
398     }
399 }
400