/* * Copyright (C) 2017 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.wallpaper.asset; import android.app.Activity; import android.content.Context; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.Bitmap.Config; import android.graphics.Point; import android.graphics.Rect; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.TransitionDrawable; import android.os.Handler; import android.os.Looper; import android.view.Display; import android.view.View; import android.widget.ImageView; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import com.android.wallpaper.module.BitmapCropper; import com.android.wallpaper.module.InjectorProvider; import com.android.wallpaper.picker.preview.ui.util.CropSizeUtil; import com.android.wallpaper.util.RtlUtils; import com.android.wallpaper.util.ScreenSizeCalculator; import com.android.wallpaper.util.WallpaperCropUtils; import com.bumptech.glide.load.resource.bitmap.BitmapTransformation; import java.io.File; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * Interface representing an image asset. */ public abstract class Asset { private static final ExecutorService sExecutorService = Executors.newSingleThreadExecutor(); /** * Creates and returns a placeholder Drawable instance sized exactly to the target ImageView and * filled completely with pixels of the provided placeholder color. */ protected static Drawable getPlaceholderDrawable( Context context, ImageView imageView, int placeholderColor) { Point imageViewDimensions = getViewDimensions(imageView); Bitmap placeholderBitmap = Bitmap.createBitmap(imageViewDimensions.x, imageViewDimensions.y, Config.ARGB_8888); placeholderBitmap.eraseColor(placeholderColor); return new BitmapDrawable(context.getResources(), placeholderBitmap); } /** * Returns the visible height and width in pixels of the provided ImageView, or if it hasn't * been laid out yet, then gets the absolute value of the layout params. */ private static Point getViewDimensions(View view) { int width = view.getWidth() > 0 ? view.getWidth() : Math.abs(view.getLayoutParams().width); int height = view.getHeight() > 0 ? view.getHeight() : Math.abs(view.getLayoutParams().height); return new Point(width, height); } /** * Decodes a bitmap sized for the destination view's dimensions off the main UI thread. * * @param targetWidth Width of target view in physical pixels. * @param targetHeight Height of target view in physical pixels. * @param receiver Called with the decoded bitmap or null if there was an error decoding the * bitmap. */ public final void decodeBitmap(int targetWidth, int targetHeight, BitmapReceiver receiver) { decodeBitmap(targetWidth, targetHeight, true, receiver); } /** * Decodes a bitmap sized for the destination view's dimensions off the main UI thread. * * @param targetWidth Width of target view in physical pixels. * @param targetHeight Height of target view in physical pixels. * @param hardwareBitmapAllowed if true and it's possible, we'll try to decode into a HARDWARE * bitmap * @param receiver Called with the decoded bitmap or null if there was an error decoding the * bitmap. */ public abstract void decodeBitmap(int targetWidth, int targetHeight, boolean hardwareBitmapAllowed, BitmapReceiver receiver); /** * Copies the asset file to another place. * @param dest The destination file. */ public void copy(File dest) { // no op } /** * Decodes a full bitmap. * * @param receiver Called with the decoded bitmap or null if there was an error decoding the * bitmap. */ public abstract void decodeBitmap(BitmapReceiver receiver); /** * For {@link #decodeBitmap(int, int, BitmapReceiver)} to use when it is done. It then call * the receiver with decoded bitmap in the main thread. * * @param receiver The receiver to handle decoded bitmap or null if decoding failed. * @param decodedBitmap The bitmap which is already decoded. */ protected void decodeBitmapCompleted(BitmapReceiver receiver, Bitmap decodedBitmap) { new Handler(Looper.getMainLooper()).post(() -> receiver.onBitmapDecoded(decodedBitmap)); } /** * Decodes and downscales a bitmap region off the main UI thread. * @param rect Rect representing the crop region in terms of the original image's * resolution. * @param targetWidth Width of target view in physical pixels. * @param targetHeight Height of target view in physical pixels. * @param shouldAdjustForRtl whether the region selected should be adjusted for RTL (that is, * the crop region will be considered starting from the right) * @param receiver Called with the decoded bitmap region or null if there was an error */ public abstract void decodeBitmapRegion(Rect rect, int targetWidth, int targetHeight, boolean shouldAdjustForRtl, BitmapReceiver receiver); /** * Calculates the raw dimensions of the asset at its original resolution off the main UI thread. * Avoids decoding the entire bitmap if possible to conserve memory. * * @param activity Activity in which this decoding request is made. Allows for early termination * of fetching image data and/or decoding to a bitmap. May be null, in which * case the request is made in the application context instead. * @param receiver Called with the decoded raw dimensions of the whole image or null if there * was an error decoding the dimensions. */ public abstract void decodeRawDimensions(@Nullable Activity activity, DimensionsReceiver receiver); /** * Returns whether this asset has access to a separate, lower fidelity source of image data * (that may be able to be loaded more quickly to simulate progressive loading). */ public boolean hasLowResDataSource() { return false; } /** * Loads the asset from the separate low resolution data source (if there is one) into the * provided ImageView with the placeholder color and bitmap transformation. * * @param transformation Bitmap transformation that can transform the thumbnail image * post-decoding. */ public void loadLowResDrawable(Activity activity, ImageView imageView, int placeholderColor, BitmapTransformation transformation) { // No op } /** * Returns a Bitmap from the separate low resolution data source (if there is one) or * {@code null} otherwise. * This could be an I/O operation so DO NOT CALL ON UI THREAD */ @WorkerThread @Nullable public Bitmap getLowResBitmap(Context context) { return null; } /** * Returns whether the asset supports rendering tile regions at varying pixel densities. */ public abstract boolean supportsTiling(); /** * Loads a Drawable for this asset into the provided ImageView. While waiting for the image to * load, first loads a ColorDrawable based on the provided placeholder color. * * @param context Activity hosting the ImageView. * @param imageView ImageView which is the target view of this asset. * @param placeholderColor Color of placeholder set to ImageView while waiting for image to * load. */ public void loadDrawable(final Context context, final ImageView imageView, int placeholderColor) { // Transition from a placeholder ColorDrawable to the decoded bitmap when the ImageView in // question is empty. final boolean needsTransition = imageView.getDrawable() == null; final Drawable placeholderDrawable = new ColorDrawable(placeholderColor); if (needsTransition) { imageView.setImageDrawable(placeholderDrawable); } // Set requested height and width to the either the actual height and width of the view in // pixels, or if it hasn't been laid out yet, then to the absolute value of the layout // params. int width = imageView.getWidth() > 0 ? imageView.getWidth() : Math.abs(imageView.getLayoutParams().width); int height = imageView.getHeight() > 0 ? imageView.getHeight() : Math.abs(imageView.getLayoutParams().height); decodeBitmap(width, height, new BitmapReceiver() { @Override public void onBitmapDecoded(Bitmap bitmap) { if (!needsTransition) { imageView.setImageBitmap(bitmap); return; } Resources resources = context.getResources(); Drawable[] layers = new Drawable[2]; layers[0] = placeholderDrawable; layers[1] = new BitmapDrawable(resources, bitmap); TransitionDrawable transitionDrawable = new TransitionDrawable(layers); transitionDrawable.setCrossFadeEnabled(true); imageView.setImageDrawable(transitionDrawable); transitionDrawable.startTransition(resources.getInteger( android.R.integer.config_shortAnimTime)); } }); } /** * Loads a Drawable for this asset into the provided ImageView, providing a crossfade transition * with the given duration from the Drawable previously set on the ImageView. * * @param context Activity hosting the ImageView. * @param imageView ImageView which is the target view of this asset. * @param transitionDurationMillis Duration of the crossfade, in milliseconds. * @param drawableLoadedListener Listener called once the transition has begun. * @param placeholderColor Color of the placeholder if the provided ImageView is empty * before the */ public void loadDrawableWithTransition( final Context context, final ImageView imageView, final int transitionDurationMillis, @Nullable final DrawableLoadedListener drawableLoadedListener, int placeholderColor) { Point imageViewDimensions = getViewDimensions(imageView); // Transition from a placeholder ColorDrawable to the decoded bitmap when the ImageView in // question is empty. boolean needsPlaceholder = imageView.getDrawable() == null; if (needsPlaceholder) { imageView.setImageDrawable( getPlaceholderDrawable(context, imageView, placeholderColor)); } decodeBitmap(imageViewDimensions.x, imageViewDimensions.y, new BitmapReceiver() { @Override public void onBitmapDecoded(Bitmap bitmap) { final Resources resources = context.getResources(); centerCropBitmap(bitmap, imageView, new BitmapReceiver() { @Override public void onBitmapDecoded(@Nullable Bitmap newBitmap) { Drawable[] layers = new Drawable[2]; Drawable existingDrawable = imageView.getDrawable(); if (existingDrawable instanceof TransitionDrawable) { // Take only the second layer in the existing TransitionDrawable so // we don't keep // around a reference to older layers which are no longer shown (this // way we avoid a // memory leak). TransitionDrawable existingTransitionDrawable = (TransitionDrawable) existingDrawable; int id = existingTransitionDrawable.getId(1); layers[0] = existingTransitionDrawable.findDrawableByLayerId(id); } else { layers[0] = existingDrawable; } layers[1] = new BitmapDrawable(resources, newBitmap); TransitionDrawable transitionDrawable = new TransitionDrawable(layers); transitionDrawable.setCrossFadeEnabled(true); imageView.setImageDrawable(transitionDrawable); transitionDrawable.startTransition(transitionDurationMillis); if (drawableLoadedListener != null) { drawableLoadedListener.onDrawableLoaded(); } } }); } }); } /** * Loads the image for this asset into the provided ImageView which is used for the preview. * While waiting for the image to load, first loads a ColorDrawable based on the provided * placeholder color. * * @param activity Activity hosting the ImageView. * @param imageView ImageView which is the target view of this asset. * @param placeholderColor Color of placeholder set to ImageView while waiting for image to * load. * @param offsetToStart true to let the preview show from the start of the image, false to * center-aligned to the image. */ public void loadPreviewImage(Activity activity, ImageView imageView, int placeholderColor, boolean offsetToStart) { loadPreviewImage(activity, imageView, placeholderColor, offsetToStart, null); } /** * Loads the image for this asset into the provided ImageView which is used for the preview. * While waiting for the image to load, first loads a ColorDrawable based on the provided * placeholder color. * * @param activity Activity hosting the ImageView. * @param imageView ImageView which is the target view of this asset. * @param placeholderColor Color of placeholder set to ImageView while waiting for image to * load. * @param offsetToStart true to let the preview show from the start of the image, false to * center-aligned to the image. * @param cropHints A Map of display size to crop rect */ public void loadPreviewImage(Activity activity, ImageView imageView, int placeholderColor, boolean offsetToStart, @Nullable Map cropHints) { boolean needsTransition = imageView.getDrawable() == null; Drawable placeholderDrawable = new ColorDrawable(placeholderColor); if (needsTransition) { imageView.setImageDrawable(placeholderDrawable); } decodeRawDimensions(activity, dimensions -> { // TODO (b/286404249): A proper fix here would be to find out why the // leak happens in first place if (activity.isDestroyed()) { return; } if (dimensions == null) { loadDrawable(activity, imageView, placeholderColor); return; } boolean isRtl = RtlUtils.isRtl(activity); Display defaultDisplay = activity.getWindowManager().getDefaultDisplay(); Point screenSize = ScreenSizeCalculator.getInstance().getScreenSize(defaultDisplay); Rect visibleRawWallpaperRect = WallpaperCropUtils.calculateVisibleRect(dimensions, screenSize); if (cropHints != null && cropHints.containsKey(screenSize)) { visibleRawWallpaperRect = CropSizeUtil.INSTANCE.fitCropRectToLayoutDirection( cropHints.get(screenSize), screenSize, RtlUtils.isRtl(activity)); // For multi-crop, the visibleRawWallpaperRect above is already the exact size of // the part of wallpaper we should show on the screen, turning off the old RTL // logic by assigning false. isRtl = false; } // TODO(b/264234793): Make offsetToStart general support or for the specific asset. adjustCropRect(activity, dimensions, visibleRawWallpaperRect, offsetToStart); BitmapCropper bitmapCropper = InjectorProvider.getInjector().getBitmapCropper(); bitmapCropper.cropAndScaleBitmap(this, /* scale= */ 1f, visibleRawWallpaperRect, isRtl, new BitmapCropper.Callback() { @Override public void onBitmapCropped(Bitmap croppedBitmap) { // Since the size of the cropped bitmap may not exactly the same with // image view(maybe has 1px or 2px difference), // so set CENTER_CROP to let the bitmap to fit the image view. if (!activity.isDestroyed()) { imageView.setScaleType(ImageView.ScaleType.CENTER_CROP); if (!needsTransition) { imageView.setImageBitmap(croppedBitmap); return; } Resources resources = activity.getResources(); Drawable[] layers = new Drawable[2]; layers[0] = placeholderDrawable; layers[1] = new BitmapDrawable(resources, croppedBitmap); TransitionDrawable transitionDrawable = new TransitionDrawable(layers); transitionDrawable.setCrossFadeEnabled(true); imageView.setImageDrawable(transitionDrawable); transitionDrawable.startTransition(resources.getInteger( android.R.integer.config_shortAnimTime)); } } @Override public void onError(@Nullable Throwable e) { if (!activity.isDestroyed()) { loadDrawable(activity, imageView, placeholderColor); } } }); }); } /** * Interface for receiving decoded Bitmaps. */ public interface BitmapReceiver { /** * Called with a decoded Bitmap object or null if there was an error decoding the bitmap. */ void onBitmapDecoded(@Nullable Bitmap bitmap); } /** * Interface for receiving raw asset dimensions. */ public interface DimensionsReceiver { /** * Called with raw dimensions of asset or null if the asset is unable to decode the raw * dimensions. * * @param dimensions Dimensions as a Point where width is represented by "x" and height by * "y". */ void onDimensionsDecoded(@Nullable Point dimensions); } /** * Interface for being notified when a drawable has been loaded. */ public interface DrawableLoadedListener { void onDrawableLoaded(); } protected void adjustCropRect(Context context, Point assetDimensions, Rect cropRect, boolean offsetToStart) { WallpaperCropUtils.adjustCropRect(context, cropRect, true /* zoomIn */); } /** * Returns a copy of the given bitmap which is center cropped and scaled * to fit in the given ImageView and the thread runs on ExecutorService. */ public void centerCropBitmap(Bitmap bitmap, View view, BitmapReceiver bitmapReceiver) { Point imageViewDimensions = getViewDimensions(view); sExecutorService.execute(() -> { int measuredWidth = imageViewDimensions.x; int measuredHeight = imageViewDimensions.y; int bitmapWidth = bitmap.getWidth(); int bitmapHeight = bitmap.getHeight(); float scale = Math.min( (float) bitmapWidth / measuredWidth, (float) bitmapHeight / measuredHeight); Bitmap scaledBitmap = Bitmap.createScaledBitmap( bitmap, Math.round(bitmapWidth / scale), Math.round(bitmapHeight / scale), true); int horizontalGutterPx = Math.max(0, (scaledBitmap.getWidth() - measuredWidth) / 2); int verticalGutterPx = Math.max(0, (scaledBitmap.getHeight() - measuredHeight) / 2); Bitmap result = Bitmap.createBitmap( scaledBitmap, horizontalGutterPx, verticalGutterPx, scaledBitmap.getWidth() - (2 * horizontalGutterPx), scaledBitmap.getHeight() - (2 * verticalGutterPx)); decodeBitmapCompleted(bitmapReceiver, result); }); } }