// 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.pm.PackageManager;
import android.location.Location;
import android.net.Uri;
import android.os.Process;
import android.os.SystemClock;
import android.provider.Settings;
import android.util.Base64;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.core.util.ObjectsCompat;
import com.google.android.gms.location.FusedLocationProviderClient;
import com.google.android.gms.location.Granularity;
import com.google.android.gms.location.LocationListener;
import com.google.android.gms.location.LocationRequest;
import com.google.android.gms.location.LocationServices;
import com.google.android.gms.location.Priority;
import org.jni_zero.CalledByNative;
import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.CollectionUtil;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.TraceEvent;
import org.chromium.chrome.browser.omnibox.geo.VisibleNetworks.VisibleCell;
import org.chromium.chrome.browser.omnibox.geo.VisibleNetworks.VisibleWifi;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.components.browser_ui.site_settings.PermissionInfo;
import org.chromium.components.browser_ui.site_settings.WebsitePreferenceBridge;
import org.chromium.components.content_settings.ContentSettingValues;
import org.chromium.components.content_settings.ContentSettingsType;
import org.chromium.components.embedder_support.util.UrlConstants;
import org.chromium.components.embedder_support.util.UrlUtilitiesJni;
import org.chromium.components.omnibox.OmniboxFeatures;
import org.chromium.components.search_engines.TemplateUrlService;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.time.Duration;
import java.util.Locale;
import java.util.Set;
/**
* Provides methods for building the X-Geo HTTP header, which provides device location to a server
* when making an HTTP request.
*
* <p>X-Geo header spec: https://goto.google.com/xgeospec.
*/
public class GeolocationHeader {
private static final String TAG = "GeolocationHeader";
@IntDef({
UmaPermission.UNKNOWN,
UmaPermission.HIGH_ACCURACY_APP_YES_DOMAIN_YES_LOCATION,
UmaPermission.HIGH_ACCURACY_APP_YES_DOMAIN_YES_NO_LOCATION,
UmaPermission.HIGH_ACCURACY_APP_YES_DOMAIN_PROMPT_LOCATION,
UmaPermission.HIGH_ACCURACY_APP_YES_DOMAIN_PROMPT_NO_LOCATION,
UmaPermission.HIGH_ACCURACY_APP_YES_DOMAIN_BLOCKED,
UmaPermission.HIGH_ACCURACY_APP_PROMPT_DOMAIN_YES,
UmaPermission.HIGH_ACCURACY_APP_PROMPT_DOMAIN_PROMPT,
UmaPermission.HIGH_ACCURACY_APP_PROMPT_DOMAIN_BLOCKED,
UmaPermission.HIGH_ACCURACY_APP_BLOCKED_DOMAIN_YES,
UmaPermission.HIGH_ACCURACY_APP_BLOCKED_DOMAIN_PROMPT,
UmaPermission.HIGH_ACCURACY_APP_BLOCKED_DOMAIN_BLOCKED,
UmaPermission.BATTERY_SAVING_APP_YES_DOMAIN_YES_LOCATION,
UmaPermission.BATTERY_SAVING_APP_YES_DOMAIN_YES_NO_LOCATION,
UmaPermission.BATTERY_SAVING_APP_YES_DOMAIN_PROMPT_LOCATION,
UmaPermission.BATTERY_SAVING_APP_YES_DOMAIN_PROMPT_NO_LOCATION,
UmaPermission.BATTERY_SAVING_APP_YES_DOMAIN_BLOCKED,
UmaPermission.BATTERY_SAVING_APP_PROMPT_DOMAIN_YES,
UmaPermission.BATTERY_SAVING_APP_PROMPT_DOMAIN_PROMPT,
UmaPermission.BATTERY_SAVING_APP_PROMPT_DOMAIN_BLOCKED,
UmaPermission.BATTERY_SAVING_APP_BLOCKED_DOMAIN_YES,
UmaPermission.BATTERY_SAVING_APP_BLOCKED_DOMAIN_PROMPT,
UmaPermission.BATTERY_SAVING_APP_BLOCKED_DOMAIN_BLOCKED,
UmaPermission.GPS_ONLY_APP_YES_DOMAIN_YES_LOCATION,
UmaPermission.GPS_ONLY_APP_YES_DOMAIN_YES_NO_LOCATION,
UmaPermission.GPS_ONLY_APP_YES_DOMAIN_PROMPT_LOCATION,
UmaPermission.GPS_ONLY_APP_YES_DOMAIN_PROMPT_NO_LOCATION,
UmaPermission.GPS_ONLY_APP_YES_DOMAIN_BLOCKED,
UmaPermission.GPS_ONLY_APP_PROMPT_DOMAIN_YES,
UmaPermission.GPS_ONLY_APP_PROMPT_DOMAIN_PROMPT,
UmaPermission.GPS_ONLY_APP_PROMPT_DOMAIN_BLOCKED,
UmaPermission.GPS_ONLY_APP_BLOCKED_DOMAIN_YES,
UmaPermission.GPS_ONLY_APP_BLOCKED_DOMAIN_PROMPT,
UmaPermission.GPS_ONLY_APP_BLOCKED_DOMAIN_BLOCKED,
UmaPermission.LOCATION_OFF_APP_YES_DOMAIN_YES,
UmaPermission.LOCATION_OFF_APP_YES_DOMAIN_PROMPT,
UmaPermission.LOCATION_OFF_APP_YES_DOMAIN_BLOCKED,
UmaPermission.LOCATION_OFF_APP_PROMPT_DOMAIN_YES,
UmaPermission.LOCATION_OFF_APP_PROMPT_DOMAIN_PROMPT,
UmaPermission.LOCATION_OFF_APP_PROMPT_DOMAIN_BLOCKED,
UmaPermission.LOCATION_OFF_APP_BLOCKED_DOMAIN_YES,
UmaPermission.LOCATION_OFF_APP_BLOCKED_DOMAIN_PROMPT,
UmaPermission.LOCATION_OFF_APP_BLOCKED_DOMAIN_BLOCKED,
UmaPermission.UNSUITABLE_URL,
UmaPermission.NOT_HTTPS
})
@Retention(RetentionPolicy.SOURCE)
public @interface UmaPermission {
// Values for the histogram Geolocation.Header.PermissionState.
// These are used to back an UMA histogram and so should be treated as append-only.
//
// In order to keep the names of constants from being too long, the following were used:
// APP_YES (instead of APP_GRANTED) to indicate App permission granted,
// DOMAIN_YES (instead of DOMAIN_GRANTED) to indicate Domain permission granted.
int UNKNOWN = 0;
int HIGH_ACCURACY_APP_YES_DOMAIN_YES_LOCATION = 1;
int HIGH_ACCURACY_APP_YES_DOMAIN_YES_NO_LOCATION = 2;
int HIGH_ACCURACY_APP_YES_DOMAIN_PROMPT_LOCATION = 3;
int HIGH_ACCURACY_APP_YES_DOMAIN_PROMPT_NO_LOCATION = 4;
int HIGH_ACCURACY_APP_YES_DOMAIN_BLOCKED = 5;
int HIGH_ACCURACY_APP_PROMPT_DOMAIN_YES = 6;
int HIGH_ACCURACY_APP_PROMPT_DOMAIN_PROMPT = 7;
int HIGH_ACCURACY_APP_PROMPT_DOMAIN_BLOCKED = 8;
int HIGH_ACCURACY_APP_BLOCKED_DOMAIN_YES = 9;
int HIGH_ACCURACY_APP_BLOCKED_DOMAIN_PROMPT = 10;
int HIGH_ACCURACY_APP_BLOCKED_DOMAIN_BLOCKED = 11;
int BATTERY_SAVING_APP_YES_DOMAIN_YES_LOCATION = 12;
int BATTERY_SAVING_APP_YES_DOMAIN_YES_NO_LOCATION = 13;
int BATTERY_SAVING_APP_YES_DOMAIN_PROMPT_LOCATION = 14;
int BATTERY_SAVING_APP_YES_DOMAIN_PROMPT_NO_LOCATION = 15;
int BATTERY_SAVING_APP_YES_DOMAIN_BLOCKED = 16;
int BATTERY_SAVING_APP_PROMPT_DOMAIN_YES = 17;
int BATTERY_SAVING_APP_PROMPT_DOMAIN_PROMPT = 18;
int BATTERY_SAVING_APP_PROMPT_DOMAIN_BLOCKED = 19;
int BATTERY_SAVING_APP_BLOCKED_DOMAIN_YES = 20;
int BATTERY_SAVING_APP_BLOCKED_DOMAIN_PROMPT = 21;
int BATTERY_SAVING_APP_BLOCKED_DOMAIN_BLOCKED = 22;
int GPS_ONLY_APP_YES_DOMAIN_YES_LOCATION = 23;
int GPS_ONLY_APP_YES_DOMAIN_YES_NO_LOCATION = 24;
int GPS_ONLY_APP_YES_DOMAIN_PROMPT_LOCATION = 25;
int GPS_ONLY_APP_YES_DOMAIN_PROMPT_NO_LOCATION = 26;
int GPS_ONLY_APP_YES_DOMAIN_BLOCKED = 27;
int GPS_ONLY_APP_PROMPT_DOMAIN_YES = 28;
int GPS_ONLY_APP_PROMPT_DOMAIN_PROMPT = 29;
int GPS_ONLY_APP_PROMPT_DOMAIN_BLOCKED = 30;
int GPS_ONLY_APP_BLOCKED_DOMAIN_YES = 31;
int GPS_ONLY_APP_BLOCKED_DOMAIN_PROMPT = 32;
int GPS_ONLY_APP_BLOCKED_DOMAIN_BLOCKED = 33;
int LOCATION_OFF_APP_YES_DOMAIN_YES = 34;
int LOCATION_OFF_APP_YES_DOMAIN_PROMPT = 35;
int LOCATION_OFF_APP_YES_DOMAIN_BLOCKED = 36;
int LOCATION_OFF_APP_PROMPT_DOMAIN_YES = 37;
int LOCATION_OFF_APP_PROMPT_DOMAIN_PROMPT = 38;
int LOCATION_OFF_APP_PROMPT_DOMAIN_BLOCKED = 39;
int LOCATION_OFF_APP_BLOCKED_DOMAIN_YES = 40;
int LOCATION_OFF_APP_BLOCKED_DOMAIN_PROMPT = 41;
int LOCATION_OFF_APP_BLOCKED_DOMAIN_BLOCKED = 42;
int UNSUITABLE_URL = 43;
int NOT_HTTPS = 44;
int NUM_ENTRIES = 45;
}
@IntDef({
LocationSource.HIGH_ACCURACY,
LocationSource.BATTERY_SAVING,
LocationSource.GPS_ONLY,
LocationSource.LOCATION_OFF
})
@Retention(RetentionPolicy.SOURCE)
public @interface LocationSource {
@VisibleForTesting int HIGH_ACCURACY = 0;
@VisibleForTesting int BATTERY_SAVING = 1;
@VisibleForTesting int GPS_ONLY = 2;
@VisibleForTesting int LOCATION_OFF = 3;
}
@IntDef({Permission.GRANTED, Permission.PROMPT, Permission.BLOCKED})
@Retention(RetentionPolicy.SOURCE)
private @interface Permission {
int GRANTED = 0;
int PROMPT = 1;
int BLOCKED = 2;
}
@IntDef({
HeaderState.HEADER_ENABLED,
HeaderState.INCOGNITO,
HeaderState.UNSUITABLE_URL,
HeaderState.NOT_HTTPS,
HeaderState.LOCATION_PERMISSION_BLOCKED
})
@Retention(RetentionPolicy.SOURCE)
private @interface HeaderState {
int HEADER_ENABLED = 0;
int INCOGNITO = 1;
int UNSUITABLE_URL = 2;
int NOT_HTTPS = 3;
int LOCATION_PERMISSION_BLOCKED = 4;
}
/** The maximum age in milliseconds of a location that we'll send in an X-Geo header. */
private static final int MAX_LOCATION_AGE = 24 * 60 * 60 * 1000; // 24 hours
/** The maximum age in milliseconds of a location before we'll request a refresh. */
@VisibleForTesting static final int REFRESH_LOCATION_AGE = 5 * 60 * 1000; // 5 minutes
// 9 minutes is just below the 10 minute threshold to be considered fresh by the search backend.
@VisibleForTesting
static final long LOCATION_REQUEST_UPDATE_INTERVAL = Duration.ofMinutes(9).toMillis();
// Timeout requests after 30 minutes if we somehow fail to remove our listener.
@VisibleForTesting
static final long LOCATION_REQUEST_UPDATE_MAX_DURATION = Duration.ofMinutes(30).toMillis();
/** The X-Geo header prefix, preceding any location descriptors */
private static final String XGEO_HEADER_PREFIX = "X-Geo:";
/**
* The location descriptor separator used in the X-Geo header to separate encoding prefix, and
* encoded descriptors
*/
private static final String LOCATION_SEPARATOR = " ";
/** The location descriptor prefix used in the X-Geo header to specify a proto wire encoding */
private static final String LOCATION_PROTO_PREFIX = "w";
/** The time of the first location refresh. Contains Long.MAX_VALUE if not set. */
private static long sFirstLocationTime = Long.MAX_VALUE;
/** Present in WiFi SSID that should not be mapped */
private static final String SSID_NOMAP = "_nomap";
/** Present in WiFi SSID that opted out */
private static final String SSID_OPTOUT = "_optout";
private static int sLocationSourceForTesting;
private static boolean sUseLocationSourceForTesting;
private static boolean sAppPermissionGrantedForTesting;
private static boolean sUseAppPermissionGrantedForTesting;
private static Location sFusedLocation;
private static final LocationListener sLocationListener = GeolocationHeader::onLocationUpate;
private static boolean sCurrentLocationRequested;
private static final String DUMMY_URL_QUERY = "some_query";
/**
* Requests a location refresh so that a valid location will be available for constructing an
* X-Geo header in the near future (i.e. within 5 minutes). Checks whether the header can
* actually be sent before requesting the location refresh. If UseFusedLocationProvider is true,
* this starts listening for location updates on a regular interval rather than triggering a
* single refresh.
*/
public static void primeLocationForGeoHeaderIfEnabled(
Profile profile, TemplateUrlService templateService) {
if (profile == null) return;
if (!hasGeolocationPermission()) return;
if (!isGeoHeaderEnabledForDSE(profile, templateService)) return;
if (sFirstLocationTime == Long.MAX_VALUE) {
sFirstLocationTime = SystemClock.elapsedRealtime();
}
VisibleNetworksTracker.refreshVisibleNetworks(ContextUtils.getApplicationContext());
boolean listeningForFusedLocationProviderUpdates =
OmniboxFeatures.sUseFusedLocationProvider.isEnabled()
&& startListeningForLocationUpdates();
if (!listeningForFusedLocationProviderUpdates) {
GeolocationTracker.refreshLastKnownLocation(
ContextUtils.getApplicationContext(), REFRESH_LOCATION_AGE);
}
}
/**
* Attempts to start listening for location updates on a LOCATION_REQUEST_UPDATE_INTERVAL (9
* minute) interval, returning true if the request to listen succeeded. Locations are requested
* to be less than five minutes old and have a granularity matching the app's permission level.
*/
private static boolean startListeningForLocationUpdates() {
if (sCurrentLocationRequested) return true;
try (TraceEvent e =
TraceEvent.scoped("GeolocationHeader.startListeningForLocationUpdates")) {
FusedLocationProviderClient fusedLocationClient =
LocationServices.getFusedLocationProviderClient(
ContextUtils.getApplicationContext());
var locationRequest =
new LocationRequest.Builder(LOCATION_REQUEST_UPDATE_INTERVAL)
.setDurationMillis(LOCATION_REQUEST_UPDATE_MAX_DURATION)
.setMaxUpdateAgeMillis(REFRESH_LOCATION_AGE)
.setPriority(Priority.PRIORITY_BALANCED_POWER_ACCURACY)
.setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL)
.build();
fusedLocationClient.requestLocationUpdates(locationRequest, sLocationListener, null);
sCurrentLocationRequested = true;
} catch (RuntimeException e) {
// GMSCore may not exist on the device or be very old. Return false and trigger fallback
// behavior.
sCurrentLocationRequested = false;
}
return sCurrentLocationRequested;
}
/** Stop requesting and listening for location updates from FusedLocationProvider. */
public static void stopListeningForLocationUpdates() {
if (!sCurrentLocationRequested) return;
FusedLocationProviderClient fusedLocationClient =
LocationServices.getFusedLocationProviderClient(
ContextUtils.getApplicationContext());
fusedLocationClient.removeLocationUpdates(sLocationListener);
sCurrentLocationRequested = false;
}
private static void onLocationUpate(Location location) {
sFusedLocation = location;
}
private static boolean isGeoHeaderEnabledForDSE(
Profile profile, TemplateUrlService templateService) {
return geoHeaderStateForUrl(profile, templateService.getUrlForSearchQuery(DUMMY_URL_QUERY))
== HeaderState.HEADER_ENABLED;
}
private static @HeaderState int geoHeaderStateForUrl(Profile profile, String url) {
try (TraceEvent e = TraceEvent.scoped("GeolocationHeader.geoHeaderStateForUrl")) {
// Only send X-Geo in normal mode.
if (profile.isOffTheRecord()) return HeaderState.INCOGNITO;
// Only send X-Geo header to Google domains.
if (!UrlUtilitiesJni.get().isGoogleSearchUrl(url)) return HeaderState.UNSUITABLE_URL;
Uri uri = Uri.parse(url);
if (!UrlConstants.HTTPS_SCHEME.equals(uri.getScheme())) return HeaderState.NOT_HTTPS;
if (!hasGeolocationPermission()) {
return HeaderState.LOCATION_PERMISSION_BLOCKED;
}
// Only send X-Geo header if the user hasn't disabled geolocation for url.
if (isLocationDisabledForUrl(profile, uri)) {
return HeaderState.LOCATION_PERMISSION_BLOCKED;
}
return HeaderState.HEADER_ENABLED;
}
}
/**
* Returns an X-Geo HTTP header string if:
*
* <ul>
* <li>The current mode is not incognito,
* <li>The url is a google search URL (e.g. www.google.co.uk/search?q=cars),
* <li>The user has not disabled sharing location with this url, and
* <li>There is a valid and recent location available.
* </ul>
*
* <p>Returns null otherwise.
*
* @param url The URL of the request with which this header will be sent.
* @param tab The Tab currently being accessed.
* @return The X-Geo header string or null.
*/
public static @Nullable String getGeoHeader(String url, Tab tab) {
return getGeoHeader(url, tab.getProfile(), tab);
}
/**
* Returns an X-Geo HTTP header string if:
*
* <ul>
* <li>The current mode is not incognito,
* <li>The url is a google search URL (e.g. www.google.co.uk/search?q=cars),
* <li>The user has not disabled sharing location with this url, and
* <li>There is a valid and recent location available.
* </ul>
*
* <p>Returns null otherwise. This will never prompt for location access.
*
* @param url The URL of the request with which this header will be sent.
* @param profile The Tab currently being accessed.
* @return The X-Geo header string or null.
*/
@SuppressWarnings("unused")
@CalledByNative
public static @Nullable String getGeoHeader(String url, Profile profile) {
if (profile == null) return null;
Tab tab = null;
return getGeoHeader(url, profile, tab);
}
/**
* Returns an X-Geo HTTP header string if:
*
* <ul>
* <li>The current mode is not incognito,
* <li>The url is a google search URL (e.g. www.google.co.uk/search?q=cars),
* <li>The user has not disabled sharing location with this url, and
* <li>There is a valid and recent location available.
* </ul>
*
* <p>Returns null otherwise.
*
* @param url The URL of the request with which this header will be sent.
* @param profile The user profile being accessed.
* @param tab The Tab currently being accessed. Can be null, in which case, location permissions
* will never prompt.
* @return The X-Geo header string or null.
*/
private static @Nullable String getGeoHeader(String url, Profile profile, Tab tab) {
try (TraceEvent e = TraceEvent.scoped("GeolocationHeader.getGeoHeader")) {
Location locationToAttach = null;
VisibleNetworks visibleNetworksToAttach = null;
long locationAge = Long.MAX_VALUE;
@HeaderState int headerState = geoHeaderStateForUrl(profile, url);
if (headerState == HeaderState.HEADER_ENABLED) {
locationToAttach = getLastKnownLocation();
if (locationToAttach != null) {
locationAge = GeolocationTracker.getLocationAge(locationToAttach);
if (locationAge > MAX_LOCATION_AGE) {
// Do not attach the location
locationToAttach = null;
}
}
// The header state is enabled, so this means we have app permissions, and the url
// is allowed to receive location. Before attempting to attach visible networks,
// check if network-based location is enabled.
if (isNetworkLocationEnabled() && !isLocationFresh(locationToAttach)) {
visibleNetworksToAttach =
VisibleNetworksTracker.getLastKnownVisibleNetworks(
ContextUtils.getApplicationContext());
}
}
// Proto encoding
String locationProtoEncoding = encodeProtoLocation(locationToAttach);
String visibleNetworksProtoEncoding =
encodeProtoVisibleNetworks(visibleNetworksToAttach);
if (locationProtoEncoding == null && visibleNetworksProtoEncoding == null) return null;
StringBuilder header = new StringBuilder(XGEO_HEADER_PREFIX);
if (locationProtoEncoding != null) {
header.append(LOCATION_SEPARATOR)
.append(LOCATION_PROTO_PREFIX)
.append(LOCATION_SEPARATOR)
.append(locationProtoEncoding);
}
if (visibleNetworksProtoEncoding != null) {
header.append(LOCATION_SEPARATOR)
.append(LOCATION_PROTO_PREFIX)
.append(LOCATION_SEPARATOR)
.append(visibleNetworksProtoEncoding);
}
return header.toString();
}
}
@SuppressWarnings("unused")
@CalledByNative
static boolean hasGeolocationPermission() {
if (sUseAppPermissionGrantedForTesting) return sAppPermissionGrantedForTesting;
int pid = Process.myPid();
int uid = Process.myUid();
if (ApiCompatibilityUtils.checkPermission(
ContextUtils.getApplicationContext(),
Manifest.permission.ACCESS_COARSE_LOCATION,
pid,
uid)
!= PackageManager.PERMISSION_GRANTED) {
return false;
}
return true;
}
/**
* Returns the app level geolocation permission. This permission can be either granted, blocked
* or prompt.
*/
static @Permission int getGeolocationPermission(Tab tab) {
try (TraceEvent e = TraceEvent.scoped("GeolocationHeader.getGeolocationPermission")) {
if (sUseAppPermissionGrantedForTesting) {
return sAppPermissionGrantedForTesting ? Permission.GRANTED : Permission.BLOCKED;
}
if (hasGeolocationPermission()) return Permission.GRANTED;
return (tab != null
&& tab.getWindowAndroid()
.canRequestPermission(
Manifest.permission.ACCESS_COARSE_LOCATION))
? Permission.PROMPT
: Permission.BLOCKED;
}
}
/**
* Returns true if the user has disabled sharing their location with url (e.g. via the
* geolocation infobar).
*/
static boolean isLocationDisabledForUrl(Profile profile, Uri uri) {
// TODO(raymes): The call to isDSEOrigin is only needed if this could be called for
// an origin that isn't the default search engine. Otherwise remove this line.
boolean isDSEOrigin = WebsitePreferenceBridge.isDSEOrigin(profile, uri.toString());
@ContentSettingValues
@Nullable
Integer settingValue = locationContentSettingForUrl(profile, uri);
boolean enabled = isDSEOrigin && settingValue == ContentSettingValues.ALLOW;
return !enabled;
}
/**
* Returns the location permission for sharing their location with url (e.g. via the geolocation
* infobar).
*/
static @ContentSettingValues @Nullable Integer locationContentSettingForUrl(
Profile profile, Uri uri) {
return PermissionInfo.getContentSetting(
profile, ContentSettingsType.GEOLOCATION, uri.toString(), null);
}
static void setLocationSourceForTesting(int locationSourceForTesting) {
sLocationSourceForTesting = locationSourceForTesting;
sUseLocationSourceForTesting = true;
}
static void setAppPermissionGrantedForTesting(boolean appPermissionGrantedForTesting) {
sAppPermissionGrantedForTesting = appPermissionGrantedForTesting;
sUseAppPermissionGrantedForTesting = true;
}
static long getFirstLocationTimeForTesting() {
return sFirstLocationTime;
}
@VisibleForTesting
static Location getLastKnownLocation() {
if (OmniboxFeatures.sUseFusedLocationProvider.isEnabled() && sFusedLocation != null) {
return sFusedLocation;
}
return GeolocationTracker.getLastKnownLocation(ContextUtils.getApplicationContext());
}
/** Returns the location source. */
private static @LocationSource int getLocationSource() {
try (TraceEvent te = TraceEvent.scoped("GeolocationHeader.getLocationSource")) {
if (sUseLocationSourceForTesting) return sLocationSourceForTesting;
int locationMode;
try {
locationMode =
Settings.Secure.getInt(
ContextUtils.getApplicationContext().getContentResolver(),
Settings.Secure.LOCATION_MODE);
} catch (Settings.SettingNotFoundException e) {
Log.e(TAG, "Error getting the LOCATION_MODE");
return LocationSource.LOCATION_OFF;
}
if (locationMode == Settings.Secure.LOCATION_MODE_HIGH_ACCURACY) {
return LocationSource.HIGH_ACCURACY;
} else if (locationMode == Settings.Secure.LOCATION_MODE_SENSORS_ONLY) {
return LocationSource.GPS_ONLY;
} else if (locationMode == Settings.Secure.LOCATION_MODE_BATTERY_SAVING) {
return LocationSource.BATTERY_SAVING;
} else {
return LocationSource.LOCATION_OFF;
}
}
}
private static boolean isNetworkLocationEnabled() {
int locationSource = getLocationSource();
return locationSource == LocationSource.HIGH_ACCURACY
|| locationSource == LocationSource.BATTERY_SAVING;
}
private static boolean isLocationFresh(@Nullable Location location) {
return location != null
&& GeolocationTracker.getLocationAge(location) <= REFRESH_LOCATION_AGE;
}
/**
* Returns the domain permission as either granted, blocked or prompt. This is based upon the
* location permission for sharing their location with url (e.g. via the geolocation infobar).
*/
private static @Permission int getDomainPermission(Profile profile, String url) {
try (TraceEvent e = TraceEvent.scoped("GeolocationHeader.getDomainPermission")) {
@ContentSettingValues
@Nullable
Integer domainPermission = locationContentSettingForUrl(profile, Uri.parse(url));
switch (domainPermission) {
case ContentSettingValues.ALLOW:
return Permission.GRANTED;
case ContentSettingValues.ASK:
return Permission.PROMPT;
default:
return Permission.BLOCKED;
}
}
}
/**
* Returns the enum to use in the Geolocation.Header.PermissionState histogram. Unexpected input
* values return UmaPermission.UNKNOWN.
*/
private static @UmaPermission int getPermissionHistogramEnum(
@LocationSource int locationSource,
@Permission int appPermission,
@Permission int domainPermission,
boolean locationAttached,
@HeaderState int headerState) {
if (headerState == HeaderState.UNSUITABLE_URL) return UmaPermission.UNSUITABLE_URL;
if (headerState == HeaderState.NOT_HTTPS) return UmaPermission.NOT_HTTPS;
if (locationSource == LocationSource.HIGH_ACCURACY) {
if (appPermission == Permission.GRANTED) {
if (domainPermission == Permission.GRANTED) {
return locationAttached
? UmaPermission.HIGH_ACCURACY_APP_YES_DOMAIN_YES_LOCATION
: UmaPermission.HIGH_ACCURACY_APP_YES_DOMAIN_YES_NO_LOCATION;
} else if (domainPermission == Permission.PROMPT) {
return locationAttached
? UmaPermission.HIGH_ACCURACY_APP_YES_DOMAIN_PROMPT_LOCATION
: UmaPermission.HIGH_ACCURACY_APP_YES_DOMAIN_PROMPT_NO_LOCATION;
} else if (domainPermission == Permission.BLOCKED) {
return UmaPermission.HIGH_ACCURACY_APP_YES_DOMAIN_BLOCKED;
}
} else if (appPermission == Permission.PROMPT) {
if (domainPermission == Permission.GRANTED) {
return UmaPermission.HIGH_ACCURACY_APP_PROMPT_DOMAIN_YES;
} else if (domainPermission == Permission.PROMPT) {
return UmaPermission.HIGH_ACCURACY_APP_PROMPT_DOMAIN_PROMPT;
} else if (domainPermission == Permission.BLOCKED) {
return UmaPermission.HIGH_ACCURACY_APP_PROMPT_DOMAIN_BLOCKED;
}
} else if (appPermission == Permission.BLOCKED) {
if (domainPermission == Permission.GRANTED) {
return UmaPermission.HIGH_ACCURACY_APP_BLOCKED_DOMAIN_YES;
} else if (domainPermission == Permission.PROMPT) {
return UmaPermission.HIGH_ACCURACY_APP_BLOCKED_DOMAIN_PROMPT;
} else if (domainPermission == Permission.BLOCKED) {
return UmaPermission.HIGH_ACCURACY_APP_BLOCKED_DOMAIN_BLOCKED;
}
}
} else if (locationSource == LocationSource.BATTERY_SAVING) {
if (appPermission == Permission.GRANTED) {
if (domainPermission == Permission.GRANTED) {
return locationAttached
? UmaPermission.BATTERY_SAVING_APP_YES_DOMAIN_YES_LOCATION
: UmaPermission.BATTERY_SAVING_APP_YES_DOMAIN_YES_NO_LOCATION;
} else if (domainPermission == Permission.PROMPT) {
return locationAttached
? UmaPermission.BATTERY_SAVING_APP_YES_DOMAIN_PROMPT_LOCATION
: UmaPermission.BATTERY_SAVING_APP_YES_DOMAIN_PROMPT_NO_LOCATION;
} else if (domainPermission == Permission.BLOCKED) {
return UmaPermission.BATTERY_SAVING_APP_YES_DOMAIN_BLOCKED;
}
} else if (appPermission == Permission.PROMPT) {
if (domainPermission == Permission.GRANTED) {
return UmaPermission.BATTERY_SAVING_APP_PROMPT_DOMAIN_YES;
} else if (domainPermission == Permission.PROMPT) {
return UmaPermission.BATTERY_SAVING_APP_PROMPT_DOMAIN_PROMPT;
} else if (domainPermission == Permission.BLOCKED) {
return UmaPermission.BATTERY_SAVING_APP_PROMPT_DOMAIN_BLOCKED;
}
} else if (appPermission == Permission.BLOCKED) {
if (domainPermission == Permission.GRANTED) {
return UmaPermission.BATTERY_SAVING_APP_BLOCKED_DOMAIN_YES;
} else if (domainPermission == Permission.PROMPT) {
return UmaPermission.BATTERY_SAVING_APP_BLOCKED_DOMAIN_PROMPT;
} else if (domainPermission == Permission.BLOCKED) {
return UmaPermission.BATTERY_SAVING_APP_BLOCKED_DOMAIN_BLOCKED;
}
}
} else if (locationSource == LocationSource.GPS_ONLY) {
if (appPermission == Permission.GRANTED) {
if (domainPermission == Permission.GRANTED) {
return locationAttached
? UmaPermission.GPS_ONLY_APP_YES_DOMAIN_YES_LOCATION
: UmaPermission.GPS_ONLY_APP_YES_DOMAIN_YES_NO_LOCATION;
} else if (domainPermission == Permission.PROMPT) {
return locationAttached
? UmaPermission.GPS_ONLY_APP_YES_DOMAIN_PROMPT_LOCATION
: UmaPermission.GPS_ONLY_APP_YES_DOMAIN_PROMPT_NO_LOCATION;
} else if (domainPermission == Permission.BLOCKED) {
return UmaPermission.GPS_ONLY_APP_YES_DOMAIN_BLOCKED;
}
} else if (appPermission == Permission.PROMPT) {
if (domainPermission == Permission.GRANTED) {
return UmaPermission.GPS_ONLY_APP_PROMPT_DOMAIN_YES;
} else if (domainPermission == Permission.PROMPT) {
return UmaPermission.GPS_ONLY_APP_PROMPT_DOMAIN_PROMPT;
} else if (domainPermission == Permission.BLOCKED) {
return UmaPermission.GPS_ONLY_APP_PROMPT_DOMAIN_BLOCKED;
}
} else if (appPermission == Permission.BLOCKED) {
if (domainPermission == Permission.GRANTED) {
return UmaPermission.GPS_ONLY_APP_BLOCKED_DOMAIN_YES;
} else if (domainPermission == Permission.PROMPT) {
return UmaPermission.GPS_ONLY_APP_BLOCKED_DOMAIN_PROMPT;
} else if (domainPermission == Permission.BLOCKED) {
return UmaPermission.GPS_ONLY_APP_BLOCKED_DOMAIN_BLOCKED;
}
}
} else if (locationSource == LocationSource.LOCATION_OFF) {
if (appPermission == Permission.GRANTED) {
if (domainPermission == Permission.GRANTED) {
return UmaPermission.LOCATION_OFF_APP_YES_DOMAIN_YES;
} else if (domainPermission == Permission.PROMPT) {
return UmaPermission.LOCATION_OFF_APP_YES_DOMAIN_PROMPT;
} else if (domainPermission == Permission.BLOCKED) {
return UmaPermission.LOCATION_OFF_APP_YES_DOMAIN_BLOCKED;
}
} else if (appPermission == Permission.PROMPT) {
if (domainPermission == Permission.GRANTED) {
return UmaPermission.LOCATION_OFF_APP_PROMPT_DOMAIN_YES;
} else if (domainPermission == Permission.PROMPT) {
return UmaPermission.LOCATION_OFF_APP_PROMPT_DOMAIN_PROMPT;
} else if (domainPermission == Permission.BLOCKED) {
return UmaPermission.LOCATION_OFF_APP_PROMPT_DOMAIN_BLOCKED;
}
} else if (appPermission == Permission.BLOCKED) {
if (domainPermission == Permission.GRANTED) {
return UmaPermission.LOCATION_OFF_APP_BLOCKED_DOMAIN_YES;
} else if (domainPermission == Permission.PROMPT) {
return UmaPermission.LOCATION_OFF_APP_BLOCKED_DOMAIN_PROMPT;
} else if (domainPermission == Permission.BLOCKED) {
return UmaPermission.LOCATION_OFF_APP_BLOCKED_DOMAIN_BLOCKED;
}
}
}
return UmaPermission.UNKNOWN;
}
/** Encodes location into proto encoding. */
@Nullable
@VisibleForTesting
static String encodeProtoLocation(@Nullable Location location) {
if (location == null) return null;
// Timestamp in microseconds since the UNIX epoch.
long timestamp = location.getTime() * 1000;
// Latitude times 1e7.
int latitudeE7 = (int) (location.getLatitude() * 10000000);
// Longitude times 1e7.
int longitudeE7 = (int) (location.getLongitude() * 10000000);
// Radius of 68% accuracy in mm.
int radius = (int) (location.getAccuracy() * 1000);
// Create a LatLng for the coordinates.
PartnerLocationDescriptor.LatLng latlng =
PartnerLocationDescriptor.LatLng.newBuilder()
.setLatitudeE7(latitudeE7)
.setLongitudeE7(longitudeE7)
.build();
// Populate a LocationDescriptor with the LatLng.
PartnerLocationDescriptor.LocationDescriptor locationDescriptor =
PartnerLocationDescriptor.LocationDescriptor.newBuilder()
.setLatlng(latlng)
// Include role, producer, timestamp and radius.
.setRole(PartnerLocationDescriptor.LocationRole.CURRENT_LOCATION)
.setProducer(PartnerLocationDescriptor.LocationProducer.DEVICE_LOCATION)
.setTimestamp(timestamp)
.setRadius((float) radius)
.build();
return encodeLocationDescriptor(locationDescriptor);
}
/** Encodes the given proto location descriptor into a BASE64 URL_SAFE encoding. */
private static String encodeLocationDescriptor(
PartnerLocationDescriptor.LocationDescriptor locationDescriptor) {
return Base64.encodeToString(
locationDescriptor.toByteArray(), Base64.NO_WRAP | Base64.URL_SAFE);
}
/** Encodes visible networks in proto encoding. */
@Nullable
@VisibleForTesting
static String encodeProtoVisibleNetworks(@Nullable VisibleNetworks visibleNetworks) {
VisibleNetworks visibleNetworksToEncode = trimVisibleNetworks(visibleNetworks);
if (visibleNetworksToEncode == null || visibleNetworksToEncode.isEmpty()) {
// No data to encode.
return null;
}
VisibleWifi connectedWifi = visibleNetworksToEncode.connectedWifi();
VisibleCell connectedCell = visibleNetworksToEncode.connectedCell();
Set<VisibleWifi> visibleWifis = visibleNetworksToEncode.allVisibleWifis();
Set<VisibleCell> visibleCells = visibleNetworksToEncode.allVisibleCells();
PartnerLocationDescriptor.LocationDescriptor.Builder locationDescriptorBuilder =
PartnerLocationDescriptor.LocationDescriptor.newBuilder()
.setRole(PartnerLocationDescriptor.LocationRole.CURRENT_LOCATION)
.setProducer(PartnerLocationDescriptor.LocationProducer.DEVICE_LOCATION);
if (connectedWifi != null) {
locationDescriptorBuilder.addVisibleNetwork(connectedWifi.toProto(true));
}
if (visibleWifis != null) {
for (VisibleWifi visibleWifi : visibleWifis) {
locationDescriptorBuilder.addVisibleNetwork(visibleWifi.toProto(false));
}
}
if (connectedCell != null) {
locationDescriptorBuilder.addVisibleNetwork(connectedCell.toProto(true));
}
if (visibleCells != null) {
for (VisibleCell visibleCell : visibleCells) {
locationDescriptorBuilder.addVisibleNetwork(visibleCell.toProto(false));
}
}
return encodeLocationDescriptor(locationDescriptorBuilder.build());
}
@Nullable
@VisibleForTesting
static VisibleNetworks trimVisibleNetworks(@Nullable VisibleNetworks visibleNetworks) {
if (visibleNetworks == null || visibleNetworks.isEmpty()) {
return null;
}
// Trim visible networks to only include a limited number of visible not-conntected networks
// based on flag.
VisibleCell connectedCell = visibleNetworks.connectedCell();
VisibleWifi connectedWifi = visibleNetworks.connectedWifi();
Set<VisibleCell> visibleCells = visibleNetworks.allVisibleCells();
Set<VisibleWifi> visibleWifis = visibleNetworks.allVisibleWifis();
VisibleCell extraVisibleCell = null;
VisibleWifi extraVisibleWifi = null;
if (shouldExcludeVisibleWifi(connectedWifi)) {
// Trim the connected wifi.
connectedWifi = null;
}
// Select the extra visible cell.
if (visibleCells != null) {
for (VisibleCell candidateCell : visibleCells) {
if (ObjectsCompat.equals(connectedCell, candidateCell)) {
// Do not include this candidate cell, since its already the connected one.
continue;
}
// Add it and since we only want one, stop iterating over other cells.
extraVisibleCell = candidateCell;
break;
}
}
// Select the extra visible wifi.
if (visibleWifis != null) {
for (VisibleWifi candidateWifi : visibleWifis) {
if (shouldExcludeVisibleWifi(candidateWifi)) {
// Do not include this candidate wifi.
continue;
}
if (ObjectsCompat.equals(connectedWifi, candidateWifi)) {
// Replace the connected, since the candidate will have level. This is because
// the android APIs exposing connected WIFI do not expose level, while the ones
// exposing visible wifis expose level.
connectedWifi = candidateWifi;
// Do not include this candidate wifi, since its already the connected one.
continue;
}
// Keep the one with stronger level (since it's negative, this is the smaller value)
if (extraVisibleWifi == null || extraVisibleWifi.level() > candidateWifi.level()) {
extraVisibleWifi = candidateWifi;
}
}
}
if (connectedCell == null
&& connectedWifi == null
&& extraVisibleCell == null
&& extraVisibleWifi == null) {
return null;
}
return VisibleNetworks.create(
connectedWifi,
connectedCell,
extraVisibleWifi != null ? CollectionUtil.newHashSet(extraVisibleWifi) : null,
extraVisibleCell != null ? CollectionUtil.newHashSet(extraVisibleCell) : null);
}
/**
* Returns whether the provided {@link VisibleWifi} should be excluded. This can happen if the
* network is opted out (ssid contains "_nomap" or "_optout").
*/
private static boolean shouldExcludeVisibleWifi(@Nullable VisibleWifi visibleWifi) {
if (visibleWifi == null || visibleWifi.bssid() == null) {
return true;
}
String ssid = visibleWifi.ssid();
if (ssid == null) {
// No ssid, so the networks is not opted out and should not be excluded.
return false;
}
// Optimization to avoid costly toLowerCase() in most cases.
if (ssid.indexOf('_') < 0) {
// No "_nomap" or "_optout".
return false;
}
String ssidLowerCase = ssid.toLowerCase(Locale.ENGLISH);
return ssidLowerCase.contains(SSID_NOMAP) || ssidLowerCase.contains(SSID_OPTOUT);
}
}