1 /*
2  * 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.server.wallpaper;
18 
19 import static android.view.Display.DEFAULT_DISPLAY;
20 
21 import static com.android.server.wallpaper.WallpaperUtils.RECORD_FILE;
22 import static com.android.server.wallpaper.WallpaperUtils.RECORD_LOCK_FILE;
23 import static com.android.server.wallpaper.WallpaperUtils.WALLPAPER;
24 import static com.android.server.wallpaper.WallpaperUtils.getWallpaperDir;
25 
26 import android.graphics.Bitmap;
27 import android.graphics.BitmapFactory;
28 import android.graphics.ImageDecoder;
29 import android.graphics.Rect;
30 import android.os.FileUtils;
31 import android.os.SELinux;
32 import android.util.Slog;
33 import android.view.DisplayInfo;
34 
35 import com.android.server.utils.TimingsTraceAndSlog;
36 
37 import libcore.io.IoUtils;
38 
39 import java.io.BufferedOutputStream;
40 import java.io.File;
41 import java.io.FileOutputStream;
42 
43 /**
44  * Helper file for wallpaper cropping
45  * Meant to have a single instance, only used by the WallpaperManagerService
46  */
47 class WallpaperCropper {
48 
49     private static final String TAG = WallpaperCropper.class.getSimpleName();
50     private static final boolean DEBUG = false;
51     private static final boolean DEBUG_CROP = true;
52 
53     private final WallpaperDisplayHelper mWallpaperDisplayHelper;
54 
WallpaperCropper(WallpaperDisplayHelper wallpaperDisplayHelper)55     WallpaperCropper(WallpaperDisplayHelper wallpaperDisplayHelper) {
56         mWallpaperDisplayHelper = wallpaperDisplayHelper;
57     }
58 
59     /**
60      * Once a new wallpaper has been written via setWallpaper(...), it needs to be cropped
61      * for display.
62      *
63      * This will generate the crop and write it in the file
64      */
generateCrop(WallpaperData wallpaper)65     void generateCrop(WallpaperData wallpaper) {
66         TimingsTraceAndSlog t = new TimingsTraceAndSlog(TAG);
67         t.traceBegin("WPMS.generateCrop");
68         generateCropInternal(wallpaper);
69         t.traceEnd();
70     }
71 
generateCropInternal(WallpaperData wallpaper)72     private void generateCropInternal(WallpaperData wallpaper) {
73         boolean success = false;
74 
75         // Only generate crop for default display.
76         final WallpaperDisplayHelper.DisplayData wpData =
77                 mWallpaperDisplayHelper.getDisplayDataOrCreate(DEFAULT_DISPLAY);
78         final Rect cropHint = new Rect(wallpaper.cropHint);
79         final DisplayInfo displayInfo = mWallpaperDisplayHelper.getDisplayInfo(DEFAULT_DISPLAY);
80 
81         if (DEBUG) {
82             Slog.v(TAG, "Generating crop for new wallpaper(s): 0x"
83                     + Integer.toHexString(wallpaper.mWhich)
84                     + " to " + wallpaper.getCropFile().getName()
85                     + " crop=(" + cropHint.width() + 'x' + cropHint.height()
86                     + ") dim=(" + wpData.mWidth + 'x' + wpData.mHeight + ')');
87         }
88 
89         // Analyse the source; needed in multiple cases
90         BitmapFactory.Options options = new BitmapFactory.Options();
91         options.inJustDecodeBounds = true;
92         BitmapFactory.decodeFile(wallpaper.getWallpaperFile().getAbsolutePath(), options);
93         if (options.outWidth <= 0 || options.outHeight <= 0) {
94             Slog.w(TAG, "Invalid wallpaper data");
95             success = false;
96         } else {
97             boolean needCrop = false;
98             boolean needScale;
99 
100             // Empty crop means use the full image
101             if (cropHint.isEmpty()) {
102                 cropHint.left = cropHint.top = 0;
103                 cropHint.right = options.outWidth;
104                 cropHint.bottom = options.outHeight;
105             } else {
106                 // force the crop rect to lie within the measured bounds
107                 int dx = cropHint.right > options.outWidth ? options.outWidth - cropHint.right : 0;
108                 int dy = cropHint.bottom > options.outHeight
109                         ? options.outHeight - cropHint.bottom : 0;
110                 cropHint.offset(dx, dy);
111 
112                 // If the crop hint was larger than the image we just overshot. Patch things up.
113                 if (cropHint.left < 0) {
114                     cropHint.left = 0;
115                 }
116                 if (cropHint.top < 0) {
117                     cropHint.top = 0;
118                 }
119 
120                 // Don't bother cropping if what we're left with is identity
121                 needCrop = (options.outHeight > cropHint.height()
122                         || options.outWidth > cropHint.width());
123             }
124 
125             // scale if the crop height winds up not matching the recommended metrics
126             needScale = cropHint.height() > wpData.mHeight
127                     || cropHint.height() > GLHelper.getMaxTextureSize()
128                     || cropHint.width() > GLHelper.getMaxTextureSize();
129 
130             //make sure screen aspect ratio is preserved if width is scaled under screen size
131             if (needScale) {
132                 final float scaleByHeight = (float) wpData.mHeight / (float) cropHint.height();
133                 final int newWidth = (int) (cropHint.width() * scaleByHeight);
134                 if (newWidth < displayInfo.logicalWidth) {
135                     final float screenAspectRatio =
136                             (float) displayInfo.logicalHeight / (float) displayInfo.logicalWidth;
137                     cropHint.bottom = (int) (cropHint.width() * screenAspectRatio);
138                     needCrop = true;
139                 }
140             }
141 
142             if (DEBUG_CROP) {
143                 Slog.v(TAG, "crop: w=" + cropHint.width() + " h=" + cropHint.height());
144                 Slog.v(TAG, "dims: w=" + wpData.mWidth + " h=" + wpData.mHeight);
145                 Slog.v(TAG, "meas: w=" + options.outWidth + " h=" + options.outHeight);
146                 Slog.v(TAG, "crop?=" + needCrop + " scale?=" + needScale);
147             }
148 
149             if (!needCrop && !needScale) {
150                 // Simple case:  the nominal crop fits what we want, so we take
151                 // the whole thing and just copy the image file directly.
152 
153                 // TODO: It is not accurate to estimate bitmap size without decoding it,
154                 //  may be we can try to remove this optimized way in the future,
155                 //  that means, we will always go into the 'else' block.
156 
157                 success = FileUtils.copyFile(wallpaper.getWallpaperFile(), wallpaper.getCropFile());
158 
159                 if (!success) {
160                     wallpaper.getCropFile().delete();
161                 }
162 
163                 if (DEBUG) {
164                     long estimateSize = (long) options.outWidth * options.outHeight * 4;
165                     Slog.v(TAG, "Null crop of new wallpaper, estimate size="
166                             + estimateSize + ", success=" + success);
167                 }
168             } else {
169                 // Fancy case: crop and scale.  First, we decode and scale down if appropriate.
170                 FileOutputStream f = null;
171                 BufferedOutputStream bos = null;
172                 try {
173                     // This actually downsamples only by powers of two, but that's okay; we do
174                     // a proper scaling blit later.  This is to minimize transient RAM use.
175                     // We calculate the largest power-of-two under the actual ratio rather than
176                     // just let the decode take care of it because we also want to remap where the
177                     // cropHint rectangle lies in the decoded [super]rect.
178                     final int actualScale = cropHint.height() / wpData.mHeight;
179                     int scale = 1;
180                     while (2 * scale <= actualScale) {
181                         scale *= 2;
182                     }
183                     options.inSampleSize = scale;
184                     options.inJustDecodeBounds = false;
185 
186                     final Rect estimateCrop = new Rect(cropHint);
187                     estimateCrop.scale(1f / options.inSampleSize);
188                     final float hRatio = (float) wpData.mHeight / estimateCrop.height();
189                     final int destHeight = (int) (estimateCrop.height() * hRatio);
190                     final int destWidth = (int) (estimateCrop.width() * hRatio);
191 
192                     // We estimated an invalid crop, try to adjust the cropHint to get a valid one.
193                     if (destWidth > GLHelper.getMaxTextureSize()) {
194                         int newHeight = (int) (wpData.mHeight / hRatio);
195                         int newWidth = (int) (wpData.mWidth / hRatio);
196 
197                         if (DEBUG) {
198                             Slog.v(TAG, "Invalid crop dimensions, trying to adjust.");
199                         }
200 
201                         estimateCrop.set(cropHint);
202                         estimateCrop.left += (cropHint.width() - newWidth) / 2;
203                         estimateCrop.top += (cropHint.height() - newHeight) / 2;
204                         estimateCrop.right = estimateCrop.left + newWidth;
205                         estimateCrop.bottom = estimateCrop.top + newHeight;
206                         cropHint.set(estimateCrop);
207                         estimateCrop.scale(1f / options.inSampleSize);
208                     }
209 
210                     // We've got the safe cropHint; now we want to scale it properly to
211                     // the desired rectangle.
212                     // That's a height-biased operation: make it fit the hinted height.
213                     final int safeHeight = (int) (estimateCrop.height() * hRatio);
214                     final int safeWidth = (int) (estimateCrop.width() * hRatio);
215 
216                     if (DEBUG_CROP) {
217                         Slog.v(TAG, "Decode parameters:");
218                         Slog.v(TAG, "  cropHint=" + cropHint + ", estimateCrop=" + estimateCrop);
219                         Slog.v(TAG, "  down sampling=" + options.inSampleSize
220                                 + ", hRatio=" + hRatio);
221                         Slog.v(TAG, "  dest=" + destWidth + "x" + destHeight);
222                         Slog.v(TAG, "  safe=" + safeWidth + "x" + safeHeight);
223                         Slog.v(TAG, "  maxTextureSize=" + GLHelper.getMaxTextureSize());
224                     }
225 
226                     //Create a record file and will delete if ImageDecoder work well.
227                     final String recordName =
228                             (wallpaper.getWallpaperFile().getName().equals(WALLPAPER)
229                                     ? RECORD_FILE : RECORD_LOCK_FILE);
230                     final File record = new File(getWallpaperDir(wallpaper.userId), recordName);
231                     record.createNewFile();
232                     Slog.v(TAG, "record path =" + record.getPath()
233                             + ", record name =" + record.getName());
234 
235                     final ImageDecoder.Source srcData =
236                             ImageDecoder.createSource(wallpaper.getWallpaperFile());
237                     final int sampleSize = scale;
238                     Bitmap cropped = ImageDecoder.decodeBitmap(srcData, (decoder, info, src) -> {
239                         decoder.setTargetSampleSize(sampleSize);
240                         decoder.setCrop(estimateCrop);
241                     });
242 
243                     record.delete();
244 
245                     if (cropped == null) {
246                         Slog.e(TAG, "Could not decode new wallpaper");
247                     } else {
248                         // We are safe to create final crop with safe dimensions now.
249                         final Bitmap finalCrop = Bitmap.createScaledBitmap(cropped,
250                                 safeWidth, safeHeight, true);
251                         if (DEBUG) {
252                             Slog.v(TAG, "Final extract:");
253                             Slog.v(TAG, "  dims: w=" + wpData.mWidth
254                                     + " h=" + wpData.mHeight);
255                             Slog.v(TAG, "  out: w=" + finalCrop.getWidth()
256                                     + " h=" + finalCrop.getHeight());
257                         }
258 
259                         f = new FileOutputStream(wallpaper.getCropFile());
260                         bos = new BufferedOutputStream(f, 32 * 1024);
261                         finalCrop.compress(Bitmap.CompressFormat.PNG, 100, bos);
262                         // don't rely on the implicit flush-at-close when noting success
263                         bos.flush();
264                         success = true;
265                     }
266                 } catch (Exception e) {
267                     if (DEBUG) {
268                         Slog.e(TAG, "Error decoding crop", e);
269                     }
270                 } finally {
271                     IoUtils.closeQuietly(bos);
272                     IoUtils.closeQuietly(f);
273                 }
274             }
275         }
276 
277         if (!success) {
278             Slog.e(TAG, "Unable to apply new wallpaper");
279             wallpaper.getCropFile().delete();
280         }
281 
282         if (wallpaper.getCropFile().exists()) {
283             boolean didRestorecon = SELinux.restorecon(wallpaper.getCropFile().getAbsoluteFile());
284             if (DEBUG) {
285                 Slog.v(TAG, "restorecon() of crop file returned " + didRestorecon);
286             }
287         }
288     }
289 }
290