chromium/components/browser_ui/photo_picker/android/java/src/org/chromium/components/browser_ui/photo_picker/PickerCategoryView.java

// 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.ContentResolver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.Rect;
import android.net.Uri;
import android.os.SystemClock;
import android.transition.ChangeBounds;
import android.transition.Transition;
import android.transition.TransitionManager;
import android.util.DisplayMetrics;
import android.util.LruCache;
import android.view.LayoutInflater;
import android.view.View;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.RelativeLayout;

import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

import org.chromium.base.DiscardableReferencePool.DiscardableReference;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.task.AsyncTask;
import org.chromium.components.browser_ui.util.ConversionUtils;
import org.chromium.components.browser_ui.util.GlobalDiscardableReferencePool;
import org.chromium.components.browser_ui.widget.selectable_list.SelectableListLayout;
import org.chromium.components.browser_ui.widget.selectable_list.SelectionDelegate;
import org.chromium.net.MimeTypeFilter;
import org.chromium.ui.base.PhotoPickerListener;
import org.chromium.ui.base.ViewUtils;
import org.chromium.ui.base.WindowAndroid;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;

/**
 * A class for keeping track of common data associated with showing photos in the photo picker, for
 * example the RecyclerView and the bitmap caches.
 */
public class PickerCategoryView extends RelativeLayout
        implements FileEnumWorkerTask.FilesEnumeratedCallback,
                RecyclerView.RecyclerListener,
                DecoderServiceHost.DecoderStatusCallback,
                View.OnClickListener,
                SelectionDelegate.SelectionObserver<PickerBitmap> {
    // These values are written to logs.  New enum values can be added, but existing
    // enums must never be renumbered or deleted and reused.
    private static final int ACTION_CANCEL = 0;
    private static final int ACTION_PHOTO_PICKED = 1;
    private static final int ACTION_NEW_PHOTO = 2;
    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).
     */
    public static class Thumbnail {
        public List<Bitmap> bitmaps;
        public Boolean fullWidth;
        public String videoDuration;

        // The calculated ratio of the originals for the bitmaps above, were they to be shown
        // un-cropped. NOTE: The |bitmaps| above may already have been cropped and as such might
        // have a different ratio.
        public float ratioOriginal;

        Thumbnail(List<Bitmap> bitmaps, String videoDuration, Boolean fullWidth, float ratio) {
            this.bitmaps = bitmaps;
            this.videoDuration = videoDuration;
            this.fullWidth = fullWidth;
            this.ratioOriginal = ratio;
        }
    }

    // The dialog that owns us.
    private PhotoPickerDialog mDialog;

    // The view containing the RecyclerView and the toolbar, etc.
    private SelectableListLayout<PickerBitmap> mSelectableListLayout;

    // The {@link WindowAndroid} for the hosting WebContents.
    private WindowAndroid mWindowAndroid;

    // The ContentResolver to use to retrieve image metadata from disk.
    private ContentResolver mContentResolver;

    // The list of images on disk, sorted by last-modified first.
    private List<PickerBitmap> mPickerBitmaps;

    // True if multi-selection is allowed in the picker.
    private boolean mMultiSelectionAllowed;

    // The callback to notify the listener of decisions reached in the picker.
    private PhotoPickerListener mListener;

    // The host class for the decoding service.
    private DecoderServiceHost mDecoderServiceHost;

    // The RecyclerView showing the images.
    private RecyclerView mRecyclerView;

    // The {@link PickerAdapter} for the RecyclerView.
    private PickerAdapter mPickerAdapter;

    // The layout manager for the RecyclerView.
    private GridLayoutManager mLayoutManager;

    // The decoration to use for the RecyclerView.
    private GridSpacingItemDecoration mSpacingDecoration;

    // The {@link SelectionDelegate} keeping track of which images are selected.
    private SelectionDelegate<PickerBitmap> mSelectionDelegate;

    // 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, Thumbnail>> mLowResThumbnails;

    // A high-resolution cache for thumbnails, lazily created.
    private DiscardableReference<LruCache<String, Thumbnail>> mHighResThumbnails;

    // A cache for full-screen versions of images, lazily created.
    private DiscardableReference<LruCache<String, Thumbnail>> mFullScreenBitmaps;

    // The size of the low-res cache.
    private int mCacheSizeLarge;

    // The size of the high-res cache.
    private int mCacheSizeSmall;

    // The size of the full-screen cache.
    private int mCacheSizeFullScreen;

    // Whether we are in magnifying mode (one image per column).
    private boolean mMagnifyingMode;

    // Whether we are in the middle of animating between magnifying modes.
    private boolean mZoomSwitchingInEffect;

    /**
     * The number of columns to show. Note: mColumns and mPadding (see below) should both be even
     * numbers or both odd, not a mix (the column padding will not be of uniform thickness if they
     * are a mix).
     */
    private int mColumns;

    // The padding between columns. See also comment for mColumns.
    private int mPadding;

    // The width of the bitmaps.
    private int mImageWidth;

    // The height of the bitmaps.
    private int mImageHeight;

    // The height of the special tiles.
    private int mSpecialTileHeight;

    // A worker task for asynchronously enumerating files off the main thread.
    private FileEnumWorkerTask mWorkerTask;

    // The timestamp for the start of the enumeration of files on disk.
    private long mEnumStartTime;

    // Whether the connection to the service has been established.
    private boolean mServiceReady;

    // The MIME types requested.
    private List<String> mMimeTypes;

    // A list of files to use for testing (instead of reading files on disk).
    private static List<PickerBitmap> sTestFiles;

    // The Video Player.
    private final PickerVideoPlayer mVideoPlayer;

    // The Zoom (floating action) button.
    private ImageView mZoom;

    /**
     * @param windowAndroid The window of the {@link WebContents} that requested the photo
     *     selection.
     * @param contentResolver The ContentResolver to use to retrieve image metadata from disk.
     * @param multiSelectionAllowed Whether to allow the user to select more than one image.
     */
    @SuppressWarnings("unchecked") // mSelectableListLayout
    public PickerCategoryView(
            WindowAndroid windowAndroid,
            ContentResolver contentResolver,
            boolean multiSelectionAllowed,
            PhotoPickerToolbar.PhotoPickerToolbarDelegate delegate) {
        super(windowAndroid.getContext().get());
        mWindowAndroid = windowAndroid;
        Context context = mWindowAndroid.getContext().get();
        mContentResolver = contentResolver;
        mMultiSelectionAllowed = multiSelectionAllowed;

        mDecoderServiceHost = new DecoderServiceHost(this, context);
        mDecoderServiceHost.bind();

        mSelectionDelegate = new SelectionDelegate<PickerBitmap>();
        mSelectionDelegate.addObserver(this);
        if (!multiSelectionAllowed) mSelectionDelegate.setSingleSelectionMode();

        View root = LayoutInflater.from(context).inflate(R.layout.photo_picker_dialog, this);
        mSelectableListLayout =
                (SelectableListLayout<PickerBitmap>) root.findViewById(R.id.selectable_list);

        mPickerAdapter = new PickerAdapter(this);
        mRecyclerView = mSelectableListLayout.initializeRecyclerView(mPickerAdapter);
        int titleId =
                multiSelectionAllowed
                        ? R.string.photo_picker_select_images
                        : R.string.photo_picker_select_image;
        PhotoPickerToolbar toolbar =
                (PhotoPickerToolbar)
                        mSelectableListLayout.initializeToolbar(
                                R.layout.photo_picker_toolbar,
                                mSelectionDelegate,
                                titleId,
                                0,
                                0,
                                null,
                                false);
        toolbar.setNavigationOnClickListener(this);
        toolbar.setDelegate(delegate);
        Button doneButton = (Button) toolbar.findViewById(R.id.done);
        doneButton.setOnClickListener(this);
        mVideoPlayer = findViewById(R.id.playback_container);
        mZoom = findViewById(R.id.zoom);

        calculateGridMetrics();

        mLayoutManager = new GridLayoutManager(context, mColumns);
        mRecyclerView.setHasFixedSize(true);
        mRecyclerView.setLayoutManager(mLayoutManager);
        mSpacingDecoration = new GridSpacingItemDecoration(mColumns, mPadding);
        mRecyclerView.addItemDecoration(mSpacingDecoration);
        mRecyclerView.setRecyclerListener(this);

        final long maxMemory = ConversionUtils.bytesToKilobytes(Runtime.getRuntime().maxMemory());
        mCacheSizeFullScreen = (int) (maxMemory / 4); // 1/4 of the available memory.
        mCacheSizeLarge = (int) (maxMemory / 4); // 1/4 of the available memory.
        mCacheSizeSmall = (int) (maxMemory / 8); // 1/8th of the available memory.
    }

    @Override
    public void onConfigurationChanged(Configuration newConfig) {
        super.onConfigurationChanged(newConfig);

        calculateGridMetrics();
        mLayoutManager.setSpanCount(mColumns);
        mRecyclerView.removeItemDecoration(mSpacingDecoration);
        mSpacingDecoration = new GridSpacingItemDecoration(mColumns, mPadding);
        mRecyclerView.addItemDecoration(mSpacingDecoration);

        // Configuration change can happen at any time, even before the photos have been
        // enumerated (when mPickerBitmaps is null, causing: https://crbug.com/947657). There's no
        // need to call notifyDataSetChanged in that case because it will be called once the photo
        // list becomes ready.
        if (mPickerBitmaps != null) {
            mPickerAdapter.notifyDataSetChanged();
            ViewUtils.requestLayout(mRecyclerView, "PickerCategoryView.onConfigurationChanged");
        }
    }

    /** Severs the connection to the decoding utility process and cancels any outstanding requests. */
    public void onDialogDismissed() {
        if (mWorkerTask != null) {
            mWorkerTask.cancel(true);
            mWorkerTask = null;
        }

        if (mDecoderServiceHost != null) {
            mDecoderServiceHost.unbind();
            mDecoderServiceHost = null;
        }

        mDialog = null;
    }

    /**
     * Start playback of a video in an overlay above the photo picker.
     *
     * @param uri The uri of the video to start playing.
     */
    public void startVideoPlaybackAsync(Uri uri) {
        if (mDialog == null) return;
        mVideoPlayer.startVideoPlaybackAsync(uri, mDialog.getWindow());
    }

    /**
     * Ends video playback (if a video is playing) and closes the video player. Aborts if the video
     * playback container is not showing.
     *
     * @return true if a video container was showing, false otherwise.
     */
    public boolean closeVideoPlayer() {
        return mVideoPlayer.closeVideoPlayer();
    }

    /**
     * Initializes the PickerCategoryView object.
     *
     * @param dialog The dialog showing us.
     * @param listener The listener who should be notified of actions.
     * @param mimeTypes A list of mime types to show in the dialog.
     */
    public void initialize(
            PhotoPickerDialog dialog, PhotoPickerListener listener, List<String> mimeTypes) {
        mDialog = dialog;
        mListener = listener;
        mMimeTypes = new ArrayList<>(mimeTypes);

        enumerateBitmaps();

        mDialog.setOnCancelListener(
                new DialogInterface.OnCancelListener() {
                    @Override
                    public void onCancel(DialogInterface dialog) {
                        executeAction(
                                PhotoPickerListener.PhotoPickerAction.CANCEL, null, ACTION_CANCEL);
                    }
                });
    }

    // FileEnumWorkerTask.FilesEnumeratedCallback:

    @Override
    public void filesEnumeratedCallback(List<PickerBitmap> files) {
        if (files == null) {
            return;
        }

        // Calculate the rate of files enumerated per tenth of a second.
        long elapsedTimeMs = SystemClock.elapsedRealtime() - mEnumStartTime;
        int rate = (int) (100 * files.size() / elapsedTimeMs);
        RecordHistogram.recordTimesHistogram("Android.PhotoPicker.EnumerationTime", elapsedTimeMs);
        RecordHistogram.recordCustomCountHistogram(
                "Android.PhotoPicker.EnumeratedFiles", files.size(), 1, 10000, 50);
        RecordHistogram.recordCount1000Histogram("Android.PhotoPicker.EnumeratedRate", rate);

        mPickerBitmaps = files;
        processBitmaps();
    }

    // DecoderServiceHost.DecoderStatusCallback:

    @Override
    public void serviceReady() {
        mServiceReady = true;
        processBitmaps();
    }

    @Override
    public void decoderIdle() {}

    // RecyclerView.RecyclerListener:

    @Override
    public void onViewRecycled(RecyclerView.ViewHolder holder) {
        PickerBitmapViewHolder bitmapHolder = (PickerBitmapViewHolder) holder;
        String filePath = bitmapHolder.getFilePath();
        if (filePath != null) {
            getDecoderServiceHost().cancelDecodeImage(filePath);
        }
    }

    // SelectionDelegate.SelectionObserver:

    @Override
    public void onSelectionStateChange(List<PickerBitmap> selectedItems) {
        if (mZoom.getVisibility() != View.VISIBLE) {
            mZoom.setVisibility(View.VISIBLE);
            mZoom.setOnClickListener(this);
        }
    }

    // OnClickListener:

    @Override
    public void onClick(View view) {
        int id = view.getId();
        if (id == R.id.done) {
            notifyPhotosSelected();
        } else if (id == R.id.zoom) {
            if (!mZoomSwitchingInEffect) {
                flipZoomMode();
            }
        } else {
            executeAction(PhotoPickerListener.PhotoPickerAction.CANCEL, null, ACTION_CANCEL);
        }
    }

    /** Start loading of bitmaps, once files have been enumerated and service is ready to decode. */
    private void processBitmaps() {
        if (mServiceReady && mPickerBitmaps != null) {
            mPickerAdapter.notifyDataSetChanged();
        }
    }

    private void flipZoomMode() {
        // Bitmap scaling is cumulative, so if an image is selected when we switch modes, it will
        // become skewed when switching between full size and square modes because dimensions of the
        // picture also change (from square to full width). We therefore un-select all items before
        // starting the animation and then reselect them once animation has ended.
        final HashSet<PickerBitmap> selectedItems =
                new HashSet<>(mSelectionDelegate.getSelectedItems());
        mSelectionDelegate.clearSelection();

        mMagnifyingMode = !mMagnifyingMode;

        Context context = mWindowAndroid.getContext().get();
        if (mMagnifyingMode) {
            mZoom.setImageResource(R.drawable.zoom_out);
            mZoom.setContentDescription(
                    context.getString(R.string.photo_picker_accessibility_zoom_out));
        } else {
            mZoom.setImageResource(R.drawable.zoom_in);
            mZoom.setContentDescription(
                    context.getString(R.string.photo_picker_accessibility_zoom_in));
        }

        calculateGridMetrics();

        if (!mMagnifyingMode) {
            getFullScreenBitmaps().evictAll();
        }

        mZoomSwitchingInEffect = true;

        ChangeBounds transition = new ChangeBounds();
        transition.addListener(
                new Transition.TransitionListener() {
                    @Override
                    public void onTransitionStart(Transition transition) {}

                    @Override
                    public void onTransitionEnd(Transition transition) {
                        mZoomSwitchingInEffect = false;

                        // Redo selection when switching between modes to make it obvious what got
                        // selected.
                        mSelectionDelegate.setSelectedItems(selectedItems);
                    }

                    @Override
                    public void onTransitionCancel(Transition transition) {}

                    @Override
                    public void onTransitionPause(Transition transition) {}

                    @Override
                    public void onTransitionResume(Transition transition) {}
                });

        TransitionManager.beginDelayedTransition(mRecyclerView, transition);

        mLayoutManager.setSpanCount(mColumns);
        mPickerAdapter.notifyDataSetChanged();
        ViewUtils.requestLayout(mRecyclerView, "PickerCategoryView.flipZoomMode");
    }

    // Simple accessors:

    public int getImageWidth() {
        return mImageWidth;
    }

    public int getSpecialTileHeight() {
        return mSpecialTileHeight;
    }

    public boolean isInMagnifyingMode() {
        return mMagnifyingMode;
    }

    public boolean isZoomSwitchingInEffect() {
        return mZoomSwitchingInEffect;
    }

    public SelectionDelegate<PickerBitmap> getSelectionDelegate() {
        return mSelectionDelegate;
    }

    public List<PickerBitmap> getPickerBitmaps() {
        return mPickerBitmaps;
    }

    public DecoderServiceHost getDecoderServiceHost() {
        return mDecoderServiceHost;
    }

    public LruCache<String, Thumbnail> getLowResThumbnails() {
        if (mLowResThumbnails == null || mLowResThumbnails.get() == null) {
            mLowResThumbnails =
                    GlobalDiscardableReferencePool.getReferencePool()
                            .put(new LruCache<String, Thumbnail>(mCacheSizeSmall));
        }
        return mLowResThumbnails.get();
    }

    public LruCache<String, Thumbnail> getHighResThumbnails() {
        if (mHighResThumbnails == null || mHighResThumbnails.get() == null) {
            mHighResThumbnails =
                    GlobalDiscardableReferencePool.getReferencePool()
                            .put(new LruCache<String, Thumbnail>(mCacheSizeLarge));
        }
        return mHighResThumbnails.get();
    }

    public LruCache<String, Thumbnail> getFullScreenBitmaps() {
        if (mFullScreenBitmaps == null || mFullScreenBitmaps.get() == null) {
            mFullScreenBitmaps =
                    GlobalDiscardableReferencePool.getReferencePool()
                            .put(new LruCache<String, Thumbnail>(mCacheSizeFullScreen));
        }
        return mFullScreenBitmaps.get();
    }

    public boolean isMultiSelectAllowed() {
        return mMultiSelectionAllowed;
    }

    /** Notifies the listener that the user selected to launch the gallery. */
    public void showGallery() {
        executeAction(PhotoPickerListener.PhotoPickerAction.LAUNCH_GALLERY, null, ACTION_BROWSE);
    }

    /** Notifies the listener that the user selected to launch the camera intent. */
    public void showCamera() {
        executeAction(PhotoPickerListener.PhotoPickerAction.LAUNCH_CAMERA, null, ACTION_NEW_PHOTO);
    }

    /** Calculates image size and how many columns can fit on-screen. */
    private void calculateGridMetrics() {
        DisplayMetrics displayMetrics = new DisplayMetrics();
        Context context = mWindowAndroid.getContext().get();
        WindowManager windowManager =
                (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
        windowManager.getDefaultDisplay().getMetrics(displayMetrics);

        int width = displayMetrics.widthPixels;
        int minSize =
                context.getResources().getDimensionPixelSize(R.dimen.photo_picker_tile_min_size);
        mPadding =
                mMagnifyingMode
                        ? 0
                        : context.getResources()
                                .getDimensionPixelSize(R.dimen.photo_picker_tile_gap);
        mColumns = mMagnifyingMode ? 1 : Math.max(1, (width - mPadding) / (minSize + mPadding));
        mImageWidth = (width - mPadding * (mColumns + 1)) / (mColumns);
        mImageHeight =
                mMagnifyingMode
                        ? displayMetrics.heightPixels - findViewById(R.id.action_bar_bg).getHeight()
                        : mImageWidth;
        if (!mMagnifyingMode) mSpecialTileHeight = mImageWidth;

        // Make sure columns and padding are either both even or both odd.
        if (!mMagnifyingMode && ((mColumns % 2) == 0) != ((mPadding % 2) == 0)) {
            mPadding++;
        }
    }

    /** Asynchronously enumerates bitmaps on disk. */
    private void enumerateBitmaps() {
        if (sTestFiles != null) {
            filesEnumeratedCallback(sTestFiles);
            return;
        }

        if (mWorkerTask != null) {
            mWorkerTask.cancel(true);
        }

        mEnumStartTime = SystemClock.elapsedRealtime();
        mWorkerTask =
                new FileEnumWorkerTask(
                        mWindowAndroid,
                        this,
                        new MimeTypeFilter(mMimeTypes, true),
                        mMimeTypes,
                        mContentResolver);
        mWorkerTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
    }

    /** Notifies any listeners that one or more photos have been selected. */
    private void notifyPhotosSelected() {
        List<PickerBitmap> selectedFiles = mSelectionDelegate.getSelectedItemsAsList();
        Collections.sort(selectedFiles);
        Uri[] photos = new Uri[selectedFiles.size()];
        int i = 0;
        for (PickerBitmap bitmap : selectedFiles) {
            photos[i++] = bitmap.getUri();
        }

        executeAction(
                PhotoPickerListener.PhotoPickerAction.PHOTOS_SELECTED, photos, ACTION_PHOTO_PICKED);
    }

    /** A class for implementing grid spacing between items. */
    private class GridSpacingItemDecoration extends RecyclerView.ItemDecoration {
        // The number of spans to account for.
        private int mSpanCount;

        // The amount of spacing to use.
        private int mSpacing;

        public GridSpacingItemDecoration(int spanCount, int spacing) {
            mSpanCount = spanCount;
            mSpacing = spacing;
        }

        @Override
        public void getItemOffsets(
                Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
            if (mMagnifyingMode) {
                outRect.set(0, 0, 0, mSpacing);
                return;
            }

            int left = 0;
            int right = 0;
            int top = 0;
            int bottom = 0;
            int position = parent.getChildAdapterPosition(view);

            if (position >= 0) {
                int column = position % mSpanCount;

                left = mSpacing - ((column * mSpacing) / mSpanCount);
                right = (column + 1) * mSpacing / mSpanCount;

                if (position < mSpanCount) {
                    top = mSpacing;
                }
                bottom = mSpacing;
            }

            outRect.set(left, top, right, bottom);
        }
    }

    /**
     * Report back what the user selected in the dialog, report UMA and clean up.
     *
     * @param action The action taken.
     * @param photos The photos that were selected (if any).
     * @param umaId The UMA value to record with the action.
     */
    private void executeAction(
            @PhotoPickerListener.PhotoPickerAction int action, Uri[] photos, int umaId) {
        mListener.onPhotoPickerUserAction(action, photos);
        if (mDialog != null) mDialog.dismiss();
        recordFinalUmaStats(umaId);
    }

    /**
     * Record UMA statistics (what action was taken in the dialog and other performance stats).
     *
     * @param action The action the user took in the dialog.
     */
    private void recordFinalUmaStats(int action) {
        RecordHistogram.recordEnumeratedHistogram(
                "Android.PhotoPicker.DialogAction", action, ACTION_BOUNDARY);
        RecordHistogram.recordCount1MHistogram(
                "Android.PhotoPicker.DecodeRequests", mPickerAdapter.getDecodeRequestCount());
        RecordHistogram.recordCount1MHistogram(
                "Android.PhotoPicker.CacheHits", mPickerAdapter.getCacheHitCount());
    }

    /** Sets a list of files to use as data for the dialog. For testing use only. */
    @VisibleForTesting
    public static void setTestFiles(List<PickerBitmap> testFiles) {
        sTestFiles = new ArrayList<>(testFiles);
    }

    public SelectionDelegate<PickerBitmap> getSelectionDelegateForTesting() {
        return mSelectionDelegate;
    }

    public PickerVideoPlayer getVideoPlayerForTesting() {
        return mVideoPlayer;
    }
}