Move wallpaper crop logic in a new class WallpaperCropper

Part of the WallpaperManagerService refactor. This mainly moves the
method generateCrop and generateCropInternal

Test: treehugger
Test: atest CtsWallpaperTestCases
Test: atest FrameworksMockingServicesTests:WallpaperManagerServiceTests
Bug: 264637309
Change-Id: I0446e677de0c673cf5cfbd704a1dac7d529906ff
This commit is contained in:
Aurélien Pomini
2023-01-09 12:31:16 +00:00
parent 8d22a5fa95
commit 4d01bfe4a0
4 changed files with 298 additions and 237 deletions

View File

@@ -0,0 +1,290 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.server.wallpaper;
import static android.view.Display.DEFAULT_DISPLAY;
import static com.android.server.wallpaper.WallpaperUtils.RECORD_FILE;
import static com.android.server.wallpaper.WallpaperUtils.RECORD_LOCK_FILE;
import static com.android.server.wallpaper.WallpaperUtils.WALLPAPER;
import static com.android.server.wallpaper.WallpaperUtils.getWallpaperDir;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.ImageDecoder;
import android.graphics.Rect;
import android.os.FileUtils;
import android.os.SELinux;
import android.util.Slog;
import android.view.DisplayInfo;
import com.android.server.utils.TimingsTraceAndSlog;
import libcore.io.IoUtils;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
/**
* Helper file for wallpaper cropping
* Meant to have a single instance, only used by the WallpaperManagerService
*/
class WallpaperCropper {
private static final String TAG = WallpaperCropper.class.getSimpleName();
private static final boolean DEBUG = false;
private static final boolean DEBUG_CROP = true;
private final WallpaperDisplayHelper mWallpaperDisplayHelper;
WallpaperCropper(WallpaperDisplayHelper wallpaperDisplayHelper) {
mWallpaperDisplayHelper = wallpaperDisplayHelper;
}
/**
* Once a new wallpaper has been written via setWallpaper(...), it needs to be cropped
* for display.
*
* This will generate the crop and write it in the file
*/
void generateCrop(WallpaperData wallpaper) {
TimingsTraceAndSlog t = new TimingsTraceAndSlog(TAG);
t.traceBegin("WPMS.generateCrop");
generateCropInternal(wallpaper);
t.traceEnd();
}
private void generateCropInternal(WallpaperData wallpaper) {
boolean success = false;
// Only generate crop for default display.
final WallpaperDisplayHelper.DisplayData wpData =
mWallpaperDisplayHelper.getDisplayDataOrCreate(DEFAULT_DISPLAY);
final Rect cropHint = new Rect(wallpaper.cropHint);
final DisplayInfo displayInfo = mWallpaperDisplayHelper.getDisplayInfo(DEFAULT_DISPLAY);
if (DEBUG) {
Slog.v(TAG, "Generating crop for new wallpaper(s): 0x"
+ Integer.toHexString(wallpaper.mWhich)
+ " to " + wallpaper.cropFile.getName()
+ " crop=(" + cropHint.width() + 'x' + cropHint.height()
+ ") dim=(" + wpData.mWidth + 'x' + wpData.mHeight + ')');
}
// Analyse the source; needed in multiple cases
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(wallpaper.wallpaperFile.getAbsolutePath(), options);
if (options.outWidth <= 0 || options.outHeight <= 0) {
Slog.w(TAG, "Invalid wallpaper data");
success = false;
} else {
boolean needCrop = false;
boolean needScale;
// Empty crop means use the full image
if (cropHint.isEmpty()) {
cropHint.left = cropHint.top = 0;
cropHint.right = options.outWidth;
cropHint.bottom = options.outHeight;
} else {
// force the crop rect to lie within the measured bounds
int dx = cropHint.right > options.outWidth ? options.outWidth - cropHint.right : 0;
int dy = cropHint.bottom > options.outHeight
? options.outHeight - cropHint.bottom : 0;
cropHint.offset(dx, dy);
// If the crop hint was larger than the image we just overshot. Patch things up.
if (cropHint.left < 0) {
cropHint.left = 0;
}
if (cropHint.top < 0) {
cropHint.top = 0;
}
// Don't bother cropping if what we're left with is identity
needCrop = (options.outHeight > cropHint.height()
|| options.outWidth > cropHint.width());
}
// scale if the crop height winds up not matching the recommended metrics
needScale = cropHint.height() > wpData.mHeight
|| cropHint.height() > GLHelper.getMaxTextureSize()
|| cropHint.width() > GLHelper.getMaxTextureSize();
//make sure screen aspect ratio is preserved if width is scaled under screen size
if (needScale) {
final float scaleByHeight = (float) wpData.mHeight / (float) cropHint.height();
final int newWidth = (int) (cropHint.width() * scaleByHeight);
if (newWidth < displayInfo.logicalWidth) {
final float screenAspectRatio =
(float) displayInfo.logicalHeight / (float) displayInfo.logicalWidth;
cropHint.bottom = (int) (cropHint.width() * screenAspectRatio);
needCrop = true;
}
}
if (DEBUG_CROP) {
Slog.v(TAG, "crop: w=" + cropHint.width() + " h=" + cropHint.height());
Slog.v(TAG, "dims: w=" + wpData.mWidth + " h=" + wpData.mHeight);
Slog.v(TAG, "meas: w=" + options.outWidth + " h=" + options.outHeight);
Slog.v(TAG, "crop?=" + needCrop + " scale?=" + needScale);
}
if (!needCrop && !needScale) {
// Simple case: the nominal crop fits what we want, so we take
// the whole thing and just copy the image file directly.
// TODO: It is not accurate to estimate bitmap size without decoding it,
// may be we can try to remove this optimized way in the future,
// that means, we will always go into the 'else' block.
success = FileUtils.copyFile(wallpaper.wallpaperFile, wallpaper.cropFile);
if (!success) {
wallpaper.cropFile.delete();
// TODO: fall back to default wallpaper in this case
}
if (DEBUG) {
long estimateSize = (long) options.outWidth * options.outHeight * 4;
Slog.v(TAG, "Null crop of new wallpaper, estimate size="
+ estimateSize + ", success=" + success);
}
} else {
// Fancy case: crop and scale. First, we decode and scale down if appropriate.
FileOutputStream f = null;
BufferedOutputStream bos = null;
try {
// This actually downsamples only by powers of two, but that's okay; we do
// a proper scaling blit later. This is to minimize transient RAM use.
// We calculate the largest power-of-two under the actual ratio rather than
// just let the decode take care of it because we also want to remap where the
// cropHint rectangle lies in the decoded [super]rect.
final int actualScale = cropHint.height() / wpData.mHeight;
int scale = 1;
while (2 * scale <= actualScale) {
scale *= 2;
}
options.inSampleSize = scale;
options.inJustDecodeBounds = false;
final Rect estimateCrop = new Rect(cropHint);
estimateCrop.scale(1f / options.inSampleSize);
final float hRatio = (float) wpData.mHeight / estimateCrop.height();
final int destHeight = (int) (estimateCrop.height() * hRatio);
final int destWidth = (int) (estimateCrop.width() * hRatio);
// We estimated an invalid crop, try to adjust the cropHint to get a valid one.
if (destWidth > GLHelper.getMaxTextureSize()) {
int newHeight = (int) (wpData.mHeight / hRatio);
int newWidth = (int) (wpData.mWidth / hRatio);
if (DEBUG) {
Slog.v(TAG, "Invalid crop dimensions, trying to adjust.");
}
estimateCrop.set(cropHint);
estimateCrop.left += (cropHint.width() - newWidth) / 2;
estimateCrop.top += (cropHint.height() - newHeight) / 2;
estimateCrop.right = estimateCrop.left + newWidth;
estimateCrop.bottom = estimateCrop.top + newHeight;
cropHint.set(estimateCrop);
estimateCrop.scale(1f / options.inSampleSize);
}
// We've got the safe cropHint; now we want to scale it properly to
// the desired rectangle.
// That's a height-biased operation: make it fit the hinted height.
final int safeHeight = (int) (estimateCrop.height() * hRatio);
final int safeWidth = (int) (estimateCrop.width() * hRatio);
if (DEBUG_CROP) {
Slog.v(TAG, "Decode parameters:");
Slog.v(TAG, " cropHint=" + cropHint + ", estimateCrop=" + estimateCrop);
Slog.v(TAG, " down sampling=" + options.inSampleSize
+ ", hRatio=" + hRatio);
Slog.v(TAG, " dest=" + destWidth + "x" + destHeight);
Slog.v(TAG, " safe=" + safeWidth + "x" + safeHeight);
Slog.v(TAG, " maxTextureSize=" + GLHelper.getMaxTextureSize());
}
//Create a record file and will delete if ImageDecoder work well.
final String recordName =
(wallpaper.wallpaperFile.getName().equals(WALLPAPER)
? RECORD_FILE : RECORD_LOCK_FILE);
final File record = new File(getWallpaperDir(wallpaper.userId), recordName);
record.createNewFile();
Slog.v(TAG, "record path =" + record.getPath()
+ ", record name =" + record.getName());
final ImageDecoder.Source srcData =
ImageDecoder.createSource(wallpaper.wallpaperFile);
final int sampleSize = scale;
Bitmap cropped = ImageDecoder.decodeBitmap(srcData, (decoder, info, src) -> {
decoder.setTargetSampleSize(sampleSize);
decoder.setCrop(estimateCrop);
});
record.delete();
if (cropped == null) {
Slog.e(TAG, "Could not decode new wallpaper");
} else {
// We are safe to create final crop with safe dimensions now.
final Bitmap finalCrop = Bitmap.createScaledBitmap(cropped,
safeWidth, safeHeight, true);
if (DEBUG) {
Slog.v(TAG, "Final extract:");
Slog.v(TAG, " dims: w=" + wpData.mWidth
+ " h=" + wpData.mHeight);
Slog.v(TAG, " out: w=" + finalCrop.getWidth()
+ " h=" + finalCrop.getHeight());
}
f = new FileOutputStream(wallpaper.cropFile);
bos = new BufferedOutputStream(f, 32 * 1024);
finalCrop.compress(Bitmap.CompressFormat.PNG, 100, bos);
// don't rely on the implicit flush-at-close when noting success
bos.flush();
success = true;
}
} catch (Exception e) {
if (DEBUG) {
Slog.e(TAG, "Error decoding crop", e);
}
} finally {
IoUtils.closeQuietly(bos);
IoUtils.closeQuietly(f);
}
}
}
if (!success) {
Slog.e(TAG, "Unable to apply new wallpaper");
wallpaper.cropFile.delete();
}
if (wallpaper.cropFile.exists()) {
boolean didRestorecon = SELinux.restorecon(wallpaper.cropFile.getAbsoluteFile());
if (DEBUG) {
Slog.v(TAG, "restorecon() of crop file returned " + didRestorecon);
}
}
}
}

View File

@@ -27,7 +27,6 @@ import android.util.SparseArray;
import android.view.Display;
import android.view.DisplayInfo;
import com.android.internal.annotations.VisibleForTesting;
import com.android.server.wm.WindowManagerInternal;
import java.util.function.Consumer;
@@ -36,7 +35,6 @@ import java.util.function.Consumer;
*/
class WallpaperDisplayHelper {
@VisibleForTesting
static final class DisplayData {
int mWidth = -1;
int mHeight = -1;

View File

@@ -74,7 +74,6 @@ import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.graphics.ImageDecoder;
import android.graphics.Rect;
import android.graphics.RectF;
import android.hardware.display.DisplayManager;
@@ -110,7 +109,6 @@ import android.util.SparseArray;
import android.util.SparseBooleanArray;
import android.util.Xml;
import android.view.Display;
import android.view.DisplayInfo;
import com.android.internal.R;
import com.android.internal.annotations.VisibleForTesting;
@@ -133,7 +131,6 @@ import libcore.io.IoUtils;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileDescriptor;
import java.io.FileInputStream;
@@ -309,7 +306,7 @@ public class WallpaperManagerService extends IWallpaperManager.Stub
}
loadSettingsLocked(wallpaper.userId, true);
}
generateCrop(wallpaper);
mWallpaperCropper.generateCrop(wallpaper);
if (DEBUG) {
Slog.v(TAG, "Crop done; invoking completion callback");
}
@@ -594,233 +591,6 @@ public class WallpaperManagerService extends IWallpaperManager.Stub
return colors;
}
/**
* Once a new wallpaper has been written via setWallpaper(...), it needs to be cropped
* for display.
*/
void generateCrop(WallpaperData wallpaper) {
TimingsTraceAndSlog t = new TimingsTraceAndSlog(TAG);
t.traceBegin("WPMS.generateCrop");
generateCropInternal(wallpaper);
t.traceEnd();
}
private void generateCropInternal(WallpaperData wallpaper) {
boolean success = false;
// Only generate crop for default display.
final DisplayData wpData = mWallpaperDisplayHelper.getDisplayDataOrCreate(DEFAULT_DISPLAY);
final Rect cropHint = new Rect(wallpaper.cropHint);
final DisplayInfo displayInfo = mWallpaperDisplayHelper.getDisplayInfo(DEFAULT_DISPLAY);
if (DEBUG) {
Slog.v(TAG, "Generating crop for new wallpaper(s): 0x"
+ Integer.toHexString(wallpaper.mWhich)
+ " to " + wallpaper.cropFile.getName()
+ " crop=(" + cropHint.width() + 'x' + cropHint.height()
+ ") dim=(" + wpData.mWidth + 'x' + wpData.mHeight + ')');
}
// Analyse the source; needed in multiple cases
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(wallpaper.wallpaperFile.getAbsolutePath(), options);
if (options.outWidth <= 0 || options.outHeight <= 0) {
Slog.w(TAG, "Invalid wallpaper data");
success = false;
} else {
boolean needCrop = false;
boolean needScale = false;
// Empty crop means use the full image
if (cropHint.isEmpty()) {
cropHint.left = cropHint.top = 0;
cropHint.right = options.outWidth;
cropHint.bottom = options.outHeight;
} else {
// force the crop rect to lie within the measured bounds
cropHint.offset(
(cropHint.right > options.outWidth ? options.outWidth - cropHint.right : 0),
(cropHint.bottom > options.outHeight ? options.outHeight - cropHint.bottom : 0));
// If the crop hint was larger than the image we just overshot. Patch things up.
if (cropHint.left < 0) {
cropHint.left = 0;
}
if (cropHint.top < 0) {
cropHint.top = 0;
}
// Don't bother cropping if what we're left with is identity
needCrop = (options.outHeight > cropHint.height()
|| options.outWidth > cropHint.width());
}
// scale if the crop height winds up not matching the recommended metrics
needScale = cropHint.height() > wpData.mHeight
|| cropHint.height() > GLHelper.getMaxTextureSize()
|| cropHint.width() > GLHelper.getMaxTextureSize();
//make sure screen aspect ratio is preserved if width is scaled under screen size
if (needScale) {
final float scaleByHeight = (float) wpData.mHeight / (float) cropHint.height();
final int newWidth = (int) (cropHint.width() * scaleByHeight);
if (newWidth < displayInfo.logicalWidth) {
final float screenAspectRatio =
(float) displayInfo.logicalHeight / (float) displayInfo.logicalWidth;
cropHint.bottom = (int) (cropHint.width() * screenAspectRatio);
needCrop = true;
}
}
if (DEBUG_CROP) {
Slog.v(TAG, "crop: w=" + cropHint.width() + " h=" + cropHint.height());
Slog.v(TAG, "dims: w=" + wpData.mWidth + " h=" + wpData.mHeight);
Slog.v(TAG, "meas: w=" + options.outWidth + " h=" + options.outHeight);
Slog.v(TAG, "crop?=" + needCrop + " scale?=" + needScale);
}
if (!needCrop && !needScale) {
// Simple case: the nominal crop fits what we want, so we take
// the whole thing and just copy the image file directly.
// TODO: It is not accurate to estimate bitmap size without decoding it,
// may be we can try to remove this optimized way in the future,
// that means, we will always go into the 'else' block.
success = FileUtils.copyFile(wallpaper.wallpaperFile, wallpaper.cropFile);
if (!success) {
wallpaper.cropFile.delete();
// TODO: fall back to default wallpaper in this case
}
if (DEBUG) {
long estimateSize = (long) options.outWidth * options.outHeight * 4;
Slog.v(TAG, "Null crop of new wallpaper, estimate size="
+ estimateSize + ", success=" + success);
}
} else {
// Fancy case: crop and scale. First, we decode and scale down if appropriate.
FileOutputStream f = null;
BufferedOutputStream bos = null;
try {
// This actually downsamples only by powers of two, but that's okay; we do
// a proper scaling blit later. This is to minimize transient RAM use.
// We calculate the largest power-of-two under the actual ratio rather than
// just let the decode take care of it because we also want to remap where the
// cropHint rectangle lies in the decoded [super]rect.
final int actualScale = cropHint.height() / wpData.mHeight;
int scale = 1;
while (2 * scale <= actualScale) {
scale *= 2;
}
options.inSampleSize = scale;
options.inJustDecodeBounds = false;
final Rect estimateCrop = new Rect(cropHint);
estimateCrop.scale(1f / options.inSampleSize);
final float hRatio = (float) wpData.mHeight / estimateCrop.height();
final int destHeight = (int) (estimateCrop.height() * hRatio);
final int destWidth = (int) (estimateCrop.width() * hRatio);
// We estimated an invalid crop, try to adjust the cropHint to get a valid one.
if (destWidth > GLHelper.getMaxTextureSize()) {
int newHeight = (int) (wpData.mHeight / hRatio);
int newWidth = (int) (wpData.mWidth / hRatio);
if (DEBUG) {
Slog.v(TAG, "Invalid crop dimensions, trying to adjust.");
}
estimateCrop.set(cropHint);
estimateCrop.left += (cropHint.width() - newWidth) / 2;
estimateCrop.top += (cropHint.height() - newHeight) / 2;
estimateCrop.right = estimateCrop.left + newWidth;
estimateCrop.bottom = estimateCrop.top + newHeight;
cropHint.set(estimateCrop);
estimateCrop.scale(1f / options.inSampleSize);
}
// We've got the safe cropHint; now we want to scale it properly to
// the desired rectangle.
// That's a height-biased operation: make it fit the hinted height.
final int safeHeight = (int) (estimateCrop.height() * hRatio);
final int safeWidth = (int) (estimateCrop.width() * hRatio);
if (DEBUG_CROP) {
Slog.v(TAG, "Decode parameters:");
Slog.v(TAG, " cropHint=" + cropHint + ", estimateCrop=" + estimateCrop);
Slog.v(TAG, " down sampling=" + options.inSampleSize
+ ", hRatio=" + hRatio);
Slog.v(TAG, " dest=" + destWidth + "x" + destHeight);
Slog.v(TAG, " safe=" + safeWidth + "x" + safeHeight);
Slog.v(TAG, " maxTextureSize=" + GLHelper.getMaxTextureSize());
}
//Create a record file and will delete if ImageDecoder work well.
final String recordName =
(wallpaper.wallpaperFile.getName().equals(WALLPAPER)
? RECORD_FILE : RECORD_LOCK_FILE);
final File record = new File(getWallpaperDir(wallpaper.userId), recordName);
record.createNewFile();
Slog.v(TAG, "record path =" + record.getPath()
+ ", record name =" + record.getName());
final ImageDecoder.Source srcData =
ImageDecoder.createSource(wallpaper.wallpaperFile);
final int sampleSize = scale;
Bitmap cropped = ImageDecoder.decodeBitmap(srcData, (decoder, info, src) -> {
decoder.setTargetSampleSize(sampleSize);
decoder.setCrop(estimateCrop);
});
record.delete();
if (cropped == null) {
Slog.e(TAG, "Could not decode new wallpaper");
} else {
// We are safe to create final crop with safe dimensions now.
final Bitmap finalCrop = Bitmap.createScaledBitmap(cropped,
safeWidth, safeHeight, true);
if (DEBUG) {
Slog.v(TAG, "Final extract:");
Slog.v(TAG, " dims: w=" + wpData.mWidth
+ " h=" + wpData.mHeight);
Slog.v(TAG, " out: w=" + finalCrop.getWidth()
+ " h=" + finalCrop.getHeight());
}
f = new FileOutputStream(wallpaper.cropFile);
bos = new BufferedOutputStream(f, 32*1024);
finalCrop.compress(Bitmap.CompressFormat.PNG, 100, bos);
bos.flush(); // don't rely on the implicit flush-at-close when noting success
success = true;
}
} catch (Exception e) {
if (DEBUG) {
Slog.e(TAG, "Error decoding crop", e);
}
} finally {
IoUtils.closeQuietly(bos);
IoUtils.closeQuietly(f);
}
}
}
if (!success) {
Slog.e(TAG, "Unable to apply new wallpaper");
wallpaper.cropFile.delete();
}
if (wallpaper.cropFile.exists()) {
boolean didRestorecon = SELinux.restorecon(wallpaper.cropFile.getAbsoluteFile());
if (DEBUG) {
Slog.v(TAG, "restorecon() of crop file returned " + didRestorecon);
}
}
}
private final Context mContext;
private final WindowManagerInternal mWindowManagerInternal;
private final IPackageManager mIPackageManager;
@@ -913,6 +683,7 @@ public class WallpaperManagerService extends IWallpaperManager.Stub
@VisibleForTesting
final WallpaperDisplayHelper mWallpaperDisplayHelper;
final WallpaperCropper mWallpaperCropper;
private boolean supportsMultiDisplay(WallpaperConnection connection) {
if (connection != null) {
@@ -1568,6 +1339,7 @@ public class WallpaperManagerService extends IWallpaperManager.Stub
DisplayManager dm = mContext.getSystemService(DisplayManager.class);
dm.registerDisplayListener(mDisplayListener, null /* handler */);
mWallpaperDisplayHelper = new WallpaperDisplayHelper(dm, mWindowManagerInternal);
mWallpaperCropper = new WallpaperCropper(mWallpaperDisplayHelper);
mActivityManager = mContext.getSystemService(ActivityManager.class);
mMonitor = new MyPackageMonitor();
mColorsChangedListeners = new SparseArray<>();
@@ -1618,7 +1390,7 @@ public class WallpaperManagerService extends IWallpaperManager.Stub
if (DEBUG) {
Slog.i(TAG, "No crop; regenerating from source");
}
generateCrop(wallpaper);
mWallpaperCropper.generateCrop(wallpaper);
}
// Still nothing? Fall back to default.
if (!wallpaper.cropExists()) {
@@ -3459,7 +3231,7 @@ public class WallpaperManagerService extends IWallpaperManager.Stub
mWallpaperMap.put(userId, wallpaper);
if (!wallpaper.cropExists()) {
if (wallpaper.sourceExists()) {
generateCrop(wallpaper);
mWallpaperCropper.generateCrop(wallpaper);
} else {
Slog.i(TAG, "No static wallpaper imagery; defaults will be shown");
}
@@ -3680,7 +3452,7 @@ public class WallpaperManagerService extends IWallpaperManager.Stub
if (DEBUG) Slog.v(TAG, "settingsRestored: success=" + success
+ " id=" + wallpaper.wallpaperId);
if (success) {
generateCrop(wallpaper); // based on the new image + metadata
mWallpaperCropper.generateCrop(wallpaper); // based on the new image + metadata
bindWallpaperComponentLocked(wallpaper.nextWallpaperComponent, true, false,
wallpaper, null);
}

View File

@@ -420,7 +420,8 @@ public class WallpaperManagerServiceTests {
doReturn(true).when(mService)
.bindWallpaperComponentLocked(any(), anyBoolean(), anyBoolean(), any(), any());
doNothing().when(mService).saveSettingsLocked(wallpaper.userId);
doNothing().when(mService).generateCrop(wallpaper);
spyOn(mService.mWallpaperCropper);
doNothing().when(mService.mWallpaperCropper).generateCrop(wallpaper);
// timestamps of {ACTION_WALLPAPER_CHANGED, onWallpaperColorsChanged}
final long[] timestamps = new long[2];