chromium/chrome/browser/ui/android/omnibox/java/src/org/chromium/chrome/browser/omnibox/geo/GeolocationTracker.java

// Copyright 2015 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.chrome.browser.omnibox.geo;

import android.Manifest;
import android.content.Context;
import android.content.pm.PackageManager;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Bundle;
import android.os.Handler;
import android.os.Process;

import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.ThreadUtils;
import org.chromium.base.TraceEvent;

/**
 * Keeps track of the device's location, allowing synchronous location requests.
 * getLastKnownLocation() returns the current best estimate of the location. If possible, call
 * refreshLastKnownLocation() several seconds before a location is needed to maximize the chances
 * that the location is known.
 */
class GeolocationTracker {

    private static SelfCancelingListener sListener;
    private static Location sNetworkLocationForTesting;
    private static Location sGpsLocationForTesting;
    private static boolean sUseLocationForTesting;
    private static long sLocationAgeForTesting;
    private static boolean sUseLocationAgeForTesting;

    private static class SelfCancelingListener implements LocationListener {

        // Length of time before the location request should be canceled. This timeout ensures the
        // device doesn't get stuck in an infinite loop trying and failing to get a location, which
        // would cause battery drain. See: http://crbug.com/309917
        private static final int REQUEST_TIMEOUT_MS = 60 * 1000; // 60 sec.

        private final LocationManager mLocationManager;
        private final Handler mHandler;
        private final Runnable mCancelRunnable;

        private boolean mRegistrationFailed;

        private SelfCancelingListener(LocationManager manager) {
            mLocationManager = manager;
            mHandler = new Handler();
            mCancelRunnable =
                    new Runnable() {
                        @Override
                        public void run() {
                            try {
                                mLocationManager.removeUpdates(SelfCancelingListener.this);
                            } catch (Exception e) {
                                if (!mRegistrationFailed) throw e;
                            }
                            sListener = null;
                        }
                    };
            mHandler.postDelayed(mCancelRunnable, REQUEST_TIMEOUT_MS);
        }

        private void markRegistrationFailed() {
            mRegistrationFailed = true;
        }

        @Override
        public void onLocationChanged(Location location) {
            mHandler.removeCallbacks(mCancelRunnable);
            sListener = null;
        }

        @Override
        public void onProviderDisabled(String provider) {}

        @Override
        public void onProviderEnabled(String provider) {}

        @Override
        public void onStatusChanged(String provider, int status, Bundle extras) {}
    }

    /**
     * Returns the age of location is milliseconds. Note: the age will be invalid if the system
     * clock has been changed since the location was created. If the apparent age is negative,
     * Long.MAX_VALUE will be returned.
     */
    static long getLocationAge(Location location) {
        if (sUseLocationAgeForTesting) return sLocationAgeForTesting;
        long age = System.currentTimeMillis() - location.getTime();
        return age >= 0 ? age : Long.MAX_VALUE;
    }

    /** Returns the last known location or null if none is available. */
    static Location getLastKnownLocation(Context context) {
        try (TraceEvent e = TraceEvent.scoped("GeolocationTracker.getLastKnownLocation")) {
            if (sUseLocationForTesting) {
                return chooseLocation(sNetworkLocationForTesting, sGpsLocationForTesting);
            }

            if (!hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)) {
                // Do not call location manager without permissions
                return null;
            }

            LocationManager locationManager =
                    (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
            Location networkLocation =
                    locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER);
            Location gpsLocation = null;
            if (hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)) {
                // Only try to get GPS location when ACCESS_FINE_LOCATION is granted.
                gpsLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);
            }
            return chooseLocation(networkLocation, gpsLocation);
        }
    }

    /**
     * Requests an updated location if the last known location is older than maxAge milliseconds.
     *
     * <p>Note: this must be called only on the UI thread.
     */
    static void refreshLastKnownLocation(Context context, long maxAge) {
        ThreadUtils.assertOnUiThread();

        if (!hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)) {
            return;
        }

        // We're still waiting for a location update.
        if (sListener != null) return;

        LocationManager locationManager =
                (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
        Location location = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER);
        if (location == null || getLocationAge(location) > maxAge) {
            String provider = LocationManager.NETWORK_PROVIDER;
            if (locationManager.isProviderEnabled(provider)) {
                sListener = new SelfCancelingListener(locationManager);
                try {
                    locationManager.requestSingleUpdate(provider, sListener, null);
                } catch (NullPointerException ex) {
                    // https://crbug.com/819730: This can trigger an NPE due to a underlying
                    // OS/framework bug.  By ignoring this, we will not get a newer location age.
                    sListener.markRegistrationFailed();
                }
            }
        }
    }

    static void setLocationForTesting(
            Location networkLocationForTesting, Location gpsLocationForTesting) {
        sNetworkLocationForTesting = networkLocationForTesting;
        sGpsLocationForTesting = gpsLocationForTesting;
        sUseLocationForTesting = true;
    }

    static void setLocationAgeForTesting(Long locationAgeForTesting) {
        if (locationAgeForTesting == null) {
            sUseLocationAgeForTesting = false;
            return;
        }
        sLocationAgeForTesting = locationAgeForTesting;
        sUseLocationAgeForTesting = true;
    }

    private static boolean hasPermission(Context context, String permission) {
        return ApiCompatibilityUtils.checkPermission(
                        context, permission, Process.myPid(), Process.myUid())
                == PackageManager.PERMISSION_GRANTED;
    }

    private static Location chooseLocation(Location networkLocation, Location gpsLocation) {
        if (gpsLocation == null) {
            return networkLocation;
        }

        if (networkLocation == null) {
            return gpsLocation;
        }

        // Both are not null, take the younger one.
        return networkLocation.getTime() > gpsLocation.getTime() ? networkLocation : gpsLocation;
    }
}