/*
 * Copyright (C) 2020 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 android.telephony;

import android.Manifest;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.UserIdInt;
import android.app.ActivityManager;
import android.app.AppOpsManager;
import android.content.Context;
import android.content.pm.PackageManager;
import android.location.LocationManager;
import android.os.Binder;
import android.os.Build;
import android.os.Process;
import android.os.UserHandle;
import android.util.Log;
import android.widget.Toast;

import com.android.internal.telephony.util.TelephonyUtils;

/**
 * Helper for performing location access checks.
 * @hide
 */
public final class LocationAccessPolicy {
    private static final String TAG = "LocationAccessPolicy";
    private static final boolean DBG = false;
    public static final int MAX_SDK_FOR_ANY_ENFORCEMENT = Build.VERSION_CODES.CUR_DEVELOPMENT;

    public enum LocationPermissionResult {
        ALLOWED,
        /**
         * Indicates that the denial is due to a transient device state
         * (e.g. app-ops, location main switch)
         */
        DENIED_SOFT,
        /**
         * Indicates that the denial is due to a misconfigured app (e.g. missing entry in manifest)
         */
        DENIED_HARD,
    }

    /** Data structure for location permission query */
    public static class LocationPermissionQuery {
        public final String callingPackage;
        public final String callingFeatureId;
        public final int callingUid;
        public final int callingPid;
        public final int minSdkVersionForCoarse;
        public final int minSdkVersionForFine;
        public final boolean logAsInfo;
        public final String method;

        private LocationPermissionQuery(String callingPackage, @Nullable String callingFeatureId,
                int callingUid, int callingPid, int minSdkVersionForCoarse,
                int minSdkVersionForFine, boolean logAsInfo, String method) {
            this.callingPackage = callingPackage;
            this.callingFeatureId = callingFeatureId;
            this.callingUid = callingUid;
            this.callingPid = callingPid;
            this.minSdkVersionForCoarse = minSdkVersionForCoarse;
            this.minSdkVersionForFine = minSdkVersionForFine;
            this.logAsInfo = logAsInfo;
            this.method = method;
        }

        /** Builder for LocationPermissionQuery */
        public static class Builder {
            private String mCallingPackage;
            private String mCallingFeatureId;
            private int mCallingUid;
            private int mCallingPid;
            private int mMinSdkVersionForCoarse = -1;
            private int mMinSdkVersionForFine = -1;
            private int mMinSdkVersionForEnforcement = -1;
            private boolean mLogAsInfo = false;
            private String mMethod;

            /**
             * Mandatory parameter, used for performing permission checks.
             */
            public Builder setCallingPackage(String callingPackage) {
                mCallingPackage = callingPackage;
                return this;
            }

            /**
             * Mandatory parameter, used for performing permission checks.
             */
            public Builder setCallingFeatureId(@Nullable String callingFeatureId) {
                mCallingFeatureId = callingFeatureId;
                return this;
            }

            /**
             * Mandatory parameter, used for performing permission checks.
             */
            public Builder setCallingUid(int callingUid) {
                mCallingUid = callingUid;
                return this;
            }

            /**
             * Mandatory parameter, used for performing permission checks.
             */
            public Builder setCallingPid(int callingPid) {
                mCallingPid = callingPid;
                return this;
            }

            /**
             * Apps that target at least this sdk version will be checked for coarse location
             * permission. This method MUST be called before calling {@link #build()}. Otherwise, an
             * {@link IllegalArgumentException} will be thrown.
             *
             * Additionally, if both the argument to this method and
             * {@link #setMinSdkVersionForFine} are greater than {@link Build.VERSION_CODES#BASE},
             * you must call {@link #setMinSdkVersionForEnforcement} with the min of the two to
             * affirm that you do not want any location checks below a certain SDK version.
             * Otherwise, {@link #build} will throw an {@link IllegalArgumentException}.
             */
            public Builder setMinSdkVersionForCoarse(
                    int minSdkVersionForCoarse) {
                mMinSdkVersionForCoarse = minSdkVersionForCoarse;
                return this;
            }

            /**
             * Apps that target at least this sdk version will be checked for fine location
             * permission.  This method MUST be called before calling {@link #build()}.
             * Otherwise, an {@link IllegalArgumentException} will be thrown.
             *
             * Additionally, if both the argument to this method and
             * {@link #setMinSdkVersionForCoarse} are greater than {@link Build.VERSION_CODES#BASE},
             * you must call {@link #setMinSdkVersionForEnforcement} with the min of the two to
             * affirm that you do not want any location checks below a certain SDK version.
             * Otherwise, {@link #build} will throw an {@link IllegalArgumentException}.
             */
            public Builder setMinSdkVersionForFine(
                    int minSdkVersionForFine) {
                mMinSdkVersionForFine = minSdkVersionForFine;
                return this;
            }

            /**
             * If both the argument to {@link #setMinSdkVersionForFine} and
             * {@link #setMinSdkVersionForCoarse} are greater than {@link Build.VERSION_CODES#BASE},
             * this method must be called with the min of the two to
             * affirm that you do not want any location checks below a certain SDK version.
             */
            public Builder setMinSdkVersionForEnforcement(int minSdkVersionForEnforcement) {
                mMinSdkVersionForEnforcement = minSdkVersionForEnforcement;
                return this;
            }

            /**
             * Optional, for logging purposes only.
             */
            public Builder setMethod(String method) {
                mMethod = method;
                return this;
            }

            /**
             * If called with {@code true}, log messages will only be printed at the info level.
             */
            public Builder setLogAsInfo(boolean logAsInfo) {
                mLogAsInfo = logAsInfo;
                return this;
            }

            /** build LocationPermissionQuery */
            public LocationPermissionQuery build() {
                if (mMinSdkVersionForCoarse < 0 || mMinSdkVersionForFine < 0) {
                    throw new IllegalArgumentException("Must specify min sdk versions for"
                            + " enforcement for both coarse and fine permissions");
                }
                if (mMinSdkVersionForFine > Build.VERSION_CODES.BASE
                        && mMinSdkVersionForCoarse > Build.VERSION_CODES.BASE) {
                    if (mMinSdkVersionForEnforcement != Math.min(
                            mMinSdkVersionForCoarse, mMinSdkVersionForFine)) {
                        throw new IllegalArgumentException("setMinSdkVersionForEnforcement must be"
                                + " called.");
                    }
                }

                if (mMinSdkVersionForFine < mMinSdkVersionForCoarse) {
                    throw new IllegalArgumentException("Since fine location permission includes"
                            + " access to coarse location, the min sdk level for enforcement of"
                            + " the fine location permission must not be less than the min sdk"
                            + " level for enforcement of the coarse location permission.");
                }

                return new LocationPermissionQuery(mCallingPackage, mCallingFeatureId,
                        mCallingUid, mCallingPid, mMinSdkVersionForCoarse, mMinSdkVersionForFine,
                        mLogAsInfo, mMethod);
            }
        }
    }

    private static void logError(Context context, LocationPermissionQuery query, String errorMsg) {
        if (query.logAsInfo) {
            Log.i(TAG, errorMsg);
            return;
        }
        Log.e(TAG, errorMsg);
        try {
            if (TelephonyUtils.IS_DEBUGGABLE) {
                Toast.makeText(context, errorMsg, Toast.LENGTH_SHORT).show();
            }
        } catch (Throwable t) {
            // whatever, not important
        }
    }

    private static LocationPermissionResult appOpsModeToPermissionResult(int appOpsMode) {
        switch (appOpsMode) {
            case AppOpsManager.MODE_ALLOWED:
                return LocationPermissionResult.ALLOWED;
            case AppOpsManager.MODE_ERRORED:
                return LocationPermissionResult.DENIED_HARD;
            default:
                return LocationPermissionResult.DENIED_SOFT;
        }
    }

    private static String getAppOpsString(String manifestPermission) {
        switch (manifestPermission) {
            case Manifest.permission.ACCESS_FINE_LOCATION:
                return AppOpsManager.OPSTR_FINE_LOCATION;
            case Manifest.permission.ACCESS_COARSE_LOCATION:
                return AppOpsManager.OPSTR_COARSE_LOCATION;
            default:
                return null;
        }
    }

    private static LocationPermissionResult checkAppLocationPermissionHelper(Context context,
            LocationPermissionQuery query, String permissionToCheck) {
        String locationTypeForLog =
                Manifest.permission.ACCESS_FINE_LOCATION.equals(permissionToCheck)
                        ? "fine" : "coarse";

        // Do the app-ops and the manifest check without any of the allow-overrides first.
        boolean hasManifestPermission = checkManifestPermission(context, query.callingPid,
                query.callingUid, permissionToCheck);

        if (hasManifestPermission) {
            // Only check the app op if the app has the permission.
            int appOpMode = context.getSystemService(AppOpsManager.class)
                    .noteOpNoThrow(getAppOpsString(permissionToCheck), query.callingUid,
                            query.callingPackage, query.callingFeatureId, null);
            if (appOpMode == AppOpsManager.MODE_ALLOWED) {
                // If the app did everything right, return without logging.
                return LocationPermissionResult.ALLOWED;
            } else {
                // If the app has the manifest permission but not the app-op permission, it means
                // that it's aware of the requirement and the user denied permission explicitly.
                // If we see this, don't let any of the overrides happen.
                Log.i(TAG, query.callingPackage + " is aware of " + locationTypeForLog + " but the"
                        + " app-ops permission is specifically denied.");
                return appOpsModeToPermissionResult(appOpMode);
            }
        }

        int minSdkVersion = Manifest.permission.ACCESS_FINE_LOCATION.equals(permissionToCheck)
                ? query.minSdkVersionForFine : query.minSdkVersionForCoarse;

        // If the app fails for some reason, see if it should be allowed to proceed.
        if (minSdkVersion > MAX_SDK_FOR_ANY_ENFORCEMENT) {
            String errorMsg = "Allowing " + query.callingPackage + " " + locationTypeForLog
                    + " because we're not enforcing API " + minSdkVersion + " yet."
                    + " Please fix this app because it will break in the future. Called from "
                    + query.method;
            logError(context, query, errorMsg);
            return null;
        } else if (!isAppAtLeastSdkVersion(context, query.callingPackage, minSdkVersion)) {
            String errorMsg = "Allowing " + query.callingPackage + " " + locationTypeForLog
                    + " because it doesn't target API " + minSdkVersion + " yet."
                    + " Please fix this app. Called from " + query.method;
            logError(context, query, errorMsg);
            return null;
        } else {
            // If we're not allowing it due to the above two conditions, this means that the app
            // did not declare the permission in their manifest.
            return LocationPermissionResult.DENIED_HARD;
        }
    }

    /** Check if location permissions have been granted */
    public static LocationPermissionResult checkLocationPermission(
            Context context, LocationPermissionQuery query) {
        // Always allow the phone process, system server, and network stack to access location.
        // This avoid breaking legacy code that rely on public-facing APIs to access cell location,
        // and it doesn't create an info leak risk because the cell location is stored in the phone
        // process anyway, and the system server already has location access.
        if (query.callingUid == Process.PHONE_UID || query.callingUid == Process.SYSTEM_UID
                || query.callingUid == Process.NETWORK_STACK_UID
                || query.callingUid == Process.ROOT_UID) {
            return LocationPermissionResult.ALLOWED;
        }

        // Check the system-wide requirements. If the location main switch is off and the caller is
        // not in the allowlist of apps that always have loation access or the app's profile
        // isn't in the foreground, return a soft denial.
        if (!checkSystemLocationAccess(context, query.callingUid, query.callingPid,
                query.callingPackage)) {
            return LocationPermissionResult.DENIED_SOFT;
        }

        // Do the check for fine, then for coarse.
        if (query.minSdkVersionForFine < Integer.MAX_VALUE) {
            LocationPermissionResult resultForFine = checkAppLocationPermissionHelper(
                    context, query, Manifest.permission.ACCESS_FINE_LOCATION);
            if (resultForFine != null) {
                return resultForFine;
            }
        }

        if (query.minSdkVersionForCoarse < Integer.MAX_VALUE) {
            LocationPermissionResult resultForCoarse = checkAppLocationPermissionHelper(
                    context, query, Manifest.permission.ACCESS_COARSE_LOCATION);
            if (resultForCoarse != null) {
                return resultForCoarse;
            }
        }

        // At this point, we're out of location checks to do. If the app bypassed all the previous
        // ones due to the SDK backwards compatibility schemes, allow it access.
        return LocationPermissionResult.ALLOWED;
    }

    private static boolean checkManifestPermission(Context context, int pid, int uid,
            String permissionToCheck) {
        return context.checkPermission(permissionToCheck, pid, uid)
                == PackageManager.PERMISSION_GRANTED;
    }

    private static boolean checkSystemLocationAccess(@NonNull Context context, int uid, int pid,
            @NonNull String callingPackage) {
        if (!isLocationModeEnabled(context, UserHandle.getUserHandleForUid(uid).getIdentifier())
                && !isLocationBypassAllowed(context, callingPackage)) {
            if (DBG) Log.w(TAG, "Location disabled, failed, (" + uid + ")");
            return false;
        }
        // If the user or profile is current, permission is granted.
        // Otherwise, uid must have INTERACT_ACROSS_USERS_FULL permission.
        return isCurrentProfile(context, uid) || checkInteractAcrossUsersFull(context, pid, uid);
    }

    /**
     * @return Whether location is enabled for the given user.
     */
    public static boolean isLocationModeEnabled(@NonNull Context context, @UserIdInt int userId) {
        LocationManager locationManager = context.getSystemService(LocationManager.class);
        if (locationManager == null) {
            Log.w(TAG, "Couldn't get location manager, denying location access");
            return false;
        }
        return locationManager.isLocationEnabledForUser(UserHandle.of(userId));
    }

    private static boolean isLocationBypassAllowed(@NonNull Context context,
            @NonNull String callingPackage) {
        for (String bypassPackage : getLocationBypassPackages(context)) {
            if (callingPackage.equals(bypassPackage)) {
                return true;
            }
        }
        return false;
    }

    /**
     * @return An array of packages that are always allowed to access location.
     */
    public static @NonNull String[] getLocationBypassPackages(@NonNull Context context) {
        return context.getResources().getStringArray(
                com.android.internal.R.array.config_serviceStateLocationAllowedPackages);
    }

    private static boolean checkInteractAcrossUsersFull(
            @NonNull Context context, int pid, int uid) {
        return checkManifestPermission(context, pid, uid,
                Manifest.permission.INTERACT_ACROSS_USERS_FULL);
    }

    private static boolean isCurrentProfile(@NonNull Context context, int uid) {
        final long token = Binder.clearCallingIdentity();
        try {
            if (UserHandle.getUserHandleForUid(uid).getIdentifier()
                    == ActivityManager.getCurrentUser()) {
                return true;
            }
            ActivityManager activityManager = context.getSystemService(ActivityManager.class);
            if (activityManager != null) {
                return activityManager.isProfileForeground(
                        UserHandle.getUserHandleForUid(ActivityManager.getCurrentUser()));
            } else {
                return false;
            }
        } finally {
            Binder.restoreCallingIdentity(token);
        }
    }

    private static boolean isAppAtLeastSdkVersion(Context context, String pkgName, int sdkVersion) {
        try {
            if (context.getPackageManager().getApplicationInfo(pkgName, 0).targetSdkVersion
                    >= sdkVersion) {
                return true;
            }
        } catch (PackageManager.NameNotFoundException e) {
            // In case of exception, assume known app (more strict checking)
            // Note: This case will never happen since checkPackage is
            // called to verify validity before checking app's version.
        }
        return false;
    }
}
