// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.components.browser_ui.photo_picker;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.res.AssetFileDescriptor;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
import android.os.ParcelFileDescriptor;
import android.os.SystemClock;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import org.chromium.base.Log;
import org.chromium.base.StreamUtil;
import org.chromium.base.StrictModeContext;
import org.chromium.base.ThreadUtils;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.supplier.Supplier;
import org.chromium.base.task.AsyncTask;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.components.browser_ui.util.ConversionUtils;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.PriorityQueue;
/** A class to communicate with the {@link DecoderService}. */
public class DecoderServiceHost extends IDecoderServiceCallback.Stub
implements DecodeVideoTask.VideoDecodingCallback {
// A tag for logging error messages.
private static final String TAG = "ImageDecoderHost";
// The current context.
private final Context mContext;
// A content resolver for providing file descriptors for the images.
private ContentResolver mContentResolver;
// The number of successful image decodes (not video), per batch.
private int mSuccessfulImageDecodes;
// The number of runtime failures during image decoding (not video), per batch.
private int mFailedImageDecodesRuntime;
// The number of out of memory failures during image decoding (not video), per batch.
private int mFailedImageDecodesMemory;
// The number of successful video decodes, per batch.
private int mSuccessfulVideoDecodes;
// The number of file errors during video decoding, per batch.
private int mFailedVideoDecodesFile;
// The number of runtime failures during video decoding, per batch.
private int mFailedVideoDecodesRuntime;
// The number of io failures during video decoding, per batch.
private int mFailedVideoDecodesIo;
// The number of io failures during video decoding, per batch.
private int mFailedVideoDecodesUnknown;
// A worker task for asynchronously handling video decode requests.
private DecodeVideoTask mWorkerTask;
// The current processing request.
private DecoderServiceParams mProcessingRequest;
// The callbacks used to notify the clients when the service is ready.
private final List<DecoderStatusCallback> mCallbacks = new ArrayList<DecoderStatusCallback>();
// Keeps track of the last decoding ordinal issued.
static int sLastDecodingOrdinal = 0;
// A callback to use for testing to see if decoder is ready.
static DecoderStatusCallback sStatusCallbackForTesting;
// Used to create intents for launching the {@link DecoderService} service.
private static Supplier<Intent> sIntentSupplier;
/**
* Sets a factory for creating intents that launch the {@link DecoderService} service. This must
* be called prior to using the PhotoPicker.
*
* @param intentSupplier a factory that creates a new Intent. Will be called every time the
* PhotoPicker is launched.
*/
public static void setIntentSupplier(@NonNull Supplier<Intent> intentSupplier) {
sIntentSupplier = intentSupplier;
}
// This is true after {#link bindService()} has been called for {@link mConnection}. It
// indicates that {@link unbindService()} should be called.
private boolean mBindServiceCalled;
IDecoderService mIRemoteService;
private ServiceConnection mConnection =
new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName className, IBinder service) {
mIRemoteService = IDecoderService.Stub.asInterface(service);
assert mIRemoteService != null;
for (DecoderStatusCallback callback : mCallbacks) {
callback.serviceReady();
}
}
@Override
public void onServiceDisconnected(ComponentName className) {
Log.e(TAG, "Service has unexpectedly disconnected");
mIRemoteService = null;
}
};
/** Interface for notifying clients of status of the decoder service. */
public interface DecoderStatusCallback {
/** A function to define to receive a notification once the service is up and running. */
void serviceReady();
/** Called when the decoder is idle. */
void decoderIdle();
}
/** An interface notifying clients when all images have finished decoding. */
public interface ImagesDecodedCallback {
/**
* A function to define to receive a notification that an image has been decoded.
*
* @param filePath The file path for the newly decoded image.
* @param isVideo Whether the decoding was from a video or not.
* @param fullWidth Whether the image is using the full width of the screen.
* @param bitmaps The results of the decoding (or placeholder image, if failed).
* @param videoDuration The time-length of the video (null if not a video).
*/
void imagesDecodedCallback(
String filePath,
boolean isVideo,
boolean fullWidth,
List<Bitmap> bitmaps,
String videoDuration,
float ratio);
}
/** Class for keeping track of the data involved with each request. */
protected static class DecoderServiceParams {
// The URI for the file containing the bitmap to decode.
final Uri mUri;
// The requested width of the bitmap, once decoded.
final int mWidth;
// Whether this is image is taking up the full width of the screen.
final boolean mFullWidth;
// The type of media being decoded.
@PickerBitmap.TileTypes final int mFileType;
// Whether this is a request to decode only the first frame of a video. This field is
// ignored for non-videos.
final boolean mFirstFrame;
// An ordinal used to enforce FIFO decoding, in the case where all other things are equal
// (when it comes to determining which record to decode first).
private int mRequestOrdinal;
// The callback to use to communicate the results of the decoding.
final ImagesDecodedCallback mCallback;
// The timestamp for when the request was sent for decoding.
long mTimestamp;
public DecoderServiceParams(
Uri uri,
int width,
boolean fullWidth,
@PickerBitmap.TileTypes int fileType,
boolean firstFrame,
ImagesDecodedCallback callback) {
mUri = uri;
mWidth = width;
mFullWidth = fullWidth;
mFileType = fileType;
mFirstFrame = firstFrame;
mRequestOrdinal = sLastDecodingOrdinal++;
mCallback = callback;
}
}
// A request comparator that assign priority in the following way:
// Top priority: Still images (TileType.PICTURES).
// Medium priority: Videos (decoding of first frame only).
// Low priority: Videos (multiple frames for animating).
protected Comparator<DecoderServiceParams> mRequestComparator =
(r1, r2) -> {
if (r1.mFileType != r2.mFileType) {
return r1.mFileType - r2.mFileType;
}
// If they are both video types, then first frame decoding has priority.
if (r1.mFileType == PickerBitmap.TileTypes.VIDEO
&& r1.mFirstFrame != r2.mFirstFrame) {
return r1.mFirstFrame ? -1 : 1;
}
// The two requests share the same file type, or are identical video requests (both
// requesting first frame or both requesting additional frames) so they can be
// considered equal. Go with first in first out.
return r1.mRequestOrdinal - r2.mRequestOrdinal;
};
// A queue of pending requests.
PriorityQueue<DecoderServiceParams> mPendingRequests =
new PriorityQueue<>(/* initialCapacity= */ 1, mRequestComparator);
/**
* The DecoderServiceHost constructor.
*
* @param callback The callback to use when communicating back to the client.
* @param context The current context.
*/
public DecoderServiceHost(DecoderStatusCallback callback, Context context) {
mCallbacks.add(callback);
if (sStatusCallbackForTesting != null) {
mCallbacks.add(sStatusCallbackForTesting);
}
mContext = context;
mContentResolver = mContext.getContentResolver();
}
/** Initiate binding with the {@link DecoderService}. */
public void bind() {
Intent intent = sIntentSupplier.get();
intent.setAction(IDecoderService.class.getName());
mContext.bindService(intent, mConnection, Context.BIND_AUTO_CREATE);
mBindServiceCalled = true;
}
/** Unbind from the {@link DecoderService}. */
public void unbind() {
if (mBindServiceCalled) mContext.unbindService(mConnection);
mBindServiceCalled = false;
}
/**
* Accepts a request to decode a single image. Queues up the request and reports back
* asynchronously on |callback|.
*
* @param uri The URI of the file to decode.
* @param fileType The type of image being sent for decoding.
* @param width The requested size (width and height) of the resulting bitmap.
* @param fullWidth Whether the image is using the full width of the screen.
* @param callback The callback to use to communicate the decoding results.
*/
public void decodeImage(
Uri uri,
@PickerBitmap.TileTypes int fileType,
int width,
boolean fullWidth,
ImagesDecodedCallback callback) {
ThreadUtils.assertOnUiThread();
DecoderServiceParams params =
new DecoderServiceParams(
uri, width, fullWidth, fileType, /* firstFrame= */ true, callback);
mPendingRequests.add(params);
if (mProcessingRequest == null) dispatchNextDecodeRequest();
}
/**
* Fetches the next decoding request from the queue (and removes it from the queue). Note: Still
* images are preferred over videos (if available) because they are both faster to complete
* decoding and (arguably) also more likely to be shared by the user.
*
* @return Next pending request (of highest priority).
*/
private DecoderServiceParams getNextPending() {
if (mPendingRequests.isEmpty()) {
return null;
}
return mPendingRequests.remove();
}
/** Dispatches the next image/video for decoding (from the queue). */
private void dispatchNextDecodeRequest() {
// A new decoding request should not be dispatched while something is already in progress.
assert mProcessingRequest == null;
mProcessingRequest = getNextPending();
if (mProcessingRequest != null) {
mProcessingRequest.mTimestamp = SystemClock.elapsedRealtime();
if (mProcessingRequest.mFileType != PickerBitmap.TileTypes.VIDEO) {
dispatchDecodeImageRequest(mProcessingRequest);
} else {
dispatchDecodeVideoRequest(mProcessingRequest, mProcessingRequest.mFirstFrame);
}
return;
}
int totalImageRequests =
mSuccessfulImageDecodes + mFailedImageDecodesRuntime + mFailedImageDecodesMemory;
if (totalImageRequests > 0) {
// Calculate and transmit UMA for image decoding.
int runtimeFailures = 100 * mFailedImageDecodesRuntime / totalImageRequests;
RecordHistogram.recordPercentageHistogram(
"Android.PhotoPicker.DecoderHostFailureRuntime", runtimeFailures);
int memoryFailures = 100 * mFailedImageDecodesMemory / totalImageRequests;
RecordHistogram.recordPercentageHistogram(
"Android.PhotoPicker.DecoderHostFailureOutOfMemory", memoryFailures);
mSuccessfulImageDecodes = 0;
mFailedImageDecodesRuntime = 0;
mFailedImageDecodesMemory = 0;
}
int totalVideoRequests =
mSuccessfulVideoDecodes
+ mFailedVideoDecodesFile
+ mFailedVideoDecodesRuntime
+ mFailedVideoDecodesIo
+ mFailedVideoDecodesUnknown;
if (totalVideoRequests > 0) {
// Calculate and transmit UMA for video decoding.
int videoFileFailures = 100 * mFailedVideoDecodesFile / totalVideoRequests;
RecordHistogram.recordPercentageHistogram(
"Android.PhotoPicker.DecoderHostVideoFileError", videoFileFailures);
int videoRuntimeFailures = 100 * mFailedVideoDecodesRuntime / totalVideoRequests;
RecordHistogram.recordPercentageHistogram(
"Android.PhotoPicker.DecoderHostVideoRuntimeError", videoRuntimeFailures);
int videoIoFailures = 100 * mFailedVideoDecodesIo / totalVideoRequests;
RecordHistogram.recordPercentageHistogram(
"Android.PhotoPicker.DecoderHostVideoIoError", videoIoFailures);
int videoUnknownFailures = 100 * mFailedVideoDecodesUnknown / totalVideoRequests;
RecordHistogram.recordPercentageHistogram(
"Android.PhotoPicker.DecoderHostVideoUnknownError", videoUnknownFailures);
mSuccessfulVideoDecodes = 0;
mFailedVideoDecodesFile = 0;
mFailedVideoDecodesRuntime = 0;
mFailedVideoDecodesIo = 0;
mFailedVideoDecodesUnknown = 0;
}
for (DecoderStatusCallback callback : mCallbacks) {
callback.decoderIdle();
}
}
/**
* A callback that receives the results of the video decoding.
*
* @param uri The uri of the decoded video.
* @param bitmaps The thumbnails representing the decoded video.
* @param duration The video duration (a formatted human-readable string, for example "3:00").
* @param fullWidth Whether the image is using the full width of the screen.
* @param decodingResult Whether the decoding was successful.
* @param ratio The ratio of the first decoded frame in the video (>1.0=portrait,
* <1.0=landscape).
*/
@Override
public void videoDecodedCallback(
Uri uri,
List<Bitmap> bitmaps,
String duration,
boolean fullWidth,
@DecodeVideoTask.DecodingResult int decodingResult,
float ratio) {
switch (decodingResult) {
case DecodeVideoTask.DecodingResult.SUCCESS:
if (bitmaps == null || bitmaps.size() == 0) {
mFailedVideoDecodesUnknown++;
} else {
mSuccessfulVideoDecodes++;
}
break;
case DecodeVideoTask.DecodingResult.FILE_ERROR:
mFailedVideoDecodesFile++;
break;
case DecodeVideoTask.DecodingResult.RUNTIME_ERROR:
mFailedVideoDecodesRuntime++;
break;
case DecodeVideoTask.DecodingResult.IO_ERROR:
mFailedVideoDecodesIo++;
break;
}
closeRequest(uri.getPath(), true, fullWidth, bitmaps, duration, -1, ratio);
}
@Override
public void onDecodeImageDone(final Bundle payload) {
// As per the Android documentation, AIDL callbacks can (and will) happen on any thread, so
// make sure the code runs on the UI thread, since further down the callchain the code will
// end up creating UI objects.
PostTask.runOrPostTask(
TaskTraits.UI_DEFAULT,
() -> {
String filePath = "";
List<Bitmap> bitmaps = null;
Boolean fullWidth = false;
float ratio = 0;
long decodeTime = -1;
try {
// Read the reply back from the service.
filePath = payload.getString(ImageDecoder.KEY_FILE_PATH);
Boolean success = payload.getBoolean(ImageDecoder.KEY_SUCCESS);
Bitmap bitmap =
success
? (Bitmap)
payload.getParcelable(ImageDecoder.KEY_IMAGE_BITMAP)
: null;
ratio = payload.getFloat(ImageDecoder.KEY_RATIO);
decodeTime = payload.getLong(ImageDecoder.KEY_DECODE_TIME);
fullWidth = payload.getBoolean(ImageDecoder.KEY_FULL_WIDTH);
mSuccessfulImageDecodes++;
bitmaps = new ArrayList<>(1);
bitmaps.add(bitmap);
} catch (RuntimeException e) {
mFailedImageDecodesRuntime++;
} catch (OutOfMemoryError e) {
mFailedImageDecodesMemory++;
} finally {
closeRequest(
filePath,
/* isVideo= */ false,
fullWidth,
bitmaps,
/* videoDuration= */ null,
decodeTime,
ratio);
}
});
}
private void closeRequestWithError(String filePath) {
closeRequest(filePath, false, false, null, null, -1, 1.0f);
}
/**
* Ties up all the loose ends from the decoding request (communicates the results of the
* decoding process back to the client, and takes care of house-keeping chores regarding the
* request queue).
*
* @param filePath The path to the image that was just decoded.
* @param isVideo True if the request was for video decoding.
* @param fullWidth Whether the image is using the full width of the screen.
* @param bitmaps The resulting decoded bitmaps, or null if decoding fails.
* @param decodeTime The length of time it took to decode the bitmaps.
* @param ratio The ratio of the images (>1.0=portrait, <1.0=landscape).
*/
private void closeRequest(
String filePath,
boolean isVideo,
boolean fullWidth,
@Nullable List<Bitmap> bitmaps,
String videoDuration,
long decodeTime,
float ratio) {
// If this assert triggers, it means that simultaneous requests have been sent for
// decoding, which should not happen.
assert mProcessingRequest.mUri.getPath().equals(filePath);
long endRpcCall = SystemClock.elapsedRealtime();
if (isVideo && bitmaps != null) {
if (bitmaps != null && bitmaps.size() > 1) {
RecordHistogram.recordTimesHistogram(
"Android.PhotoPicker.RequestProcessTimeAnimation",
endRpcCall - mProcessingRequest.mTimestamp);
} else {
RecordHistogram.recordTimesHistogram(
"Android.PhotoPicker.RequestProcessTimeThumbnail",
endRpcCall - mProcessingRequest.mTimestamp);
}
} else {
RecordHistogram.recordTimesHistogram(
"Android.PhotoPicker.RequestProcessTime",
endRpcCall - mProcessingRequest.mTimestamp);
}
mProcessingRequest.mCallback.imagesDecodedCallback(
filePath, isVideo, fullWidth, bitmaps, videoDuration, ratio);
if (decodeTime != -1 && bitmaps != null && bitmaps.get(0) != null) {
int sizeInKB = bitmaps.get(0).getByteCount() / ConversionUtils.BYTES_PER_KILOBYTE;
if (isVideo) {
if (bitmaps.size() > 1) {
RecordHistogram.recordTimesHistogram(
"Android.PhotoPicker.VideoDecodeTimeAnimation", decodeTime);
} else {
RecordHistogram.recordTimesHistogram(
"Android.PhotoPicker.VideoDecodeTimeThumbnail", decodeTime);
RecordHistogram.recordCustomCountHistogram(
"Android.PhotoPicker.VideoByteCount", sizeInKB, 1, 100000, 50);
}
} else {
RecordHistogram.recordTimesHistogram(
"Android.PhotoPicker.ImageDecodeTime", decodeTime);
RecordHistogram.recordCustomCountHistogram(
"Android.PhotoPicker.ImageByteCount", sizeInKB, 1, 100000, 50);
}
}
mProcessingRequest = null;
dispatchNextDecodeRequest();
}
/**
* Communicates with the utility process to decode a single video.
*
* @param params The information about the decoding request.
* @param firstFrame True if the decoding request is for the first frame only.
*/
private void dispatchDecodeVideoRequest(DecoderServiceParams params, boolean firstFrame) {
// Videos are decoded by the system (on O+) using a restricted helper process, so
// there's no need to use our custom sandboxed process.
assert Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
int frames = firstFrame ? 1 : 10;
int intervalMs = 2000;
mWorkerTask =
new DecodeVideoTask(
this,
mContentResolver,
params.mUri,
params.mWidth,
params.mFullWidth,
frames,
intervalMs);
mWorkerTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
/**
* Communicates with the server to decode a single bitmap.
*
* @param params The information about the decoding request.
*/
private void dispatchDecodeImageRequest(DecoderServiceParams params) {
if (mIRemoteService == null) {
// If the connection is lost, ignore the request and continue. Further still image
// decoding requests will likely be dropped but note that there may be video requests
// remaining (which don't require this connection to be open).
Log.e(TAG, "Connection to decoder service unexpectedly terminated.");
closeRequestWithError(mProcessingRequest.mUri.getPath());
return;
}
// Obtain a file descriptor to send over to the sandboxed process.
ParcelFileDescriptor pfd = null;
Bundle bundle = new Bundle();
// The restricted utility process can't open the file to read the
// contents, so we need to obtain a file descriptor to pass over.
try (StrictModeContext ignored = StrictModeContext.allowAllThreadPolicies()) {
AssetFileDescriptor afd = null;
try {
afd = mContentResolver.openAssetFileDescriptor(params.mUri, "r");
} catch (Exception e) {
// FileNotFoundException, IllegalStateException, IllegalArgumentException.
Log.e(TAG, "Unable to obtain FileDescriptor", e);
closeRequestWithError(params.mUri.getPath());
return;
}
pfd = afd.getParcelFileDescriptor();
if (pfd == null) {
closeRequestWithError(params.mUri.getPath());
return;
}
}
// Prepare and send the data over.
bundle.putString(ImageDecoder.KEY_FILE_PATH, params.mUri.getPath());
bundle.putParcelable(ImageDecoder.KEY_FILE_DESCRIPTOR, pfd);
bundle.putInt(ImageDecoder.KEY_WIDTH, params.mWidth);
bundle.putBoolean(ImageDecoder.KEY_FULL_WIDTH, params.mFullWidth);
try {
mIRemoteService.decodeImage(bundle, this);
} catch (Exception e) {
// RemoteException, IOException.
Log.e(TAG, "IPC Failed", e);
closeRequestWithError(params.mUri.getPath());
}
StreamUtil.closeQuietly(pfd);
}
/**
* Cancels a request to decode an image (if it hasn't already been dispatched).
*
* @param filePath The path to the image to cancel decoding.
*/
public void cancelDecodeImage(String filePath) {
ThreadUtils.assertOnUiThread();
// It is important not to null out only pending requests and not mProcessingRequest, because
// it is used as a signal to see if the decoder is busy.
Iterator it = mPendingRequests.iterator();
while (it.hasNext()) {
DecoderServiceParams param = (DecoderServiceParams) it.next();
if (param.mUri.getPath().equals(filePath)) it.remove();
}
}
/** Sets a callback to use when the service is ready. For testing use only. */
@VisibleForTesting
public static void setStatusCallback(DecoderStatusCallback callback) {
sStatusCallbackForTesting = callback;
}
}