chromium/ui/android/java/src/org/chromium/ui/UiUtils.java

// Copyright 2013 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.ui;

import android.content.Context;
import android.content.res.ColorStateList;
import android.content.res.Configuration;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Environment;
import android.os.StrictMode;
import android.text.TextUtils;
import android.view.SurfaceView;
import android.view.View;
import android.view.View.MeasureSpec;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import android.view.inputmethod.InputMethodInfo;
import android.view.inputmethod.InputMethodManager;
import android.view.inputmethod.InputMethodSubtype;
import android.widget.AbsListView;
import android.widget.ListAdapter;

import androidx.annotation.ColorInt;
import androidx.annotation.ColorRes;
import androidx.annotation.DimenRes;
import androidx.annotation.DrawableRes;
import androidx.annotation.Nullable;
import androidx.annotation.StyleableRes;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.graphics.Insets;
import androidx.core.graphics.drawable.DrawableCompat;
import androidx.core.view.WindowInsetsCompat;

import org.chromium.base.BuildInfo;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;

import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

/**
 * Utility functions for common Android UI tasks.
 * This class is not supposed to be instantiated.
 */
public class UiUtils {
    private static final String TAG = "UiUtils";

    public static final String EXTERNAL_IMAGE_FILE_PATH = "browser-images";
    // Keep this variable in sync with the value defined in file_paths.xml.
    public static final String IMAGE_FILE_PATH = "images";

    // crbug.com/1413586: Prevent potentially unintentional user interaction with any prompt for
    // this long after the prompt is displayed.
    public static long PROMPT_INPUT_PROTECTION_SHORT_DELAY_MS = 600;

    /**
     * A static map of manufacturers to the version where theming Android UI is completely
     * supported. If there is no entry, it means the manufacturer supports theming at the same
     * version Android did.
     */
    private static final Map<String, Integer> sAndroidUiThemeBlocklist = new HashMap<>();

    static {
        // HTC doesn't respect theming flags on activity restart until Android O; this affects both
        // the system nav and status bar. More info at https://crbug.com/831737.
        sAndroidUiThemeBlocklist.put("htc", Build.VERSION_CODES.O);
    }

    /** Whether theming the Android system UI has been disabled. */
    private static Boolean sSystemUiThemingDisabled;

    /** Guards this class from being instantiated. */
    private UiUtils() {}

    /**
     * Gets the set of locales supported by the current enabled Input Methods.
     * @param context A {@link Context} instance.
     * @return A possibly-empty {@link Set} of locale strings.
     */
    public static Set<String> getIMELocales(Context context) {
        LinkedHashSet<String> locales = new LinkedHashSet<String>();
        InputMethodManager imManager =
                (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
        List<InputMethodInfo> enabledMethods = imManager.getEnabledInputMethodList();
        for (int i = 0; i < enabledMethods.size(); i++) {
            List<InputMethodSubtype> subtypes =
                    imManager.getEnabledInputMethodSubtypeList(enabledMethods.get(i), true);
            if (subtypes == null) continue;
            for (int j = 0; j < subtypes.size(); j++) {
                String locale = subtypes.get(j).getLanguageTag();
                if (!TextUtils.isEmpty(locale)) locales.add(locale);
            }
        }
        return locales;
    }

    /**
     * Inserts a {@link View} into a {@link ViewGroup} after directly before a given {@View}.
     * @param container The {@link View} to add newView to.
     * @param newView The new {@link View} to add.
     * @param existingView The {@link View} to insert the newView before.
     * @return The index where newView was inserted, or -1 if it was not inserted.
     */
    public static int insertBefore(ViewGroup container, View newView, View existingView) {
        return insertView(container, newView, existingView, false);
    }

    /**
     * Inserts a {@link View} into a {@link ViewGroup} after directly after a given {@View}.
     * @param container The {@link View} to add newView to.
     * @param newView The new {@link View} to add.
     * @param existingView The {@link View} to insert the newView after.
     * @return The index where newView was inserted, or -1 if it was not inserted.
     */
    public static int insertAfter(ViewGroup container, View newView, View existingView) {
        return insertView(container, newView, existingView, true);
    }

    private static int insertView(
            ViewGroup container, View newView, View existingView, boolean after) {
        // See if the view has already been added.
        int index = container.indexOfChild(newView);
        if (index >= 0) return index;

        // Find the location of the existing view.
        index = container.indexOfChild(existingView);
        if (index < 0) return -1;

        // Add the view.
        if (after) index++;
        container.addView(newView, index);
        return index;
    }

    /**
     * Generates a scaled screenshot of the given view.  The maximum size of the screenshot is
     * determined by maximumDimension.
     *
     * @param currentView      The view to generate a screenshot of.
     * @param maximumDimension The maximum width or height of the generated screenshot.  The bitmap
     *                         will be scaled to ensure the maximum width or height is equal to or
     *                         less than this.  Any value <= 0, will result in no scaling.
     * @param bitmapConfig     Bitmap config for the generated screenshot (ARGB_8888 or RGB_565).
     * @return The screen bitmap of the view or null if a problem was encountered.
     */
    public static Bitmap generateScaledScreenshot(
            View currentView, int maximumDimension, Bitmap.Config bitmapConfig) {
        Bitmap screenshot = null;
        boolean drawingCacheEnabled = currentView.isDrawingCacheEnabled();
        try {
            prepareViewHierarchyForScreenshot(currentView, true);
            if (!drawingCacheEnabled) currentView.setDrawingCacheEnabled(true);
            // Android has a maximum drawing cache size and if the drawing cache is bigger
            // than that, getDrawingCache() returns null.
            Bitmap originalBitmap = currentView.getDrawingCache();
            if (originalBitmap != null) {
                double originalHeight = originalBitmap.getHeight();
                double originalWidth = originalBitmap.getWidth();
                int newWidth = (int) originalWidth;
                int newHeight = (int) originalHeight;
                if (maximumDimension > 0) {
                    double scale = maximumDimension / Math.max(originalWidth, originalHeight);
                    newWidth = (int) Math.round(originalWidth * scale);
                    newHeight = (int) Math.round(originalHeight * scale);
                }
                Bitmap scaledScreenshot =
                        Bitmap.createScaledBitmap(originalBitmap, newWidth, newHeight, true);
                if (scaledScreenshot.getConfig() != bitmapConfig) {
                    screenshot = scaledScreenshot.copy(bitmapConfig, false);
                    scaledScreenshot.recycle();
                    scaledScreenshot = null;
                } else {
                    screenshot = scaledScreenshot;
                }
            } else if (currentView.getMeasuredHeight() > 0 && currentView.getMeasuredWidth() > 0) {
                double originalHeight = currentView.getMeasuredHeight();
                double originalWidth = currentView.getMeasuredWidth();
                int newWidth = (int) originalWidth;
                int newHeight = (int) originalHeight;
                if (maximumDimension > 0) {
                    double scale = maximumDimension / Math.max(originalWidth, originalHeight);
                    newWidth = (int) Math.round(originalWidth * scale);
                    newHeight = (int) Math.round(originalHeight * scale);
                }
                Bitmap bitmap = Bitmap.createBitmap(newWidth, newHeight, bitmapConfig);
                Canvas canvas = new Canvas(bitmap);
                canvas.scale(
                        (float) (newWidth / originalWidth), (float) (newHeight / originalHeight));
                currentView.draw(canvas);
                screenshot = bitmap;
            }
        } catch (OutOfMemoryError e) {
            Log.d(TAG, "Unable to capture screenshot and scale it down." + e.getMessage());
        } finally {
            if (!drawingCacheEnabled) currentView.setDrawingCacheEnabled(false);
            prepareViewHierarchyForScreenshot(currentView, false);
        }
        return screenshot;
    }

    private static void prepareViewHierarchyForScreenshot(View view, boolean takingScreenshot) {
        if (view instanceof ViewGroup) {
            ViewGroup viewGroup = (ViewGroup) view;
            for (int i = 0; i < viewGroup.getChildCount(); i++) {
                prepareViewHierarchyForScreenshot(viewGroup.getChildAt(i), takingScreenshot);
            }
        } else if (view instanceof SurfaceView) {
            view.setWillNotDraw(!takingScreenshot);
        }
    }

    /**
     * Get a directory for the image capture operation. For devices with JB MR2
     * or latter android versions, the directory is IMAGE_FILE_PATH directory.
     * For ICS devices, the directory is CAPTURE_IMAGE_DIRECTORY.
     *
     * @param context The application context.
     * @return directory for the captured image to be stored.
     */
    public static File getDirectoryForImageCapture(Context context) throws IOException {
        // Temporarily allowing disk access while fixing. TODO: http://crbug.com/562173
        StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads();
        try {
            File path;
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
                path = new File(context.getFilesDir(), IMAGE_FILE_PATH);
                if (!path.exists() && !path.mkdir()) {
                    throw new IOException("Folder cannot be created.");
                }
            } else {
                File externalDataDir =
                        Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM);
                path =
                        new File(
                                externalDataDir.getAbsolutePath()
                                        + File.separator
                                        + EXTERNAL_IMAGE_FILE_PATH);
                if (!path.exists() && !path.mkdirs()) {
                    path = externalDataDir;
                }
            }
            return path;
        } finally {
            StrictMode.setThreadPolicy(oldPolicy);
        }
    }

    /**
     * Removes the view from its parent {@link ViewGroup}. No-op if the {@link View} is not yet
     * attached to the view hierarchy.
     *
     * @param view The view to be removed from the parent.
     */
    public static void removeViewFromParent(View view) {
        ViewGroup parent = (ViewGroup) view.getParent();
        if (parent == null) return;
        parent.removeView(view);
    }

    /**
     * Computes the max width of the widest list item & the total height of all of the items. The
     * height returned in unbounded and may be larger than the available window space.
     *
     * <p>WARNING: do not call this on a ListAdapter with more than a handful of items, the
     * performance will be terrible since it measures every single item.
     *
     * @param adapter The adapter for the list.
     * @param parentView The parent view for the list.
     * @return int array representing the max width of the menu items stored at index 0 & the total
     *     height of all items stored at index 1.
     */
    public static int[] computeListAdapterContentDimensions(
            ListAdapter adapter, ViewGroup parentView) {
        final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
        final int heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
        AbsListView.LayoutParams params =
                new AbsListView.LayoutParams(
                        AbsListView.LayoutParams.WRAP_CONTENT,
                        AbsListView.LayoutParams.WRAP_CONTENT);

        int[] result = new int[] {0, 0};
        View[] itemViews = new View[adapter.getViewTypeCount()];
        for (int i = 0; i < adapter.getCount(); ++i) {
            View itemView;
            int type = adapter.getItemViewType(i);
            if (type < 0) {
                // Type is negative for header/footer views, or views the adapter does not want
                // recycled.
                itemView = adapter.getView(i, null, parentView);
            } else {
                itemViews[type] = adapter.getView(i, itemViews[type], parentView);
                itemView = itemViews[type];
            }

            itemView.setLayoutParams(params);
            itemView.measure(widthMeasureSpec, heightMeasureSpec);
            result[0] = Math.max(result[0], itemView.getMeasuredWidth());
            result[1] += itemView.getMeasuredHeight();
        }

        return result;
    }

    /**
     * Get the index of a child {@link View} in a {@link ViewGroup}.
     * @param child The child to find the index of.
     * @return The index of the child in its parent. -1 if the child has no parent.
     */
    public static int getChildIndexInParent(View child) {
        if (child.getParent() == null) return -1;
        ViewGroup parent = (ViewGroup) child.getParent();
        int indexInParent = -1;
        for (int i = 0; i < parent.getChildCount(); i++) {
            if (parent.getChildAt(i) == child) {
                indexInParent = i;
                break;
            }
        }
        return indexInParent;
    }

    /**
     * Loads a {@link Drawable} from an attribute.  Uses {@link AppCompatResources} to support all
     * modern {@link Drawable} types.
     * @param context The associated context.
     * @param attrs The attributes from which to load the drawable resource.
     * @param attrId The attribute id that holds the drawable resource.
     * @return A new {@link Drawable} or {@code null} if the attribute wasn't set.
     */
    public static @Nullable Drawable getDrawable(
            Context context, @Nullable TypedArray attrs, @StyleableRes int attrId) {
        if (attrs == null) return null;

        @DrawableRes int resId = attrs.getResourceId(attrId, -1);
        if (resId == -1) return null;
        return AppCompatResources.getDrawable(context, resId);
    }

    /**
     * Gets a drawable from the resources and applies the specified tint to it. Uses Support Library
     * for vector drawables and tinting on older Android versions.
     * @param drawableId The resource id for the drawable.
     * @param tintColorId The resource id for the color to build ColorStateList with.
     */
    public static Drawable getTintedDrawable(
            Context context, @DrawableRes int drawableId, @ColorRes int tintColorId) {
        return getTintedDrawable(
                context, drawableId, AppCompatResources.getColorStateList(context, tintColorId));
    }

    /**
     * Gets a drawable from the resources and applies the specified tint to it. Uses Support Library
     * for vector drawables and tinting on older Android versions.
     * @param drawableId The resource id for the drawable.
     * @param colorStateList The color state list to apply to the drawable.
     */
    public static Drawable getTintedDrawable(
            Context context, @DrawableRes int drawableId, ColorStateList list) {
        Drawable drawable = AppCompatResources.getDrawable(context, drawableId);
        assert drawable != null;
        drawable = DrawableCompat.wrap(drawable).mutate();
        DrawableCompat.setTintList(drawable, list);
        return drawable;
    }

    /**
     * @return Whether the support for theming on a particular device has been completely disabled
     *         due to lack of support by the OEM.
     */
    public static boolean isSystemUiThemingDisabled() {
        if (sSystemUiThemingDisabled == null) {
            sSystemUiThemingDisabled = false;
            if (sAndroidUiThemeBlocklist.containsKey(Build.MANUFACTURER.toLowerCase(Locale.US))) {
                sSystemUiThemingDisabled =
                        Build.VERSION.SDK_INT
                                < sAndroidUiThemeBlocklist.get(
                                        Build.MANUFACTURER.toLowerCase(Locale.US));
            }
        }
        return sSystemUiThemingDisabled;
    }

    /**
     * Sets the navigation bar icons to dark or light. Note that this is only valid for Android
     * O+.
     * @param rootView The root view used to request updates to the system UI theme.
     * @param useDarkIcons Whether the navigation bar icons should be dark.
     */
    public static void setNavigationBarIconColor(View rootView, boolean useDarkIcons) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;

        int systemUiVisibility = rootView.getSystemUiVisibility();
        if (useDarkIcons) {
            systemUiVisibility |= View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR;
        } else {
            systemUiVisibility &= ~View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR;
        }
        rootView.setSystemUiVisibility(systemUiVisibility);
    }

    /**
     * @see android.view.Window#setStatusBarColor(int color).
     */
    public static void setStatusBarColor(Window window, @ColorInt int statusBarColor) {
        if (0
                == (window.getAttributes().flags
                        & WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)) {
            window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
        }
        // The status bar should always be black in automotive devices to match the black back
        // button toolbar.
        if (BuildInfo.getInstance().isAutomotive) {
            window.setStatusBarColor(Color.BLACK);
        } else {
            window.setStatusBarColor(statusBarColor);
        }
    }

    /**
     * Sets the status bar icons to dark or light. Note that this is only valid for
     * Android M+.
     *
     * TODO: migrate to WindowInsetsController API for Android R+ (API 30+)
     *
     * @param rootView The root view used to request updates to the system UI theming.
     * @param useDarkIcons Whether the status bar icons should be dark.
     */
    public static void setStatusBarIconColor(View rootView, boolean useDarkIcons) {
        int systemUiVisibility = rootView.getSystemUiVisibility();
        // The status bar should always be black in automotive devices to match the black back
        // button toolbar, so we should not use dark icons.
        if (useDarkIcons && !BuildInfo.getInstance().isAutomotive) {
            systemUiVisibility |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
        } else {
            systemUiVisibility &= ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
        }
        rootView.setSystemUiVisibility(systemUiVisibility);
    }

    /**
     * @return True if a hardware keyboard is detected.
     */
    public static boolean isHardwareKeyboardAttached() {
        return ContextUtils.getApplicationContext().getResources().getConfiguration().keyboard
                != Configuration.KEYBOARD_NOKEYS;
    }

    /**
     * @param window The application window which includes the decor view.
     * @return True if gesture navigation mode is on.
     */
    public static boolean isGestureNavigationMode(Window window) {
        // https://stackoverflow.com/a/70514883
        WindowInsetsCompat windowInsets =
                WindowInsetsCompat.toWindowInsetsCompat(
                        window.getDecorView().getRootWindowInsets());
        // Use systemGestures rather than tappableElements.
        // In some devices, like Samsung Fold, which has a dock, the bottom inset of
        // tappableElements is non-zero even when gesture mode is on.
        Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemGestures());
        return insets.left > 0;
    }

    /**
     * Draws a badge (a {@code badgeColorResId} colored dot) on icon.
     *
     * @param context The activity context.
     * @param icon The icon to draw the badge on.
     * @param iconColorResId The resource id of the color to color the icon with.
     * @param badgeRadius The size of the badge to be drawn (resource id).
     * @param badgeBorderSize The size of the transparent border that will surround the badge
     *     (resource id).
     * @param badgeColorResId The resource id of the color of the badge to be drawn.
     * @return A new drawable that portrays a badge on the passed icon.
     */
    public static Drawable drawIconWithBadge(
            Context context,
            Drawable icon,
            @ColorRes int iconColorResId,
            @DimenRes int badgeSizeResId,
            @DimenRes int badgeBorderSizeResId,
            @ColorRes int badgeColorResId) {
        if (icon == null || icon.getIntrinsicWidth() <= 0 || icon.getIntrinsicHeight() <= 0) {
            return icon;
        }

        int width = icon.getIntrinsicWidth();
        int height = icon.getIntrinsicHeight();

        // Create new drawable.
        Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);

        Canvas canvas = new Canvas(bitmap);
        icon.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
        icon.draw(canvas);

        // Color the icon.
        canvas.drawColor(context.getColor(iconColorResId), PorterDuff.Mode.SRC_IN);

        int badgeRadius = context.getResources().getDimensionPixelSize(badgeSizeResId) / 2;
        int badgeCenterX = width - badgeRadius;
        int badgeCenterY = height / 2 - badgeRadius;

        // Cut a transparent hole through the background icon. This will serve as a border to
        // the badge being overlaid.
        Paint hole = new Paint();
        hole.setAntiAlias(true);
        hole.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
        canvas.drawCircle(
                badgeCenterX,
                badgeCenterY,
                badgeRadius + context.getResources().getDimensionPixelSize(badgeBorderSizeResId),
                hole);

        // Draw the red badge.
        Paint badge = new Paint();
        hole.setAntiAlias(true);
        badge.setColor(context.getColor(badgeColorResId));
        canvas.drawCircle(badgeCenterX, badgeCenterY, badgeRadius, badge);

        return new BitmapDrawable(context.getResources(), bitmap);
    }
}