/*
 * 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 com.android.server.location.geofence;

import static android.location.LocationManager.FUSED_PROVIDER;
import static android.location.LocationManager.KEY_PROXIMITY_ENTERING;

import static com.android.internal.util.ConcurrentUtils.DIRECT_EXECUTOR;
import static com.android.server.location.LocationPermissions.PERMISSION_FINE;

import android.annotation.Nullable;
import android.app.AppOpsManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.location.Geofence;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.location.LocationRequest;
import android.location.util.identity.CallerIdentity;
import android.os.Binder;
import android.os.PowerManager;
import android.os.SystemClock;
import android.os.WorkSource;
import android.stats.location.LocationStatsEnums;
import android.util.ArraySet;

import com.android.internal.annotations.GuardedBy;
import com.android.server.PendingIntentUtils;
import com.android.server.location.LocationPermissions;
import com.android.server.location.injector.Injector;
import com.android.server.location.injector.LocationPermissionsHelper;
import com.android.server.location.injector.LocationUsageLogger;
import com.android.server.location.injector.SettingsHelper;
import com.android.server.location.injector.UserInfoHelper;
import com.android.server.location.injector.UserInfoHelper.UserListener;
import com.android.server.location.listeners.ListenerMultiplexer;
import com.android.server.location.listeners.PendingIntentListenerRegistration;

import java.util.Collection;
import java.util.Objects;

/**
 * Manages all geofences.
 */
public class GeofenceManager extends
        ListenerMultiplexer<GeofenceKey, PendingIntent, GeofenceManager.GeofenceRegistration,
                LocationRequest> implements
        LocationListener {

    private static final String TAG = "GeofenceManager";

    private static final String ATTRIBUTION_TAG = "GeofencingService";

    private static final long WAKELOCK_TIMEOUT_MS = 30000;

    private static final int MAX_SPEED_M_S = 100;  // 360 km/hr (high speed train)
    private static final long MAX_LOCATION_AGE_MS = 5 * 60 * 1000L; // five minutes
    private static final long MAX_LOCATION_INTERVAL_MS = 2 * 60 * 60 * 1000; // two hours

    protected final class GeofenceRegistration extends
            PendingIntentListenerRegistration<Geofence, PendingIntent> {

        private static final int STATE_UNKNOWN = 0;
        private static final int STATE_INSIDE = 1;
        private static final int STATE_OUTSIDE = 2;

        private final Location mCenter;
        private final PowerManager.WakeLock mWakeLock;

        private int mGeofenceState;

        // we store these values because we don't trust the listeners not to give us dupes, not to
        // spam us, and because checking the values may be more expensive
        private boolean mPermitted;

        private @Nullable Location mCachedLocation;
        private float mCachedLocationDistanceM;

        protected GeofenceRegistration(Geofence geofence, CallerIdentity identity,
                PendingIntent pendingIntent) {
            super(geofence, identity, pendingIntent);

            mCenter = new Location("");
            mCenter.setLatitude(geofence.getLatitude());
            mCenter.setLongitude(geofence.getLongitude());

            mWakeLock = Objects.requireNonNull(mContext.getSystemService(PowerManager.class))
                    .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
                            TAG + ":" + identity.getPackageName());
            mWakeLock.setReferenceCounted(true);
            mWakeLock.setWorkSource(identity.addToWorkSource(null));
        }

        @Override
        protected GeofenceManager getOwner() {
            return GeofenceManager.this;
        }

        @Override
        protected void onPendingIntentListenerRegister() {
            mGeofenceState = STATE_UNKNOWN;
            mPermitted = mLocationPermissionsHelper.hasLocationPermissions(PERMISSION_FINE,
                    getIdentity());
        }

        @Override
        protected void onActive() {
            Location location = getLastLocation();
            if (location != null) {
                executeOperation(onLocationChanged(location));
            }
        }

        boolean isPermitted() {
            return mPermitted;
        }

        boolean onLocationPermissionsChanged(@Nullable String packageName) {
            if (packageName == null || getIdentity().getPackageName().equals(packageName)) {
                return onLocationPermissionsChanged();
            }

            return false;
        }

        boolean onLocationPermissionsChanged(int uid) {
            if (getIdentity().getUid() == uid) {
                return onLocationPermissionsChanged();
            }

            return false;
        }

        private boolean onLocationPermissionsChanged() {
            boolean permitted = mLocationPermissionsHelper.hasLocationPermissions(PERMISSION_FINE,
                    getIdentity());
            if (permitted != mPermitted) {
                mPermitted = permitted;
                return true;
            }

            return false;
        }

        double getDistanceToBoundary(Location location) {
            if (!location.equals(mCachedLocation)) {
                mCachedLocation = location;
                mCachedLocationDistanceM = mCenter.distanceTo(mCachedLocation);
            }

            return Math.abs(getRequest().getRadius() - mCachedLocationDistanceM);
        }

        ListenerOperation<PendingIntent> onLocationChanged(Location location) {
            // remove expired fences
            if (getRequest().isExpired()) {
                remove();
                return null;
            }

            mCachedLocation = location;
            mCachedLocationDistanceM = mCenter.distanceTo(mCachedLocation);

            int oldState = mGeofenceState;
            float radius = Math.max(getRequest().getRadius(), location.getAccuracy());
            if (mCachedLocationDistanceM <= radius) {
                mGeofenceState = STATE_INSIDE;
                if (oldState != STATE_INSIDE) {
                    return pendingIntent -> sendIntent(pendingIntent, true);
                }
            } else {
                mGeofenceState = STATE_OUTSIDE;
                if (oldState == STATE_INSIDE) {
                    // return exit only if previously entered
                    return pendingIntent -> sendIntent(pendingIntent, false);
                }
            }

            return null;
        }

        private void sendIntent(PendingIntent pendingIntent, boolean entering) {
            Intent intent = new Intent().putExtra(KEY_PROXIMITY_ENTERING, entering);

            mWakeLock.acquire(WAKELOCK_TIMEOUT_MS);
            try {
                // send() only enforces permissions for broadcast intents, but since clients can
                // select any kind of pending intent we do not rely on send() to enforce permissions
                pendingIntent.send(mContext, 0, intent, (pI, i, rC, rD, rE) -> mWakeLock.release(),
                        null, null, PendingIntentUtils.createDontSendToRestrictedAppsBundle(null));
            } catch (PendingIntent.CanceledException e) {
                mWakeLock.release();
                removeRegistration(new GeofenceKey(pendingIntent, getRequest()), this);
            }
        }

        @Override
        public String toString() {
            StringBuilder builder = new StringBuilder();
            builder.append(getIdentity());

            ArraySet<String> flags = new ArraySet<>(1);
            if (!mPermitted) {
                flags.add("na");
            }
            if (!flags.isEmpty()) {
                builder.append(" ").append(flags);
            }

            builder.append(" ").append(getRequest());
            return builder.toString();
        }
    }

    final Object mLock = new Object();

    protected final Context mContext;

    private final UserListener mUserChangedListener = this::onUserChanged;
    private final SettingsHelper.UserSettingChangedListener mLocationEnabledChangedListener =
            this::onLocationEnabledChanged;
    private final SettingsHelper.UserSettingChangedListener
            mLocationPackageBlacklistChangedListener =
            this::onLocationPackageBlacklistChanged;
    private final LocationPermissionsHelper.LocationPermissionsListener
            mLocationPermissionsListener =
            new LocationPermissionsHelper.LocationPermissionsListener() {
                @Override
                public void onLocationPermissionsChanged(@Nullable String packageName) {
                    GeofenceManager.this.onLocationPermissionsChanged(packageName);
                }

                @Override
                public void onLocationPermissionsChanged(int uid) {
                    GeofenceManager.this.onLocationPermissionsChanged(uid);
                }
            };

    protected final UserInfoHelper mUserInfoHelper;
    protected final LocationPermissionsHelper mLocationPermissionsHelper;
    protected final SettingsHelper mSettingsHelper;
    protected final LocationUsageLogger mLocationUsageLogger;

    @GuardedBy("mLock")
    private @Nullable LocationManager mLocationManager;

    @GuardedBy("mLock")
    private @Nullable Location mLastLocation;

    public GeofenceManager(Context context, Injector injector) {
        mContext = context.createAttributionContext(ATTRIBUTION_TAG);
        mUserInfoHelper = injector.getUserInfoHelper();
        mSettingsHelper = injector.getSettingsHelper();
        mLocationPermissionsHelper = injector.getLocationPermissionsHelper();
        mLocationUsageLogger = injector.getLocationUsageLogger();
    }

    @Override
    public String getTag() {
        return TAG;
    }

    private LocationManager getLocationManager() {
        synchronized (mLock) {
            if (mLocationManager == null) {
                mLocationManager = Objects.requireNonNull(
                        mContext.getSystemService(LocationManager.class));
            }

            return mLocationManager;
        }
    }

    /**
     * Adds a new geofence, replacing any geofence already associated with the PendingIntent. It
     * doesn't make any real sense to register multiple geofences with the same pending intent, but
     * we continue to allow this for backwards compatibility.
     */
    public void addGeofence(Geofence geofence, PendingIntent pendingIntent, String packageName,
            @Nullable String attributionTag) {
        LocationPermissions.enforceCallingOrSelfLocationPermission(mContext, PERMISSION_FINE);

        CallerIdentity identity = CallerIdentity.fromBinder(mContext, packageName,
                attributionTag, AppOpsManager.toReceiverId(pendingIntent));

        final long ident = Binder.clearCallingIdentity();
        try {
            putRegistration(new GeofenceKey(pendingIntent, geofence),
                    new GeofenceRegistration(geofence, identity, pendingIntent));
        } finally {
            Binder.restoreCallingIdentity(ident);
        }
    }

    /**
     * Removes the geofence associated with the PendingIntent.
     */
    public void removeGeofence(PendingIntent pendingIntent) {
        final long identity = Binder.clearCallingIdentity();
        try {
            removeRegistrationIf(key -> key.getPendingIntent().equals(pendingIntent));
        } finally {
            Binder.restoreCallingIdentity(identity);
        }
    }

    @Override
    protected boolean isActive(GeofenceRegistration registration) {
        return registration.isPermitted() && isActive(registration.getIdentity());
    }

    private boolean isActive(CallerIdentity identity) {
        if (identity.isSystemServer()) {
            if (!mSettingsHelper.isLocationEnabled(mUserInfoHelper.getCurrentUserId())) {
                return false;
            }
        } else {
            if (!mSettingsHelper.isLocationEnabled(identity.getUserId())) {
                return false;
            }
            if (!mUserInfoHelper.isCurrentUserId(identity.getUserId())) {
                return false;
            }
            if (mSettingsHelper.isLocationPackageBlacklisted(identity.getUserId(),
                    identity.getPackageName())) {
                return false;
            }
        }

        return true;
    }

    @Override
    protected void onRegister() {
        mUserInfoHelper.addListener(mUserChangedListener);
        mSettingsHelper.addOnLocationEnabledChangedListener(mLocationEnabledChangedListener);
        mSettingsHelper.addOnLocationPackageBlacklistChangedListener(
                mLocationPackageBlacklistChangedListener);
        mLocationPermissionsHelper.addListener(mLocationPermissionsListener);
    }

    @Override
    protected void onUnregister() {
        mUserInfoHelper.removeListener(mUserChangedListener);
        mSettingsHelper.removeOnLocationEnabledChangedListener(mLocationEnabledChangedListener);
        mSettingsHelper.removeOnLocationPackageBlacklistChangedListener(
                mLocationPackageBlacklistChangedListener);
        mLocationPermissionsHelper.removeListener(mLocationPermissionsListener);
    }

    @Override
    protected void onRegistrationAdded(GeofenceKey key, GeofenceRegistration registration) {
        mLocationUsageLogger.logLocationApiUsage(
                LocationStatsEnums.USAGE_ENDED,
                LocationStatsEnums.API_REQUEST_GEOFENCE,
                registration.getIdentity().getPackageName(),
                registration.getIdentity().getAttributionTag(),
                null,
                /* LocationRequest= */ null,
                /* hasListener= */ false,
                true,
                registration.getRequest(), true);
    }

    @Override
    protected void onRegistrationRemoved(GeofenceKey key, GeofenceRegistration registration) {
        mLocationUsageLogger.logLocationApiUsage(
                LocationStatsEnums.USAGE_ENDED,
                LocationStatsEnums.API_REQUEST_GEOFENCE,
                registration.getIdentity().getPackageName(),
                registration.getIdentity().getAttributionTag(),
                null,
                /* LocationRequest= */ null,
                /* hasListener= */ false,
                true,
                registration.getRequest(), true);
    }

    @Override
    protected boolean registerWithService(LocationRequest locationRequest,
            Collection<GeofenceRegistration> registrations) {
        getLocationManager().requestLocationUpdates(FUSED_PROVIDER, locationRequest,
                DIRECT_EXECUTOR, this);
        return true;
    }

    @Override
    protected void unregisterWithService() {
        synchronized (mLock) {
            getLocationManager().removeUpdates(this);
            mLastLocation = null;
        }
    }

    @Override
    protected LocationRequest mergeRegistrations(Collection<GeofenceRegistration> registrations) {
        Location location = getLastLocation();

        long realtimeMs = SystemClock.elapsedRealtime();

        WorkSource workSource = null;
        double minFenceDistanceM = Double.MAX_VALUE;
        for (GeofenceRegistration registration : registrations) {
            if (registration.getRequest().isExpired(realtimeMs)) {
                continue;
            }

            workSource = registration.getIdentity().addToWorkSource(workSource);

            if (location != null) {
                double fenceDistanceM = registration.getDistanceToBoundary(location);
                if (fenceDistanceM < minFenceDistanceM) {
                    minFenceDistanceM = fenceDistanceM;
                }
            }
        }

        long intervalMs;
        if (Double.compare(minFenceDistanceM, Double.MAX_VALUE) < 0) {
            intervalMs = (long) Math.min(MAX_LOCATION_INTERVAL_MS,
                    Math.max(
                            mSettingsHelper.getBackgroundThrottleProximityAlertIntervalMs(),
                            minFenceDistanceM * 1000 / MAX_SPEED_M_S));
        } else {
            intervalMs = mSettingsHelper.getBackgroundThrottleProximityAlertIntervalMs();
        }

        return new LocationRequest.Builder(intervalMs)
                .setMinUpdateIntervalMillis(0)
                .setHiddenFromAppOps(true)
                .setWorkSource(workSource)
                .build();
    }


    @Override
    public void onLocationChanged(Location location) {
        synchronized (mLock) {
            mLastLocation = location;
        }

        deliverToListeners(registration -> {
            return registration.onLocationChanged(location);
        });
        updateService();
    }

    @Nullable Location getLastLocation() {
        Location location;
        synchronized (mLock) {
            location = mLastLocation;
        }

        if (location == null) {
            location = getLocationManager().getLastLocation();
        }

        if (location != null) {
            if (location.getElapsedRealtimeAgeMillis() > MAX_LOCATION_AGE_MS) {
                location = null;
            }
        }

        return location;
    }

    void onUserChanged(int userId, int change) {
        if (change == UserListener.CURRENT_USER_CHANGED) {
            updateRegistrations(registration -> registration.getIdentity().getUserId() == userId);
        }
    }

    void onLocationEnabledChanged(int userId) {
        updateRegistrations(registration -> registration.getIdentity().getUserId() == userId);
    }

    void onLocationPackageBlacklistChanged(int userId) {
        updateRegistrations(registration -> registration.getIdentity().getUserId() == userId);
    }

    void onLocationPermissionsChanged(@Nullable String packageName) {
        updateRegistrations(registration -> registration.onLocationPermissionsChanged(packageName));
    }

    void onLocationPermissionsChanged(int uid) {
        updateRegistrations(registration -> registration.onLocationPermissionsChanged(uid));
    }
}
