DisplayCompat.java

/*
 * Copyright 2019 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.core.view;

import static android.content.Context.UI_MODE_SERVICE;

import android.annotation.SuppressLint;
import android.app.UiModeManager;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Point;
import android.os.Build;
import android.text.TextUtils;
import android.view.Display;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.util.Preconditions;

import java.lang.reflect.Method;
import java.util.ArrayList;

/**
 * A class for retrieving the physical display size from a device. This is necessary because
 * Display.Mode.getPhysicalDisplaySize might not report the real physical display size
 * because most ATV devices don't report all available modes correctly. In this case there is no
 * way to find out whether a device is capable to display 4k content. This class offers a
 * workaround for this problem.
 */
public final class DisplayCompat {
    private static final int DISPLAY_SIZE_4K_WIDTH = 3840;
    private static final int DISPLAY_SIZE_4K_HEIGHT = 2160;

    private DisplayCompat() {
        // This class is non-instantiable.
    }

    /**
     * Gets the supported modes of the given display where at least one of the modes is flagged
     * as isNative(). Note that a native mode might not wrap any Display.Mode object in case
     * the display returns no mode with the physical display size.
     *
     * @return an array of supported modes where at least one of the modes is native which
     * contains the physical display size
     */
    @NonNull
    @SuppressLint("ArrayReturn")
    public static ModeCompat[] getSupportedModes(@NonNull Context context,
            @NonNull Display display) {
        Point physicalDisplaySize = getPhysicalDisplaySize(context, display);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            // Display.Mode class and display.getSupportedModes() exist
            Display.Mode[] supportedModes = display.getSupportedModes();
            ArrayList<ModeCompat> supportedModesCompat = new ArrayList<>(supportedModes.length);
            boolean nativeModeExists = false;
            for (int i = 0; i < supportedModes.length; i++) {
                if (physicalSizeEquals(supportedModes[i], physicalDisplaySize)) {
                    // Current mode has native resolution, flag it accordingly
                    supportedModesCompat.add(i,
                            new ModeCompat(supportedModes[i], true));
                    nativeModeExists = true;
                } else {
                    supportedModesCompat.add(i,
                            new ModeCompat(supportedModes[i], false));
                }
            }
            if (!nativeModeExists) {
                // If no mode with physicalDisplaySize dimension exists, add the mode with the
                // native display resolution
                supportedModesCompat.add(new ModeCompat(physicalDisplaySize));
            }
            return supportedModesCompat.toArray(new ModeCompat[0]);
        } else {
            // previous to Android M Display.Mode and Display.getSupportedModes() did not exist,
            // hence the only supported mode is the native display resolution
            return new ModeCompat[] { new ModeCompat(physicalDisplaySize) };
        }
    }

    /**
     * Parses a string which represents the display-size which contains 'x' as a delimiter
     * between two integers representing the display's width and height and returns the
     * display size as a Point object.
     *
     * @param displaySize a string
     * @return a Point object containing the size in x and y direction in pixels
     * @throws NumberFormatException in case the integers cannot be parsed
     */
    private static Point parseDisplaySize(@NonNull String displaySize)
            throws NumberFormatException {
        String[] displaySizeParts = displaySize.trim().split("x", -1);
        if (displaySizeParts.length == 2) {
            int width = Integer.parseInt(displaySizeParts[0]);
            int height = Integer.parseInt(displaySizeParts[1]);
            if (width > 0 && height > 0) {
                return new Point(width, height);
            }
        }
        throw new NumberFormatException();
    }

    /**
     * Reads a system property and returns its string value.
     *
     * @param name the name of the system property
     * @return the result string or null if an exception occurred
     */
    @Nullable
    private static String getSystemProperty(String name) {
        try {
            @SuppressLint("PrivateApi")
            Class<?> systemProperties = Class.forName("android.os.SystemProperties");
            Method getMethod = systemProperties.getMethod("get", String.class);
            return (String) getMethod.invoke(systemProperties, name);
        } catch (Exception e) {
            return null;
        }
    }

    /**
     * Returns true if mode.getPhysicalWidth and mode.getPhysicalHeight are equal to the given size
     *
     * @param mode a Display.Mode object
     * @param size a Point object representing the size in horizontal and vertical direction
     */
    @RequiresApi(Build.VERSION_CODES.M)
    private static boolean physicalSizeEquals(Display.Mode mode, Point size) {
        return (mode.getPhysicalWidth() == size.x && mode.getPhysicalHeight() == size.y)
                || (mode.getPhysicalWidth() == size.y && mode.getPhysicalHeight() == size.x);
    }

    /**
     * Returns whether the app is running on a TV device
     *
     * @return true iff the app is running on a TV device
     */
    private static boolean isTv(@NonNull Context context) {
        // See https://developer.android.com/training/tv/start/hardware.html#runtime-check.
        UiModeManager uiModeManager = (UiModeManager) context.getSystemService(UI_MODE_SERVICE);
        return uiModeManager != null
                && uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
    }

    /**
     * Helper function to determine the physical display size from the system properties only. On
     * Android TVs it is common for the UI to be configured for a lower resolution than SurfaceViews
     * can output. Before API 26 the Display object does not provide a way to identify this case,
     * and up to and including API 28 many devices still do not correctly set their hardware
     * composer output size.
     *
     * @return the physical display size, in pixels or null if the information is not available
     */
    @Nullable
    private static Point parsePhysicalDisplaySizeFromSystemProperties(@NonNull String property,
            @NonNull Display display) {
        if (display.getDisplayId() == Display.DEFAULT_DISPLAY) {
            // Check the system property for display size. From API 28 treble may prevent the
            // system from writing sys.display-size so we check vendor.display-size instead.
            String displaySize = getSystemProperty(property);
            // If we managed to read the display size, attempt to parse it.
            if (!TextUtils.isEmpty(displaySize)) {
                try {
                    return parseDisplaySize(displaySize);
                } catch (NumberFormatException e) {
                    // Do nothing for now, null is returned in the end
                }
            }
        }
        // Unable to determine display size from system properties
        return null;
    }

    /**
     * Gets the physical size of the given display in pixels. The size is collected in the
     * following order:
     * 1) sys.display-size if API < 28 (P) and the system-property is set
     * 2) vendor.display-size if API >= 28 (P) and the system-property is set
     * 3) physical width and height from display.getMode() for API >= 23
     * 4) display.getRealSize() for API >= 17
     * 5) display.getSize()
     *
     * @return the physical display size, in pixels
     */
    private static Point getPhysicalDisplaySize(@NonNull Context context,
            @NonNull Display display) {
        Point displaySize = Build.VERSION.SDK_INT < Build.VERSION_CODES.P
                ? parsePhysicalDisplaySizeFromSystemProperties("sys.display-size", display)
                : parsePhysicalDisplaySizeFromSystemProperties("vendor.display-size", display);
        if (displaySize != null) {
            return displaySize;
        } else if (isSonyBravia4kTv(context)) {
            // Sony Android TVs advertise support for 4k output via a system feature.
            return new Point(DISPLAY_SIZE_4K_WIDTH, DISPLAY_SIZE_4K_HEIGHT);
        } else {
            // Unable to retrieve the physical display size from system properties, get display
            // size from the framework API. Note that this might not be the actual physical
            // display size but the, possibly down-scaled, UI size.
            displaySize = new Point();
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                Display.Mode mode = display.getMode();
                displaySize.x = mode.getPhysicalWidth();
                displaySize.y = mode.getPhysicalHeight();
            } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
                display.getRealSize(displaySize);
            } else {
                display.getSize(displaySize);
            }
        }
        return displaySize;
    }

    /**
     * Determines whether the connected display is a 4k capable Sony TV.
     *
     * @return true if the display is a Sony BRAVIA TV that supports 4k
     */
    private static boolean isSonyBravia4kTv(@NonNull Context context) {
        return isTv(context)
                && "Sony".equals(Build.MANUFACTURER)
                && Build.MODEL.startsWith("BRAVIA")
                && context.getPackageManager().hasSystemFeature(
                "com.sony.dtv.hardware.panel.qfhd");
    }

    /**
     * Compat class which provides an additional isNative() field. This field indicates whether a
     * mode is native, which is important when searching for the highest possible native
     * resolution of a display.
     */
    public static final class ModeCompat {

        private final Display.Mode mMode;
        private final Point mPhysicalDisplaySize;
        private final boolean mIsNative;

        /**
         * Package private constructor which creates a native ModeCompat object that does not
         * wrap any Display.Mode object but only contains the given display size
         *
         * @param physicalDisplaySize a Point object representing the display size in pixels
         *                            (Point.x horizontal and Point.y vertical size)
         */
        ModeCompat(@NonNull Point physicalDisplaySize) {
            Preconditions.checkNotNull(physicalDisplaySize, "physicalDisplaySize == null");
            mIsNative = true;
            mPhysicalDisplaySize = physicalDisplaySize;
            mMode = null;
        }

        /**
         * Package private constructor which creates a non-native ModeCompat and wraps the given
         * Mode object
         *
         * @param mode a Display.Mode object
         */
        @RequiresApi(Build.VERSION_CODES.M)
        ModeCompat(@NonNull Display.Mode mode, boolean isNative) {
            Preconditions.checkNotNull(mode, "Display.Mode == null, can't wrap a null reference");
            mIsNative = isNative;
            // This simplifies the getPhysicalWidth() / getPhysicalHeight functions below
            mPhysicalDisplaySize = new Point(mode.getPhysicalWidth(), mode.getPhysicalHeight());
            mMode = mode;
        }

        /**
         * Returns the physical width of the given display when configured in this mode
         *
         * @return the physical screen width in pixels
         */
        public int getPhysicalWidth() {
            return mPhysicalDisplaySize.x;
        }

        /**
         * Returns the physical height of the given display when configured in this mode
         *
         * @return the physical screen height in pixels
         */
        public int getPhysicalHeight() {
            return mPhysicalDisplaySize.y;
        }

        /**
         * Function to get the wrapped object
         *
         * @return the wrapped Display.Mode object or null if there was no matching mode for the
         * native resolution.
         */
        @RequiresApi(Build.VERSION_CODES.M)
        @Nullable
        public Display.Mode toMode() {
            return mMode;
        }

        /**
         * This field indicates whether a mode is native, which is important when searching for
         * the highest possible native resolution of a display.
         *
         * @return true if this is a native mode of the wrapped display
         */
        public boolean isNative() {
            return mIsNative;
        }
    }
}