Commit 46c8ab5b authored by Finnur Thorarinsson's avatar Finnur Thorarinsson Committed by Commit Bot

Photo Picker: Add video support behind flag (1/2).

Part 1: Show video thumbnails in the grid, but no preview (yet).

Bug: 895776, 656015
Change-Id: I1f99224e33f98dd20e26543a0d05a2b7000ff871
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1615199
Commit-Queue: Finnur Thorarinsson <finnur@chromium.org>
Reviewed-by: default avatarTheresa <twellington@chromium.org>
Reviewed-by: default avatarDavid Trainor <dtrainor@chromium.org>
Cr-Commit-Position: refs/heads/master@{#663136}
parent 76ddcd29
......@@ -1193,6 +1193,7 @@ chrome_java_sources = [
"java/src/org/chromium/chrome/browser/photo_picker/BitmapUtils.java",
"java/src/org/chromium/chrome/browser/photo_picker/DecoderService.java",
"java/src/org/chromium/chrome/browser/photo_picker/DecoderServiceHost.java",
"java/src/org/chromium/chrome/browser/photo_picker/DecodeVideoTask.java",
"java/src/org/chromium/chrome/browser/photo_picker/FileEnumWorkerTask.java",
"java/src/org/chromium/chrome/browser/photo_picker/PhotoPickerDialog.java",
"java/src/org/chromium/chrome/browser/photo_picker/PhotoPickerToolbar.java",
......
......@@ -33,6 +33,15 @@
android:background="@drawable/file_picker_scrim"
tools:ignore="ContentDescription"
android:visibility="gone" />
<TextView
android:id="@+id/video_duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:layout_marginEnd="6dp"
android:layout_gravity="end"
style="@style/TextAppearance.WhiteBody"/>
</FrameLayout>
<ImageView
......
......@@ -433,6 +433,7 @@
<dimen name="photo_picker_tile_min_size">100dp</dimen>
<dimen name="photo_picker_tile_gap">4dp</dimen>
<dimen name="photo_picker_grainy_thumbnail_size">12dp</dimen>
<dimen name="photo_picker_video_duration_offset">16dp</dimen>
<!-- Account chooser dialog dimensions -->
<dimen name="account_chooser_dialog_margin">24dp</dimen>
......
......@@ -288,6 +288,7 @@ public abstract class ChromeFeatureList {
public static final String PERMISSION_DELEGATION = "PermissionDelegation";
public static final String PER_METHOD_CAN_MAKE_PAYMENT_QUOTA =
"WebPaymentsPerMethodCanMakePaymentQuota";
public static final String PHOTO_PICKER_VIDEO_SUPPORT = "PhotoPickerVideoSupport";
public static final String WEB_PAYMENTS_REDACT_SHIPPING_ADDRESS =
"WebPaymentsRedactShippingAddress";
public static final String PREDICTIVE_PREFETCHING_ALLOWED_ON_ALL_CONNECTION_TYPES =
......
......@@ -235,6 +235,12 @@ public class ProcessInitializationHandler {
public void onPhotoPickerDismissed() {
mDialog = null;
}
@Override
public boolean supportsVideos() {
return ChromeFeatureList.isEnabled(
ChromeFeatureList.PHOTO_PICKER_VIDEO_SUPPORT);
}
});
}
......
......@@ -16,20 +16,22 @@ import org.chromium.base.task.AsyncTask;
* A worker task to scale bitmaps in the background.
*/
class BitmapScalerTask extends AsyncTask<Bitmap> {
private final LruCache<String, Bitmap> mCache;
private final LruCache<String, PickerCategoryView.Thumbnail> mCache;
private final String mFilePath;
private final int mSize;
private final Bitmap mBitmap;
private final String mVideoDuration;
/**
* A BitmapScalerTask constructor.
*/
public BitmapScalerTask(
LruCache<String, Bitmap> cache, String filePath, int size, Bitmap bitmap) {
public BitmapScalerTask(LruCache<String, PickerCategoryView.Thumbnail> cache, Bitmap bitmap,
String filePath, String videoDuration, int size) {
mCache = cache;
mFilePath = filePath;
mSize = size;
mBitmap = bitmap;
mVideoDuration = videoDuration;
}
/**
......@@ -60,6 +62,6 @@ class BitmapScalerTask extends AsyncTask<Bitmap> {
return;
}
mCache.put(mFilePath, result);
mCache.put(mFilePath, new PickerCategoryView.Thumbnail(result, mVideoDuration));
}
}
......@@ -8,6 +8,7 @@ import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Matrix;
import android.media.ExifInterface;
import android.media.MediaMetadataRetriever;
import android.os.Build;
import org.chromium.base.metrics.RecordHistogram;
......@@ -69,6 +70,26 @@ class BitmapUtils {
return sizeBitmap(bitmap, size, descriptor);
}
/**
* Given a FileDescriptor, decodes the video and returns a bitmap of dimensions |size|x|size|.
* @param descriptor The FileDescriptor for the file to read.
* @param size The width and height of the bitmap to return.
* @return The resulting bitmap.
*/
public static Bitmap decodeVideoFromFileDescriptor(
MediaMetadataRetriever retriever, FileDescriptor descriptor, int size) {
try {
retriever.setDataSource(descriptor);
} catch (RuntimeException exception) {
return null;
}
Bitmap bitmap = retriever.getFrameAtTime();
if (bitmap == null) return null;
return sizeBitmap(bitmap, size, descriptor);
}
/**
* Calculates the sub-sampling factor {@link BitmapFactory#inSampleSize} option for a given
* image dimensions, which will be used to create a bitmap of a pre-determined size (as small as
......@@ -95,7 +116,16 @@ class BitmapUtils {
private static Bitmap ensureMinSize(Bitmap bitmap, int size) {
int width = bitmap.getWidth();
int height = bitmap.getHeight();
if (width >= size && height >= size) return bitmap;
if (width == size && height == size) return bitmap;
if (width > size && height > size) {
// Both sides are larger than requested, which will lead to excessive amount of
// cropping. Shrink to a more manageable amount (shorter side becomes |size| in length).
float scale = (width < height) ? (float) width / size : (float) height / size;
width = Math.round(width / scale);
height = Math.round(height / scale);
return Bitmap.createScaledBitmap(bitmap, width, height, true);
}
if (width < size) {
float scale = (float) size / width;
......
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.photo_picker;
import android.content.ContentResolver;
import android.graphics.Bitmap;
import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.util.Pair;
import org.chromium.base.ThreadUtils;
import org.chromium.base.task.AsyncTask;
import java.io.FileNotFoundException;
import java.util.Locale;
/**
* A worker task to decode video and extract information from it off of the UI thread.
*/
class DecodeVideoTask extends AsyncTask<Pair<Bitmap, String>> {
/**
* An interface to use to communicate back the results to the client.
*/
public interface VideoDecodingCallback {
/**
* A callback to define to receive the list of all images on disk.
* @param uri The uri of the video decoded.
* @param bitmap A single frame (thumbnail) from the video.
* @param duration The duration of the video.
*/
void videoDecodedCallback(Uri uri, Bitmap bitmap, String duration);
}
// The callback to use to communicate the results.
private VideoDecodingCallback mCallback;
// The URI of the video to decode.
private Uri mUri;
// The desired width and height (in pixels) of the returned thumbnail from the video.
int mSize;
// The ContentResolver to use to retrieve image metadata from disk.
private ContentResolver mContentResolver;
// A metadata retriever, used to decode the video, and extract a thumbnail frame.
private MediaMetadataRetriever mRetriever = new MediaMetadataRetriever();
/**
* A DecodeVideoTask constructor.
* @param callback The callback to use to communicate back the results.
* @param contentResolver The ContentResolver to use to retrieve image metadata from disk.
* @param uri The URI of the video to decode.
* @param size The desired width and height (in pixels) of the returned thumbnail from the
* video.
*/
public DecodeVideoTask(
VideoDecodingCallback callback, ContentResolver contentResolver, Uri uri, int size) {
mCallback = callback;
mContentResolver = contentResolver;
mUri = uri;
mSize = size;
}
/**
* Converts a duration string in ms to a human-readable form.
* @param durationMs The duration in milliseconds.
* @return The duration in human-readable form.
*/
private String formatDuration(String durationMs) {
if (durationMs == null) return null;
long duration = Long.parseLong(durationMs) / 1000;
long hours = duration / 3600;
duration -= hours * 3600;
long minutes = duration / 60;
duration -= minutes * 60;
long seconds = duration;
if (hours > 0) {
return String.format(Locale.US, "%02d:%02d:%02d", hours, minutes, seconds);
} else {
return String.format(Locale.US, "%02d:%02d", minutes, seconds);
}
}
/**
* Decodes a video and extracts metadata and a thumbnail. Called on a non-UI thread
* @return A pair of bitmap (video thumbnail) and the duration of the video.
*/
@Override
protected Pair<Bitmap, String> doInBackground() {
assert !ThreadUtils.runningOnUiThread();
if (isCancelled()) return null;
try {
Bitmap bitmap = BitmapUtils.decodeVideoFromFileDescriptor(mRetriever,
mContentResolver.openAssetFileDescriptor(mUri, "r").getFileDescriptor(), mSize);
String duration =
mRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION);
return new Pair<Bitmap, String>(bitmap, formatDuration(duration));
} catch (FileNotFoundException exception) {
return null;
}
}
/**
* Communicates the results back to the client. Called on the UI thread.
* @param results A pair of bitmap (video thumbnail) and the duration of the video.
*/
@Override
protected void onPostExecute(Pair<Bitmap, String> results) {
if (isCancelled()) {
return;
}
if (results == null) {
mCallback.videoDecodedCallback(mUri, null, "");
return;
}
mCallback.videoDecodedCallback(mUri, results.first, results.second);
}
}
......@@ -12,6 +12,7 @@ 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;
......@@ -23,6 +24,7 @@ import android.support.annotation.Nullable;
import org.chromium.base.Log;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.task.AsyncTask;
import org.chromium.base.task.PostTask;
import org.chromium.chrome.browser.util.ConversionUtils;
import org.chromium.content_public.browser.UiThreadTaskTraits;
......@@ -36,7 +38,8 @@ import java.util.List;
/**
* A class to communicate with the {@link DecoderService}.
*/
public class DecoderServiceHost extends IDecoderServiceCallback.Stub {
public class DecoderServiceHost
extends IDecoderServiceCallback.Stub implements DecodeVideoTask.VideoDecodingCallback {
// A tag for logging error messages.
private static final String TAG = "ImageDecoderHost";
......@@ -52,6 +55,9 @@ public class DecoderServiceHost extends IDecoderServiceCallback.Stub {
// The number of out of memory failures during decoding, per batch.
private int mFailedDecodesMemory;
// A worker task for asynchronously handling video decode requests.
private DecodeVideoTask mWorkerTask;
// A callback to use for testing to see if decoder is ready.
static ServiceReadyCallback sReadyCallbackForTesting;
......@@ -92,8 +98,9 @@ public class DecoderServiceHost extends IDecoderServiceCallback.Stub {
* 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 bitmap The results of the decoding (or placeholder image, if failed).
* @param videoDuration The time-length of the video (null if not a video).
*/
void imageDecodedCallback(String filePath, Bitmap bitmap);
void imageDecodedCallback(String filePath, Bitmap bitmap, String videoDuration);
}
/**
......@@ -106,15 +113,21 @@ public class DecoderServiceHost extends IDecoderServiceCallback.Stub {
// The requested size (width and height) of the bitmap, once decoded.
public int mSize;
// The type of media being decoded.
@PickerBitmap.TileTypes
int mFileType;
// The callback to use to communicate the results of the decoding.
ImageDecodedCallback mCallback;
// The timestamp for when the request was sent for decoding.
long mTimestamp;
public DecoderServiceParams(Uri uri, int size, ImageDecodedCallback callback) {
public DecoderServiceParams(Uri uri, int size, @PickerBitmap.TileTypes int fileType,
ImageDecodedCallback callback) {
mUri = uri;
mSize = size;
mFileType = fileType;
mCallback = callback;
}
}
......@@ -171,11 +184,13 @@ public class DecoderServiceHost extends IDecoderServiceCallback.Stub {
* 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 size The requested size (width and height) of the resulting bitmap.
* @param callback The callback to use to communicate the decoding results.
*/
public void decodeImage(Uri uri, int size, ImageDecodedCallback callback) {
DecoderServiceParams params = new DecoderServiceParams(uri, size, callback);
public void decodeImage(Uri uri, @PickerBitmap.TileTypes int fileType, int size,
ImageDecodedCallback callback) {
DecoderServiceParams params = new DecoderServiceParams(uri, size, fileType, callback);
mRequests.put(uri.getPath(), params);
if (mRequests.size() == 1) dispatchNextDecodeImageRequest();
}
......@@ -187,7 +202,7 @@ public class DecoderServiceHost extends IDecoderServiceCallback.Stub {
if (mRequests.entrySet().iterator().hasNext()) {
DecoderServiceParams params = mRequests.entrySet().iterator().next().getValue();
params.mTimestamp = SystemClock.elapsedRealtime();
dispatchDecodeImageRequest(params.mUri, params.mSize);
dispatchDecodeImageRequest(params.mUri, params.mFileType, params.mSize);
} else {
int totalRequests = mSuccessfulDecodes + mFailedDecodesRuntime + mFailedDecodesMemory;
if (totalRequests > 0) {
......@@ -206,6 +221,18 @@ public class DecoderServiceHost extends IDecoderServiceCallback.Stub {
}
}
/**
* A callback that receives the results of the video decoding.
* @param uri The uri of the decoded video.
* @param bitmap The thumbnail representing the decoded video.
* @param duration The video duration (a formatted human-readable string, for example "3:00").
*/
@Override
public void videoDecodedCallback(Uri uri, Bitmap bitmap, String duration) {
// TODO(finnur): Add corresponding UMA for video decoding.
closeRequest(uri.getPath(), bitmap, duration, -1);
}
@Override
public void onDecodeImageDone(final Bundle payload) {
// As per the Android documentation, AIDL callbacks can (and will) happen on any thread, so
......@@ -221,7 +248,7 @@ public class DecoderServiceHost extends IDecoderServiceCallback.Stub {
: null;
long decodeTime = payload.getLong(DecoderService.KEY_DECODE_TIME);
mSuccessfulDecodes++;
closeRequest(filePath, bitmap, decodeTime);
closeRequest(filePath, bitmap, null, decodeTime);
} catch (RuntimeException e) {
mFailedDecodesRuntime++;
} catch (OutOfMemoryError e) {
......@@ -238,14 +265,15 @@ public class DecoderServiceHost extends IDecoderServiceCallback.Stub {
* @param bitmap The resulting decoded bitmap, or null if decoding fails.
* @param decodeTime The length of time it took to decode the bitmap.
*/
public void closeRequest(String filePath, @Nullable Bitmap bitmap, long decodeTime) {
public void closeRequest(
String filePath, @Nullable Bitmap bitmap, String videoDuration, long decodeTime) {
DecoderServiceParams params = getRequests().get(filePath);
if (params != null) {
long endRpcCall = SystemClock.elapsedRealtime();
RecordHistogram.recordTimesHistogram(
"Android.PhotoPicker.RequestProcessTime", endRpcCall - params.mTimestamp);
params.mCallback.imageDecodedCallback(filePath, bitmap);
params.mCallback.imageDecodedCallback(filePath, bitmap, videoDuration);
if (decodeTime != -1 && bitmap != null) {
RecordHistogram.recordTimesHistogram(
......@@ -265,7 +293,17 @@ public class DecoderServiceHost extends IDecoderServiceCallback.Stub {
* @param uri The URI of the image on disk.
* @param size The requested width and height of the resulting bitmap.
*/
private void dispatchDecodeImageRequest(Uri uri, int size) {
private void dispatchDecodeImageRequest(
Uri uri, @PickerBitmap.TileTypes int fileType, int size) {
if (fileType == PickerBitmap.TileTypes.VIDEO) {
// Videos are decoded by the system (on N+) using a restricted helper process, so
// there's no need to use our custom sandboxed process.
assert Build.VERSION.SDK_INT >= Build.VERSION_CODES.N;
mWorkerTask = new DecodeVideoTask(this, mContentResolver, uri, size);
mWorkerTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
return;
}
// Obtain a file descriptor to send over to the sandboxed process.
ParcelFileDescriptor pfd = null;
Bundle bundle = new Bundle();
......@@ -279,12 +317,12 @@ public class DecoderServiceHost extends IDecoderServiceCallback.Stub {
afd = mContentResolver.openAssetFileDescriptor(uri, "r");
} catch (FileNotFoundException e) {
Log.e(TAG, "Unable to obtain FileDescriptor: " + e);
closeRequest(uri.getPath(), null, -1);
closeRequest(uri.getPath(), null, null, -1);
return;
}
pfd = afd.getParcelFileDescriptor();
if (pfd == null) {
closeRequest(uri.getPath(), null, -1);
closeRequest(uri.getPath(), null, null, -1);
return;
}
} finally {
......@@ -300,10 +338,10 @@ public class DecoderServiceHost extends IDecoderServiceCallback.Stub {
pfd.close();
} catch (RemoteException e) {
Log.e(TAG, "Communications failed (Remote): " + e);
closeRequest(uri.getPath(), null, -1);
closeRequest(uri.getPath(), null, null, -1);
} catch (IOException e) {
Log.e(TAG, "Communications failed (IO): " + e);
closeRequest(uri.getPath(), null, -1);
closeRequest(uri.getPath(), null, null, -1);
}
}
......
......@@ -46,10 +46,16 @@ class FileEnumWorkerTask extends AsyncTask<List<PickerBitmap>> {
// The filter to apply to the list.
private MimeTypeFilter mFilter;
// Whether any image MIME types were requested.
private boolean mIncludeImages;
// Whether any video MIME types were requested.
private boolean mIncludeVideos;
// The ContentResolver to use to retrieve image metadata from disk.
private ContentResolver mContentResolver;
// The camera directory undir DCIM.
// The camera directory under DCIM.
private static final String SAMPLE_DCIM_SOURCE_SUB_DIRECTORY = "Camera";
/**
......@@ -60,11 +66,21 @@ class FileEnumWorkerTask extends AsyncTask<List<PickerBitmap>> {
* @param contentResolver The ContentResolver to use to retrieve image metadata from disk.
*/
public FileEnumWorkerTask(WindowAndroid windowAndroid, FilesEnumeratedCallback callback,
MimeTypeFilter filter, ContentResolver contentResolver) {
MimeTypeFilter filter, List<String> mimeTypes, ContentResolver contentResolver) {
mWindowAndroid = windowAndroid;
mCallback = callback;
mFilter = filter;
mContentResolver = contentResolver;
for (String mimeType : mimeTypes) {
if (mimeType.startsWith("image/")) {
mIncludeImages = true;
} else if (mimeType.startsWith("video/")) {
mIncludeVideos = true;
}
if (mIncludeImages && mIncludeVideos) break;
}
}
/**
......@@ -76,7 +92,6 @@ class FileEnumWorkerTask extends AsyncTask<List<PickerBitmap>> {
/**
* Enumerates (in the background) the image files on disk. Called on a non-UI thread
* @param params Ignored, do not use.
* @return A sorted list of images (by last-modified first).
*/
@Override
......@@ -89,14 +104,29 @@ class FileEnumWorkerTask extends AsyncTask<List<PickerBitmap>> {
// The DATA column is deprecated in the Android Q SDK. Replaced by relative_path.
String directoryColumnName =
BuildInfo.isAtLeastQ() ? "relative_path" : MediaStore.Images.Media.DATA;
final String[] selectColumns = {MediaStore.Images.Media._ID,
MediaStore.Images.Media.DATE_TAKEN, directoryColumnName};
BuildInfo.isAtLeastQ() ? "relative_path" : MediaStore.Files.FileColumns.DATA;
final String[] selectColumns = {
MediaStore.Files.FileColumns._ID,
MediaStore.Files.FileColumns.DATE_ADDED,
MediaStore.Files.FileColumns.MEDIA_TYPE,
MediaStore.Files.FileColumns.MIME_TYPE,
directoryColumnName,
};
String whereClause = "(" + directoryColumnName + " LIKE ? OR " + directoryColumnName
+ " LIKE ? OR " + directoryColumnName + " LIKE ?) AND " + directoryColumnName
+ " NOT LIKE ?";
String[] whereArgs = null;
String additionalClause = "";
if (mIncludeImages) {
additionalClause = MediaStore.Files.FileColumns.MEDIA_TYPE + "="
+ MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE;
}
if (mIncludeVideos) {
if (mIncludeImages) additionalClause += " OR ";
additionalClause += MediaStore.Files.FileColumns.MEDIA_TYPE + "="
+ MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO;
}
if (!additionalClause.isEmpty()) whereClause += " AND (" + additionalClause + ")";
String cameraDir = getCameraDirectory();
String picturesDir = Environment.DIRECTORY_PICTURES;
......@@ -110,24 +140,37 @@ class FileEnumWorkerTask extends AsyncTask<List<PickerBitmap>> {
Environment.getExternalStoragePublicDirectory(screenshotsDir).toString();
}
whereArgs = new String[] {
String[] whereArgs = new String[] {
// Include:
cameraDir + "%", picturesDir + "%", downloadsDir + "%",
cameraDir + "%",
picturesDir + "%",
downloadsDir + "%",
// Exclude low-quality sources, such as the screenshots directory:
screenshotsDir + "%"};
screenshotsDir + "%",
};
final String orderBy = MediaStore.Images.Media.DATE_TAKEN + " DESC";
final String orderBy = MediaStore.MediaColumns.DATE_ADDED + " DESC";
Cursor imageCursor = mContentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
selectColumns, whereClause, whereArgs, orderBy);
Uri contentUri = MediaStore.Files.getContentUri("external");
Cursor imageCursor =
mContentResolver.query(contentUri, selectColumns, whereClause, whereArgs, orderBy);
while (imageCursor.moveToNext()) {
int dateTakenIndex = imageCursor.getColumnIndex(MediaStore.Images.Media.DATE_TAKEN);
int idIndex = imageCursor.getColumnIndex(MediaStore.Images.ImageColumns._ID);
Uri uri = ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, imageCursor.getInt(idIndex));
int mimeTypeIndex = imageCursor.getColumnIndex(MediaStore.Files.FileColumns.MIME_TYPE);
String mimeType = imageCursor.getString(mimeTypeIndex);
if (!mFilter.accept(null, mimeType)) continue;
int dateTakenIndex =
imageCursor.getColumnIndex(MediaStore.Files.FileColumns.DATE_ADDED);
int idIndex = imageCursor.getColumnIndex(MediaStore.Files.FileColumns._ID);
Uri uri = ContentUris.withAppendedId(contentUri, imageCursor.getInt(idIndex));
long dateTaken = imageCursor.getLong(dateTakenIndex);
pickerBitmaps.add(new PickerBitmap(uri, dateTaken, PickerBitmap.TileTypes.PICTURE));
@PickerBitmap.TileTypes
int type = PickerBitmap.TileTypes.PICTURE;
if (mimeType.startsWith("video/")) type = PickerBitmap.TileTypes.VIDEO;
pickerBitmaps.add(new PickerBitmap(uri, dateTaken, type));
}
imageCursor.close();
......
......@@ -19,12 +19,13 @@ import java.util.Date;
*/
public class PickerBitmap implements Comparable<PickerBitmap> {
// The possible types of tiles involved in the viewer.
@IntDef({TileTypes.PICTURE, TileTypes.CAMERA, TileTypes.GALLERY})
@IntDef({TileTypes.PICTURE, TileTypes.CAMERA, TileTypes.GALLERY, TileTypes.VIDEO})
@Retention(RetentionPolicy.SOURCE)
public @interface TileTypes {
int PICTURE = 0;
int CAMERA = 1;
int GALLERY = 2;
int VIDEO = 3;
}
// The URI of the bitmap to show.
......
......@@ -4,6 +4,8 @@
package org.chromium.chrome.browser.photo_picker;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
......@@ -54,6 +56,9 @@ public class PickerBitmapView extends SelectableItemView<PickerBitmap> {
// The image view containing the bitmap.
private ImageView mIconView;
// For video tiles, this lists the duration of the video. Blank for other types.
private TextView mVideoDuration;
// The little shader in the top left corner (provides backdrop for selection ring on
// unfavorable image backgrounds).
private ImageView mScrim;
......@@ -97,6 +102,7 @@ public class PickerBitmapView extends SelectableItemView<PickerBitmap> {
mSpecialTile = findViewById(R.id.special_tile);
mSpecialTileIcon = (ImageView) findViewById(R.id.special_tile_icon);
mSpecialTileLabel = (TextView) findViewById(R.id.special_tile_label);
mVideoDuration = (TextView) findViewById(R.id.video_duration);
}
@Override
......@@ -172,12 +178,22 @@ public class PickerBitmapView extends SelectableItemView<PickerBitmap> {
if (needsResize) {
float start;
float end;
float videoDurationOffsetX;
float videoDurationOffsetY;
if (size != mCategoryView.getImageSize()) {
start = 1f;
end = 0.8f;
float pixels = getResources().getDimensionPixelSize(
R.dimen.photo_picker_video_duration_offset);
videoDurationOffsetX = -pixels;
videoDurationOffsetY = pixels;
} else {
start = 0.8f;
end = 1f;
videoDurationOffsetX = 0;
videoDurationOffsetY = 0;
}
Animation animation = new ScaleAnimation(
......@@ -188,6 +204,15 @@ public class PickerBitmapView extends SelectableItemView<PickerBitmap> {
animation.setDuration(ANIMATION_DURATION);
animation.setFillAfter(true); // Keep the results of the animation.
mIconView.startAnimation(animation);
ObjectAnimator videoDurationX = ObjectAnimator.ofFloat(
mVideoDuration, View.TRANSLATION_X, videoDurationOffsetX);
ObjectAnimator videoDurationY = ObjectAnimator.ofFloat(
mVideoDuration, View.TRANSLATION_Y, videoDurationOffsetY);
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.playTogether(videoDurationX, videoDurationY);
animatorSet.setDuration(ANIMATION_DURATION);
animatorSet.start();
}
}
......@@ -222,10 +247,11 @@ public class PickerBitmapView extends SelectableItemView<PickerBitmap> {
* respond to click events.
* @param bitmapDetails The details about the bitmap represented by this PickerBitmapView.
* @param thumbnail The Bitmap to use for the thumbnail (or null).
* @param videoDuration The time-length of the video (human-friendly string).
* @param placeholder Whether the image given is a placeholder or the actual image.
*/
public void initialize(
PickerBitmap bitmapDetails, @Nullable Bitmap thumbnail, boolean placeholder) {
public void initialize(PickerBitmap bitmapDetails, @Nullable Bitmap thumbnail,
String videoDuration, boolean placeholder) {
resetTile();
mBitmapDetails = bitmapDetails;
......@@ -234,7 +260,7 @@ public class PickerBitmapView extends SelectableItemView<PickerBitmap> {
initializeSpecialTile(mBitmapDetails);
mImageLoaded = true;
} else {
setThumbnailBitmap(thumbnail);
setThumbnailBitmap(thumbnail, videoDuration);
mImageLoaded = !placeholder;
}
......@@ -246,18 +272,20 @@ public class PickerBitmapView extends SelectableItemView<PickerBitmap> {
* @param bitmapDetails The details about the bitmap represented by this PickerBitmapView.
*/
public void initializeSpecialTile(PickerBitmap bitmapDetails) {
int labelStringId;
Drawable image;
int labelStringId = 0;
Drawable image = null;
Resources resources = mContext.getResources();
if (isCameraTile()) {
image = VectorDrawableCompat.create(
resources, R.drawable.ic_photo_camera_grey, mContext.getTheme());
labelStringId = R.string.photo_picker_camera;
} else {
} else if (isGalleryTile()) {
image = VectorDrawableCompat.create(
resources, R.drawable.ic_collections_grey, mContext.getTheme());
labelStringId = R.string.photo_picker_browse;
} else {
assert false;
}
mSpecialTileIcon.setImageDrawable(image);
......@@ -277,10 +305,12 @@ public class PickerBitmapView extends SelectableItemView<PickerBitmap> {
* Sets a thumbnail bitmap for the current view and ensures the selection border is showing, if
* the image has already been selected.
* @param thumbnail The Bitmap to use for the icon ImageView.
* @param videoDuration The time-length of the video (human-friendly string).
* @return True if no image was loaded before (e.g. not even a low-res image).
*/
public boolean setThumbnailBitmap(Bitmap thumbnail) {
public boolean setThumbnailBitmap(Bitmap thumbnail, String videoDuration) {
mIconView.setImageBitmap(thumbnail);
if (videoDuration != null) mVideoDuration.setText(videoDuration);
// If the tile has been selected before the bitmap has loaded, make sure it shows up with
// a selection border on load.
......@@ -320,6 +350,7 @@ public class PickerBitmapView extends SelectableItemView<PickerBitmap> {
*/
private void resetTile() {
mIconView.setImageBitmap(null);
mVideoDuration.setText("");
mUnselectedView.setVisibility(View.GONE);
mSelectedView.setVisibility(View.GONE);
mScrim.setVisibility(View.GONE);
......@@ -378,6 +409,7 @@ public class PickerBitmapView extends SelectableItemView<PickerBitmap> {
}
private boolean isPictureTile() {
return mBitmapDetails.type() == PickerBitmap.TileTypes.PICTURE;
return mBitmapDetails.type() == PickerBitmap.TileTypes.PICTURE
|| mBitmapDetails.type() == PickerBitmap.TileTypes.VIDEO;
}
}
......@@ -42,20 +42,21 @@ public class PickerBitmapViewHolder
// DecoderServiceHost.ImageDecodedCallback
@Override
public void imageDecodedCallback(String filePath, Bitmap bitmap) {
public void imageDecodedCallback(String filePath, Bitmap bitmap, String videoDuration) {
if (bitmap == null || bitmap.getWidth() == 0 || bitmap.getHeight() == 0) {
return;
}
if (mCategoryView.getHighResBitmaps().get(filePath) == null) {
mCategoryView.getHighResBitmaps().put(filePath, bitmap);
if (mCategoryView.getHighResThumbnails().get(filePath) == null) {
mCategoryView.getHighResThumbnails().put(
filePath, new PickerCategoryView.Thumbnail(bitmap, videoDuration));
}
if (mCategoryView.getLowResBitmaps().get(filePath) == null) {
if (mCategoryView.getLowResThumbnails().get(filePath) == null) {
Resources resources = mItemView.getContext().getResources();
new BitmapScalerTask(mCategoryView.getLowResBitmaps(), filePath,
resources.getDimensionPixelSize(R.dimen.photo_picker_grainy_thumbnail_size),
bitmap)
new BitmapScalerTask(mCategoryView.getLowResThumbnails(), bitmap, filePath,
videoDuration,
resources.getDimensionPixelSize(R.dimen.photo_picker_grainy_thumbnail_size))
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
......@@ -63,7 +64,7 @@ public class PickerBitmapViewHolder
return;
}
if (mItemView.setThumbnailBitmap(bitmap)) {
if (mItemView.setThumbnailBitmap(bitmap, videoDuration)) {
mItemView.fadeInThumbnail();
}
}
......@@ -83,20 +84,21 @@ public class PickerBitmapViewHolder
if (mBitmapDetails.type() == PickerBitmap.TileTypes.CAMERA
|| mBitmapDetails.type() == PickerBitmap.TileTypes.GALLERY) {
mItemView.initialize(mBitmapDetails, null, false);
mItemView.initialize(mBitmapDetails, null, null, false);
return PickerAdapter.DecodeActions.NO_ACTION;
}
String filePath = mBitmapDetails.getUri().getPath();
Bitmap original = mCategoryView.getHighResBitmaps().get(filePath);
PickerCategoryView.Thumbnail original = mCategoryView.getHighResThumbnails().get(filePath);
if (original != null) {
mItemView.initialize(mBitmapDetails, original, false);
mItemView.initialize(mBitmapDetails, original.bitmap, original.videoDuration, false);
return PickerAdapter.DecodeActions.FROM_CACHE;
}
int size = mCategoryView.getImageSize();
Bitmap placeholder = mCategoryView.getLowResBitmaps().get(filePath);
if (placeholder != null) {
PickerCategoryView.Thumbnail payload = mCategoryView.getLowResThumbnails().get(filePath);
if (payload != null) {
Bitmap placeholder = payload.bitmap;
// For performance stats see http://crbug.com/719919.
long begin = SystemClock.elapsedRealtime();
placeholder = BitmapUtils.scale(placeholder, size, false);
......@@ -104,12 +106,13 @@ public class PickerBitmapViewHolder
RecordHistogram.recordTimesHistogram(
"Android.PhotoPicker.UpscaleLowResBitmap", scaleTime);
mItemView.initialize(mBitmapDetails, placeholder, true);
mItemView.initialize(mBitmapDetails, placeholder, payload.videoDuration, true);
} else {
mItemView.initialize(mBitmapDetails, null, true);
mItemView.initialize(mBitmapDetails, null, null, true);
}
mCategoryView.getDecoderServiceHost().decodeImage(mBitmapDetails.getUri(), size, this);
mCategoryView.getDecoderServiceHost().decodeImage(
mBitmapDetails.getUri(), mBitmapDetails.type(), size, this);
return PickerAdapter.DecodeActions.DECODE;
}
......
......@@ -52,6 +52,20 @@ public class PickerCategoryView extends RelativeLayout
private static final int ACTION_BROWSE = 3;
private static final int ACTION_BOUNDARY = 4;
/**
* A container class for keeping track of the data we need to show a photo/video tile in the
* photo picker (the data we store in the cache).
*/
static public class Thumbnail {
public Bitmap bitmap;
public String videoDuration;
Thumbnail(Bitmap bitmap, String videoDuration) {
this.bitmap = bitmap;
this.videoDuration = videoDuration;
}
}
// The dialog that owns us.
private PhotoPickerDialog mDialog;
......@@ -88,13 +102,13 @@ public class PickerCategoryView extends RelativeLayout
// The {@link SelectionDelegate} keeping track of which images are selected.
private SelectionDelegate<PickerBitmap> mSelectionDelegate;
// A low-resolution cache for images, lazily created. Helpful for cache misses from the
// A low-resolution cache for thumbnails, lazily created. Helpful for cache misses from the
// high-resolution cache to avoid showing gray squares (we show pixelated versions instead until
// image can be loaded off disk, which is much less jarring).
private DiscardableReference<LruCache<String, Bitmap>> mLowResBitmaps;
private DiscardableReference<LruCache<String, Thumbnail>> mLowResThumbnails;
// A high-resolution cache for images, lazily created.
private DiscardableReference<LruCache<String, Bitmap>> mHighResBitmaps;
// A high-resolution cache for thumbnails, lazily created.
private DiscardableReference<LruCache<String, Thumbnail>> mHighResThumbnails;
// The size of the low-res cache.
private int mCacheSizeLarge;
......@@ -118,7 +132,7 @@ public class PickerCategoryView extends RelativeLayout
// A worker task for asynchronously enumerating files off the main thread.
private FileEnumWorkerTask mWorkerTask;
// The timestap for the start of the enumeration of files on disk.
// The timestamp for the start of the enumeration of files on disk.
private long mEnumStartTime;
// Whether the connection to the service has been established.
......@@ -303,20 +317,20 @@ public class PickerCategoryView extends RelativeLayout
return mDecoderServiceHost;
}
public LruCache<String, Bitmap> getLowResBitmaps() {
if (mLowResBitmaps == null || mLowResBitmaps.get() == null) {
mLowResBitmaps =
mActivity.getReferencePool().put(new LruCache<String, Bitmap>(mCacheSizeSmall));
public LruCache<String, Thumbnail> getLowResThumbnails() {
if (mLowResThumbnails == null || mLowResThumbnails.get() == null) {
mLowResThumbnails = mActivity.getReferencePool().put(
new LruCache<String, Thumbnail>(mCacheSizeSmall));
}
return mLowResBitmaps.get();
return mLowResThumbnails.get();
}
public LruCache<String, Bitmap> getHighResBitmaps() {
if (mHighResBitmaps == null || mHighResBitmaps.get() == null) {
mHighResBitmaps =
mActivity.getReferencePool().put(new LruCache<String, Bitmap>(mCacheSizeLarge));
public LruCache<String, Thumbnail> getHighResThumbnails() {
if (mHighResThumbnails == null || mHighResThumbnails.get() == null) {
mHighResThumbnails = mActivity.getReferencePool().put(
new LruCache<String, Thumbnail>(mCacheSizeLarge));
}
return mHighResBitmaps.get();
return mHighResThumbnails.get();
}
public boolean isMultiSelectAllowed() {
......@@ -372,7 +386,7 @@ public class PickerCategoryView extends RelativeLayout
mEnumStartTime = SystemClock.elapsedRealtime();
mWorkerTask = new FileEnumWorkerTask(mActivity.getWindowAndroid(), this,
new MimeTypeFilter(mMimeTypes, true), mActivity.getContentResolver());
new MimeTypeFilter(mMimeTypes, true), mMimeTypes, mActivity.getContentResolver());
mWorkerTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
......
......@@ -152,6 +152,7 @@ const base::Feature* kFeaturesExposedToJava[] = {
&kOmniboxSpareRenderer,
&kOverlayNewLayout,
&kPayWithGoogleV1,
&kPhotoPickerVideoSupport,
&kProgressBarThrottleFeature,
&kPwaImprovedSplashScreen,
&kPwaPersistentNotification,
......@@ -470,6 +471,11 @@ const base::Feature kOverlayNewLayout{"OverlayNewLayout",
const base::Feature kPayWithGoogleV1{"PayWithGoogleV1",
base::FEATURE_ENABLED_BY_DEFAULT};
// TODO(finnur): Before enabling by default, the issue of where decoding should
// take place needs to be resolved.
const base::Feature kPhotoPickerVideoSupport{"PhotoPickerVideoSupport",
base::FEATURE_DISABLED_BY_DEFAULT};
const base::Feature kProgressBarThrottleFeature{
"ProgressBarThrottle", base::FEATURE_DISABLED_BY_DEFAULT};
......
......@@ -89,6 +89,7 @@ extern const base::Feature kNTPLaunchAfterInactivity;
extern const base::Feature kOmniboxSpareRenderer;
extern const base::Feature kOverlayNewLayout;
extern const base::Feature kPayWithGoogleV1;
extern const base::Feature kPhotoPickerVideoSupport;
extern const base::Feature kProgressBarThrottleFeature;
extern const base::Feature kPwaImprovedSplashScreen;
extern const base::Feature kPwaPersistentNotification;
......
......@@ -129,6 +129,11 @@ public class UiUtils {
* Called when the photo picker dialog has been dismissed.
*/
void onPhotoPickerDismissed();
/**
* Returns whether video decoding support is supported in the photo picker.
*/
boolean supportsVideos();
}
// ContactsPickerDelegate:
......@@ -186,6 +191,15 @@ public class UiUtils {
return sPhotoPickerDelegate != null;
}
/**
* Returns whether the photo picker supports showing videos.
*/
public static boolean photoPickerSupportsVideo() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false;
if (!shouldShowPhotoPicker()) return false;
return sPhotoPickerDelegate.supportsVideos();
}
/**
* Called to display the photo picker.
* @param context The context to use.
......
......@@ -246,7 +246,7 @@ public class SelectFileDialog implements WindowAndroid.IntentCallback, PhotoPick
Activity activity = mWindowAndroid.getActivity().get();
// Use the new photo picker, if available.
List<String> imageMimeTypes = convertToImageMimeTypes(mFileTypes);
List<String> imageMimeTypes = convertToSupportedPhotoPickerTypes(mFileTypes);
if (shouldUsePhotoPicker()
&& UiUtils.showPhotoPicker(activity, this, mAllowMultiple, imageMimeTypes)) {
return;
......@@ -308,26 +308,28 @@ public class SelectFileDialog implements WindowAndroid.IntentCallback, PhotoPick
* 4.) There is a valid Android Activity associated with the file request.
*/
private boolean shouldUsePhotoPicker() {
List<String> imageMimeTypes = convertToImageMimeTypes(mFileTypes);
return !captureImage() && imageMimeTypes != null && UiUtils.shouldShowPhotoPicker()
List<String> mediaMimeTypes = convertToSupportedPhotoPickerTypes(mFileTypes);
return !captureImage() && mediaMimeTypes != null && UiUtils.shouldShowPhotoPicker()
&& mWindowAndroid.getActivity().get() != null;
}
/**
* Converts a list of extensions and Mime types to a list of de-duped Mime types containing
* image types only. If the input list contains a non-image type, then null is returned.
* Converts a list of extensions and Mime types to a list of de-duped Mime types supported by
* the photo picker only. If the input list contains a unsupported type, then null is returned.
* @param fileTypes the list of filetypes (extensions and Mime types) to convert.
* @return A de-duped list of Image Mime types only, or null if one or more non-image types were
* given as input.
* @return A de-duped list of supported types only, or null if one or more unsupported types
* were given as input.
*/
@VisibleForTesting
public static List<String> convertToImageMimeTypes(List<String> fileTypes) {
public static List<String> convertToSupportedPhotoPickerTypes(List<String> fileTypes) {
if (fileTypes.size() == 0) return null;
List<String> mimeTypes = new ArrayList<>();
for (String type : fileTypes) {
String mimeType = ensureMimeType(type);
if (!mimeType.startsWith("image/")) {
return null;
if (!UiUtils.photoPickerSupportsVideo() || !mimeType.startsWith("video/")) {
return null;
}
}
if (!mimeTypes.contains(mimeType)) mimeTypes.add(mimeType);
}
......@@ -736,7 +738,7 @@ public class SelectFileDialog implements WindowAndroid.IntentCallback, PhotoPick
}
private boolean eligibleForPhotoPicker() {
return convertToImageMimeTypes(mFileTypes) != null;
return convertToSupportedPhotoPickerTypes(mFileTypes) != null;
}
private void onFileSelected(
......
......@@ -100,7 +100,7 @@ public class SelectFileDialogTest {
shadowMimeTypeMap.addExtensionMimeTypMapping("jpg", "image/jpeg");
shadowMimeTypeMap.addExtensionMimeTypMapping("gif", "image/gif");
shadowMimeTypeMap.addExtensionMimeTypMapping("txt", "text/plain");
shadowMimeTypeMap.addExtensionMimeTypMapping("mpg", "audio/mpeg");
shadowMimeTypeMap.addExtensionMimeTypMapping("mpg", "video/mpeg");
assertEquals("", SelectFileDialog.ensureMimeType(""));
assertEquals("image/jpeg", SelectFileDialog.ensureMimeType(".jpg"));
......@@ -108,26 +108,35 @@ public class SelectFileDialogTest {
// Unknown extension, expect default response:
assertEquals("application/octet-stream", SelectFileDialog.ensureMimeType(".flv"));
assertEquals(null, SelectFileDialog.convertToImageMimeTypes(new ArrayList<>()));
assertEquals(null, SelectFileDialog.convertToImageMimeTypes(Arrays.asList("")));
assertEquals(null, SelectFileDialog.convertToImageMimeTypes(Arrays.asList("foo/bar")));
assertEquals(null, SelectFileDialog.convertToSupportedPhotoPickerTypes(new ArrayList<>()));
assertEquals(null, SelectFileDialog.convertToSupportedPhotoPickerTypes(Arrays.asList("")));
assertEquals(null,
SelectFileDialog.convertToSupportedPhotoPickerTypes(Arrays.asList("foo/bar")));
assertEquals(Arrays.asList("image/jpeg"),
SelectFileDialog.convertToImageMimeTypes(Arrays.asList(".jpg")));
SelectFileDialog.convertToSupportedPhotoPickerTypes(Arrays.asList(".jpg")));
assertEquals(Arrays.asList("image/jpeg"),
SelectFileDialog.convertToImageMimeTypes(Arrays.asList("image/jpeg")));
SelectFileDialog.convertToSupportedPhotoPickerTypes(Arrays.asList("image/jpeg")));
assertEquals(Arrays.asList("image/jpeg"),
SelectFileDialog.convertToImageMimeTypes(Arrays.asList(".jpg", "image/jpeg")));
SelectFileDialog.convertToSupportedPhotoPickerTypes(
Arrays.asList(".jpg", "image/jpeg")));
assertEquals(Arrays.asList("image/gif", "image/jpeg"),
SelectFileDialog.convertToImageMimeTypes(Arrays.asList(".gif", "image/jpeg")));
SelectFileDialog.convertToSupportedPhotoPickerTypes(
Arrays.asList(".gif", "image/jpeg")));
// Returns null because generic picker is required (due to addition of .txt file).
// Video and mixed video/images support. This feature is supported, but off by default, so
// expect failure until it is turned on by default.
assertEquals(
null, SelectFileDialog.convertToSupportedPhotoPickerTypes(Arrays.asList(".mpg")));
assertEquals(null,
SelectFileDialog.convertToImageMimeTypes(
Arrays.asList(".txt", ".jpg", "image/jpeg")));
// Returns null because video file is included.
SelectFileDialog.convertToSupportedPhotoPickerTypes(Arrays.asList("video/mpeg")));
assertEquals(null,
SelectFileDialog.convertToImageMimeTypes(
SelectFileDialog.convertToSupportedPhotoPickerTypes(
Arrays.asList(".jpg", "image/jpeg", ".mpg")));
// Returns null because generic picker is required (due to addition of .txt file).
assertEquals(null,
SelectFileDialog.convertToSupportedPhotoPickerTypes(
Arrays.asList(".txt", ".jpg", "image/jpeg")));
}
@Test
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment