Java程序  |  276行  |  10.37 KB

/*
 * Copyright (C) 2016 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.dialer.location;

import android.Manifest.permission;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.location.Address;
import android.location.Geocoder;
import android.location.Location;
import android.location.LocationManager;
import android.preference.PreferenceManager;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
import android.support.v4.os.UserManagerCompat;
import android.telephony.TelephonyManager;
import android.text.TextUtils;
import com.android.dialer.common.Assert;
import com.android.dialer.common.LogUtil;
import com.android.dialer.common.concurrent.DialerExecutor.Worker;
import com.android.dialer.common.concurrent.DialerExecutorComponent;
import java.util.List;
import java.util.Locale;

/**
 * This class is used to detect the country where the user is. It is a simplified version of the
 * country detector service in the framework. The sources of country location are queried in the
 * following order of reliability:
 *
 * <ul>
 *   <li>Mobile network
 *   <li>Location manager
 *   <li>SIM's country
 *   <li>User's default locale
 * </ul>
 *
 * As far as possible this class tries to replicate the behavior of the system's country detector
 * service: 1) Order in priority of sources of country location 2) Mobile network information
 * provided by CDMA phones is ignored 3) Location information is updated every 12 hours (instead of
 * 24 hours in the system) 4) Location updates only uses the {@link
 * LocationManager#PASSIVE_PROVIDER} to avoid active use of the GPS 5) If a location is successfully
 * obtained and geocoded, we never fall back to use of the SIM's country (for the system, the
 * fallback never happens without a reboot) 6) Location is not used if the device does not implement
 * a {@link android.location.Geocoder}
 */
public class CountryDetector {
  private static final String KEY_PREFERENCE_TIME_UPDATED = "preference_time_updated";
  static final String KEY_PREFERENCE_CURRENT_COUNTRY = "preference_current_country";
  // Wait 12 hours between updates
  private static final long TIME_BETWEEN_UPDATES_MS = 1000L * 60 * 60 * 12;
  // Minimum distance before an update is triggered, in meters. We don't need this to be too
  // exact because all we care about is what country the user is in.
  private static final long DISTANCE_BETWEEN_UPDATES_METERS = 5000;
  // Used as a default country code when all the sources of country data have failed in the
  // exceedingly rare event that the device does not have a default locale set for some reason.
  private static final String DEFAULT_COUNTRY_ISO = "US";

  @VisibleForTesting public static CountryDetector instance;

  private final TelephonyManager telephonyManager;
  private final LocaleProvider localeProvider;
  private final Geocoder geocoder;
  private final Context appContext;

  @VisibleForTesting
  public CountryDetector(
      Context appContext,
      TelephonyManager telephonyManager,
      LocationManager locationManager,
      LocaleProvider localeProvider,
      Geocoder geocoder) {
    this.telephonyManager = telephonyManager;
    this.localeProvider = localeProvider;
    this.appContext = appContext;
    this.geocoder = geocoder;

    // If the device does not implement Geocoder there is no point trying to get location updates
    // because we cannot retrieve the country based on the location anyway.
    if (Geocoder.isPresent()) {
      registerForLocationUpdates(appContext, locationManager);
    }
  }

  @SuppressWarnings("missingPermission")
  private static void registerForLocationUpdates(Context context, LocationManager locationManager) {
    if (!hasLocationPermissions(context)) {
      LogUtil.w(
          "CountryDetector.registerForLocationUpdates",
          "no location permissions, not registering for location updates");
      return;
    }

    LogUtil.i("CountryDetector.registerForLocationUpdates", "registering for location updates");

    final Intent activeIntent = new Intent(context, LocationChangedReceiver.class);
    final PendingIntent pendingIntent =
        PendingIntent.getBroadcast(context, 0, activeIntent, PendingIntent.FLAG_UPDATE_CURRENT);

    locationManager.requestLocationUpdates(
        LocationManager.PASSIVE_PROVIDER,
        TIME_BETWEEN_UPDATES_MS,
        DISTANCE_BETWEEN_UPDATES_METERS,
        pendingIntent);
  }

  /** @return the single instance of the {@link CountryDetector} */
  public static synchronized CountryDetector getInstance(Context context) {
    if (instance == null) {
      Context appContext = context.getApplicationContext();
      instance =
          new CountryDetector(
              appContext,
              (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE),
              (LocationManager) context.getSystemService(Context.LOCATION_SERVICE),
              Locale::getDefault,
              new Geocoder(appContext));
    }
    return instance;
  }

  public String getCurrentCountryIso() {
    String result = null;
    if (isNetworkCountryCodeAvailable()) {
      result = getNetworkBasedCountryIso();
    }
    if (TextUtils.isEmpty(result)) {
      result = getLocationBasedCountryIso();
    }
    if (TextUtils.isEmpty(result)) {
      result = getSimBasedCountryIso();
    }
    if (TextUtils.isEmpty(result)) {
      result = getLocaleBasedCountryIso();
    }
    if (TextUtils.isEmpty(result)) {
      result = DEFAULT_COUNTRY_ISO;
    }
    return result.toUpperCase(Locale.US);
  }

  /** @return the country code of the current telephony network the user is connected to. */
  private String getNetworkBasedCountryIso() {
    return telephonyManager.getNetworkCountryIso();
  }

  /** @return the geocoded country code detected by the {@link LocationManager}. */
  @Nullable
  private String getLocationBasedCountryIso() {
    if (!Geocoder.isPresent()
        || !hasLocationPermissions(appContext)
        || !UserManagerCompat.isUserUnlocked(appContext)) {
      return null;
    }
    return PreferenceManager.getDefaultSharedPreferences(appContext)
        .getString(KEY_PREFERENCE_CURRENT_COUNTRY, null);
  }

  /** @return the country code of the SIM card currently inserted in the device. */
  private String getSimBasedCountryIso() {
    return telephonyManager.getSimCountryIso();
  }

  /** @return the country code of the user's currently selected locale. */
  private String getLocaleBasedCountryIso() {
    Locale defaultLocale = localeProvider.getLocale();
    if (defaultLocale != null) {
      return defaultLocale.getCountry();
    }
    return null;
  }

  private boolean isNetworkCountryCodeAvailable() {
    // On CDMA TelephonyManager.getNetworkCountryIso() just returns the SIM's country code.
    // In this case, we want to ignore the value returned and fallback to location instead.
    return telephonyManager.getPhoneType() == TelephonyManager.PHONE_TYPE_GSM;
  }

  /** Interface for accessing the current locale. */
  public interface LocaleProvider {
    Locale getLocale();
  }

  public static class LocationChangedReceiver extends BroadcastReceiver {

    @Override
    public void onReceive(final Context context, Intent intent) {
      if (!intent.hasExtra(LocationManager.KEY_LOCATION_CHANGED)) {
        return;
      }

      final Location location =
          (Location) intent.getExtras().get(LocationManager.KEY_LOCATION_CHANGED);

      // TODO: rething how we access the gecoder here, right now we have to set the static instance
      // of CountryDetector to make this work for tests which is weird
      // (see CountryDetectorTest.locationChangedBroadcast_GeocodesLocation)
      processLocationUpdate(context, CountryDetector.getInstance(context).geocoder, location);
    }
  }

  private static void processLocationUpdate(
      Context appContext, Geocoder geocoder, Location location) {
    DialerExecutorComponent.get(appContext)
        .dialerExecutorFactory()
        .createNonUiTaskBuilder(new GeocodeCountryWorker(geocoder))
        .onSuccess(
            country -> {
              if (country == null) {
                return;
              }

              PreferenceManager.getDefaultSharedPreferences(appContext)
                  .edit()
                  .putLong(CountryDetector.KEY_PREFERENCE_TIME_UPDATED, System.currentTimeMillis())
                  .putString(CountryDetector.KEY_PREFERENCE_CURRENT_COUNTRY, country)
                  .apply();
            })
        .onFailure(
            throwable ->
                LogUtil.w(
                    "CountryDetector.processLocationUpdate",
                    "exception occurred when getting geocoded country from location",
                    throwable))
        .build()
        .executeParallel(location);
  }

  /** Worker that given a {@link Location} returns an ISO 3166-1 two letter country code. */
  private static class GeocodeCountryWorker implements Worker<Location, String> {
    @NonNull private final Geocoder geocoder;

    GeocodeCountryWorker(@NonNull Geocoder geocoder) {
      this.geocoder = Assert.isNotNull(geocoder);
    }

    /** @return the ISO 3166-1 two letter country code if geocoded, else null */
    @Nullable
    @Override
    public String doInBackground(@Nullable Location location) throws Throwable {
      if (location == null) {
        return null;
      }

      List<Address> addresses =
          geocoder.getFromLocation(location.getLatitude(), location.getLongitude(), 1);
      if (addresses != null && !addresses.isEmpty()) {
        return addresses.get(0).getCountryCode();
      }
      return null;
    }
  }

  private static boolean hasLocationPermissions(Context context) {
    return context.checkSelfPermission(permission.ACCESS_FINE_LOCATION)
        == PackageManager.PERMISSION_GRANTED;
  }
}